Compare commits

...

5 Commits

Author SHA1 Message Date
kronic 5dced78de2 Docs: 2026-08-13 — Track A (camera) shipped alongside Track B
Session log gains Part 3: the operator picked "drop" on the hold fork, and both
halves of the camera fix landed. Records the measurement that forced the second
half — dropping the hold ALONE was worse (max deviation 0.653 vs 0.447) because the
hold had been masking the shake-integration bug — plus the before/after table and
the PixelArtDevControls opt-in.

Gotchas archive gains four more entries: never let a transient offset live in the
value your smoothing filter reads back; a frame-counted hold is a framerate-
dependent freeze; a masking fix can make the metric worse before better, so
re-measure the intermediate state; HideFlags.DontSave objects survive play-mode
exit and leak one per domain reload.

CLAUDE.md gains a Camera-feel ★ line, paid for by trimming the MCP-edit, swept-hit,
LANTERN-direction, harvest, bootstrap and presentation bullets. Now 39936 bytes —
exactly the >=1 KB headroom target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:09:53 -07:00
kronic 150f30f56c Fix: PixelArtDevControls is opt-in — it was running OnGUI in Game.unity every frame
The editor-only pixel-art tuner self-spawned via [RuntimeInitializeOnLoadMethod] +
DontDestroyOnLoad into EVERY scene, not just DevSandbox, and its OnGUI draws the
toggle button on every IMGUI pass BEFORE the `!_open` early-out. IMGUI dispatches
at least Layout+Repaint per frame, so it allocated continuously during normal
gameplay — it is a MonoBehaviour, so an ECS system-toggle sweep never touched it,
and it dominated this session's allocation profiling until it was found.

Now gated behind an EditorPrefs opt-in with a checked menu toggle, mirroring
`ProjectM/Boot Into Menu (Editor)`. Off by default.

Separately: because the object carries HideFlags.DontSave it SURVIVES play-mode
exit while the static _instance does not, so the old code leaked one instance per
domain reload — two live strays were found and cleared in the editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:03:15 -07:00
kronic 4bd00818e1 Fix: the combat "freeze on a kill" — camera shake channel + drop the position hold
This is what the operator's report was actually describing, and it was never a CPU
hitch: median frame was 12.2 ms with a kill costing ~15 ms, wave respawn is not a
spike, and enemies carry no PhysicsCollider so corpses never blocked.

Two bugs in PrototypeCameraRig.LateUpdate, both measured with a STATIONARY player
so the ideal camera motion is exactly zero and everything observed is artifact:

1. Shake was integrated by the follow filter. The smoothing ran as
   Lerp(transform.position, desired, k), and transform.position already contained
   last frame's random shake — so the filter treated shake as real positional error
   and corrected only ~9 %/frame. Shake was never subtracted, just slowly lerped out
   while new shake piled on, so in sustained combat the camera random-walked around
   its framing and never settled. One kill left it 0.447 units off-frame and still
   0.199 off 24 frames later. The follow now smooths a _basePos that shake never
   touches; shake is applied only when writing the transform.

2. The position HOLD froze the follow for a fixed number of RENDER frames on every
   kill and heavy hit (2, or 7 on a finisher), making the freeze framerate-dependent
   — 49 ms at 144 fps, 233 ms at 30 fps — and it took its base from
   transform.position, permanently baking that frame's shake in as a 0.283-unit
   single-frame jump. A camera that stops while the world keeps moving reads as a
   hitch, not as crunch. Dropped; impact rides the FOV punch + shake alone
   (operator's call). HitStopMaxFrames / HitStopFreezeEnabled / FinisherHoldFrames
   retire with it, and the three saved feel profiles drop the dead keys.

Worth recording: dropping the hold ALONE measured slightly worse (max deviation
0.653 vs 0.447) — the hold had been partly masking the shake integration. Both
fixes together: deviation returns to 0.000 within a few frames of a kill, mean
deviation 0.038 across the window. The 0.428 peak is now the shake impulse itself,
a transient punch that immediately returns instead of a drift that lingers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:03:06 -07:00
kronic e0c59ad663 Perf: pool one-shot SFX + authored VFX, cut per-frame presentation allocation
Track B. All 21 one-shot cues funnelled through FeedbackFx.PlayClip ->
AudioSource.PlayClipAtPoint, which allocates a GameObject + AudioSource per call
and schedules a delayed Destroy — ~20-33 times a second in light combat. New
OneShotAudioPool is a 32-voice 3D ring behind an UNCHANGED PlayClip signature, so
all 20 consuming call sites are untouched.

Parity is the whole game here: PlayClipAtPoint sets spatialBlend = 1 explicitly (a
fresh AudioSource is 2D) and leaves the rest at stock defaults. Two deliberate
divergences, both forced by the voices being long-lived: playOnAwake = false, and
dopplerLevel = 0 because a pooled voice TELEPORTS between events and would
otherwise pitch-bend. Root is DontDestroyOnLoad (WorldLauncher does
LoadScene(Single) while the client world is alive) with a SubsystemRegistration
reset, or session two rents destroyed voices.

Authored impact VFX are pooled per prefab instead of Instantiate/Destroy per hit:
components cached per INSTANCE (refs are instance-scoped), main.stopAction forced
to None (a prefab set to Destroy silently drains the pool), instances filled under
an inactive root so Awake/Start never run — which is what makes the DestroyImmediate
in StripCosmetic safe — ps.Clear before Play, TrailRenderer.Clear after the
reposition, and a Rented flag as the at-most-once guard against a double Return
aliasing one instance to two callers.

Per-frame allocation: the slash-arc and enemy-wedge mesh builders each allocated
four arrays on every call (up to twice a frame, and once per winding enemy); HUD
and ability-bar labels rebuilt their strings every frame; damage-number fades
rewrote TextMesh vertex colours every frame; health bars pushed uGUI writes
unconditionally; two systems played back an empty EntityCommandBuffer (a
structural-change sync point) every frame.

Also closes an AudioClip leak across all seven clip-owning systems: an
AudioClip.Create'd clip is a standalone UnityEngine.Object, so destroying a
system's FX root left it alive (MusicSystem ~6.8 MB, AmbientAudioSystem ~2 MB per
client-world teardown).

CombatFeedbackSystem's TryHold call sites go with this commit because they share
the file; the camera-side removal lands in the next one.

Verified live: PlayClipAtPoint's "One shot audio" GameObject never appears again
across 270 frames of combat with kills; the VFX pool fills to its retain cap and
stabilises; real cues route through the ring. 304/304 EditMode green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:02:50 -07:00
kronic bdeee3c51a Docs: 2026-08-13 session — combat freeze diagnosis + Track B allocation pass
Session log for the "freezes on a kill" diagnosis (the cause is the camera, not
CPU: shake is integrated by the follow filter, the hold is frame-counted, and the
hit + kill packages stack on the lethal frame) and for the Track B allocation work
the operator selected.

Gotchas archive gains nine dated entries, incl. two that changed how the work was
ranked: GC.GetTotalMemory quantises to 4 KB and is useless for allocation
attribution (use ProfilerRecorder), and an editor-only IMGUI dev tool self-spawns
into Game.unity and dominated every editor-side measurement.

CLAUDE.md: one new pooling/per-frame-cost line, paid for under the net-zero rule by
dropping the DR-051 build-palette text (that code died with the purge), the two
purge enumerations now carried by their DRs, and a duplicated ACES clause.
40183 bytes, under the 40960 budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:39:30 -07:00
24 changed files with 983 additions and 144 deletions
@@ -21,11 +21,33 @@ namespace ProjectM.Client
static PixelArtDevControls _instance;
/// <summary>
/// OPT-IN EditorPref, mirroring `ProjectM/Boot Into Menu (Editor)`. This used to self-spawn into EVERY
/// scene — Game.unity included, not just DevSandbox — and <see cref="OnGUI"/> draws its toggle button on
/// every IMGUI pass BEFORE the `!_open` early-out. IMGUI dispatches at least Layout+Repaint per frame,
/// so it allocated continuously during normal gameplay and dominated allocation profiling until it was
/// found on 2026-08-13. Off by default; enable from the menu when you actually want to tune the look.
/// </summary>
const string EnabledPref = "ProjectM.PixelArtDevControls.Enabled";
const string MenuPath = "ProjectM/Pixel Art Dev Controls (Editor)";
[MenuItem(MenuPath)]
static void ToggleEnabled() => EditorPrefs.SetBool(EnabledPref, !EditorPrefs.GetBool(EnabledPref, false));
[MenuItem(MenuPath, true)]
static bool ToggleEnabledValidate()
{
Menu.SetChecked(MenuPath, EditorPrefs.GetBool(EnabledPref, false));
return true;
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Bootstrap()
{
if (_instance != null)
return;
if (!EditorPrefs.GetBool(EnabledPref, false))
return; // opt-in only — see EnabledPref
var go = new GameObject("~PixelArtDevControls") { hideFlags = HideFlags.HideAndDontSave };
DontDestroyOnLoad(go);
_instance = go.AddComponent<PixelArtDevControls>();
@@ -41,6 +41,14 @@ namespace ProjectM.Client
readonly byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
readonly int[] _prevRemaining = new int[SlotCount];
readonly float[] _flashUntil = new float[SlotCount];
// Track B: last countdown value rendered per slot, in tenths of a second (-1 = blank). Gates the
// float formatting in UpdateSlotCooldown, which otherwise ran 5x per frame.
// Seeded to int.MinValue, not the default 0: 0 is a REAL value (a cooldown under ~1/20 s rounds to
// 0 tenths), and a default-0 array would silently skip that first render.
readonly int[] _shownDeci = FilledWith(SlotCount, int.MinValue);
readonly bool[] _shownWhole = new bool[SlotCount]; // which countdown FORMAT is currently rendered per slot
static int[] FilledWith(int n, int v) { var a = new int[n]; for (int i = 0; i < n; i++) a[i] = v; return a; }
static readonly Color EmptyCol = new(1f, 1f, 1f, 0.22f);
static readonly Color ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f);
@@ -125,9 +133,27 @@ namespace ProjectM.Client
{
float frac = math.saturate(remaining / (float)total);
_cdOverlay[slot].style.height = Length.Percent(frac * 100f);
_countdown[slot].text = remaining > 0
? (remaining < 597 ? (remaining / 60f).ToString("0.0") : Mathf.CeilToInt(remaining / 60f).ToString())
: "";
// Track B: this ran for all 5 slots EVERY frame and formatted a float each time. The readout only
// has a tenth-of-a-second resolution (6 ticks), so quantise first and only format on a real change.
//
// The rendered text is derived from `deci`, NEVER re-derived from `remaining`. Deriving it twice is a
// trap: Mathf.RoundToInt rounds half-to-EVEN while ToString("0.0") rounds half-AWAY-from-zero, so the
// gate and the string disagreed at midpoints and the label latched a stale, too-high reading and
// skipped a tenth on every cooldown. CeilToInt also means the readout never reads LOWER than the true
// remainder. `whole` is cached alongside deci because the two branches can produce the same deci
// (596 ticks -> 100 -> "9.9" vs 597 ticks -> 100 -> "10") and the format must still switch.
bool whole = remaining >= 597;
int deci = remaining > 0
? (whole ? Mathf.CeilToInt(remaining / 60f) * 10 : Mathf.CeilToInt(remaining / 6f))
: -1;
if (deci != _shownDeci[slot] || whole != _shownWhole[slot])
{
_shownDeci[slot] = deci;
_shownWhole[slot] = whole;
_countdown[slot].text = deci < 0 ? "" : (whole ? (deci / 10).ToString() : (deci * 0.1f).ToString("0.0"));
}
bool empty = slot < SocketId.Count && _shownSpark[slot] == 0;
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);
@@ -58,6 +58,12 @@ namespace ProjectM.Client
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
// The AudioSources die with _root, but the clips they played do NOT — see FeedbackFx.DestroyClip.
// _ambientClip alone is ~1.4 MB of native audio per client-world teardown.
FeedbackFx.DestroyClip(ref _ambientClip);
FeedbackFx.DestroyClip(ref _groanClip);
FeedbackFx.DestroyClip(ref _stingBeep);
FeedbackFx.DestroyClip(ref _stingRoar);
}
protected override void OnUpdate()
@@ -68,6 +68,7 @@ namespace ProjectM.Client
if (_cloudMesh != null) Object.Destroy(_cloudMesh);
if (_critterMat != null) Object.Destroy(_critterMat);
if (_cloudMat != null) Object.Destroy(_cloudMat);
FeedbackFx.DestroyClip(ref _thunderClip); // not owned by _root — see FeedbackFx.DestroyClip
}
protected override void OnUpdate()
@@ -46,7 +46,7 @@ namespace ProjectM.Client
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
readonly List<TimedVfx> _activeVfx = new();
readonly Dictionary<Entity, GameObject> _projTrails = new();
readonly Dictionary<Entity, VfxInstance> _projTrails = new();
readonly HashSet<Entity> _projSeen = new();
readonly List<Entity> _projStale = new();
@@ -65,6 +65,16 @@ namespace ProjectM.Client
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height)
// Track B: BuildSlashInto used to allocate four arrays (~1.7 KB) on EVERY call, and it runs twice a
// frame for the local arc + smear plus once per live remote swing. The segment count is a compile-time
// constant, so the sizes never vary — fill these in place instead. UVs/triangles are argument-independent
// (built once, guarded by _arcStaticsBuilt) and are uploaded to each mesh only on its first fill.
const int ArcSeg = 16;
readonly Vector3[] _arcVerts = new Vector3[(ArcSeg + 1) * 2];
readonly Color[] _arcCols = new Color[(ArcSeg + 1) * 2];
readonly Vector2[] _arcUvs = new Vector2[(ArcSeg + 1) * 2];
readonly int[] _arcTris = new int[ArcSeg * 6];
bool _arcStaticsBuilt;
uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none)
int _pendingConnectStep;
uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none)
@@ -75,7 +85,6 @@ namespace ProjectM.Client
float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on
int _stressBeat;
#endif
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
@@ -116,7 +125,28 @@ namespace ProjectM.Client
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal)
struct TimedVfx { public GameObject Go; public double Kill; }
// Track B: a pooled VFX instance. Component arrays are cached PER INSTANCE — component references are
// instance-scoped, so arrays captured off the prefab ASSET would drive the asset, not the clone.
class VfxInstance
{
public GameObject Go;
public Transform Tr;
public ParticleSystem[] Systems;
public TrailRenderer[] Trails;
public GameObject Prefab; // the stack this instance returns to; never re-read from VFXConfig
public bool Rented; // at-most-once guard: a double Return would alias one instance to two callers
}
struct TimedVfx { public VfxInstance Inst; public double Kill; }
// Per-prefab pool of inactive instances, plus the two values that ARE legitimately per-prefab. Instance
// fields, never static: the Kill deadlines come from the per-world SystemAPI.Time.ElapsedTime, which
// restarts at 0 for each session world.
readonly Dictionary<GameObject, Stack<VfxInstance>> _vfxPool = new();
readonly Dictionary<GameObject, double> _vfxLifetime = new();
readonly Dictionary<GameObject, Vector3> _vfxPrefabScale = new();
Transform _vfxFillRoot; // INACTIVE parent: instances fill here so Awake/Start never run
const int VfxPerPrefabRetain = 10; // retained inactive instances per prefab; destroy beyond it
protected override void OnCreate()
{
@@ -161,6 +191,10 @@ namespace ProjectM.Client
protected override void OnDestroy()
{
// The SFX ring is DontDestroyOnLoad (it must outlive LoadScene(Single)), so a world teardown
// would otherwise bleed in-flight combat cues straight into the main menu.
OneShotAudioPool.SilenceAll();
if (_fxRoot != null)
Object.Destroy(_fxRoot.gameObject);
if (_slashMesh != null) Object.Destroy(_slashMesh);
@@ -168,6 +202,18 @@ namespace ProjectM.Client
if (_smearMesh != null) Object.Destroy(_smearMesh);
if (_smearMat != null) Object.Destroy(_smearMat);
// See FeedbackFx.DestroyClip: an AudioClip.Create'd clip is not owned by _fxRoot, so all ten leaked
// on every client-world teardown. The four sibling clip-owning systems do the same in their OnDestroy.
FeedbackFx.DestroyClip(ref _hitClip);
FeedbackFx.DestroyClip(ref _deathClip);
FeedbackFx.DestroyClip(ref _fireClip);
FeedbackFx.DestroyClip(ref _telegraphClip);
FeedbackFx.DestroyClip(ref _dashClip);
FeedbackFx.DestroyClip(ref _swingClip);
FeedbackFx.DestroyClip(ref _meleeConnectClip);
for (int i = 0; i < _footstepClips.Length; i++) FeedbackFx.DestroyClip(ref _footstepClips[i]);
foreach (var kv in _remoteSlashes)
{
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
@@ -178,6 +224,8 @@ namespace ProjectM.Client
}
protected override void OnUpdate()
{
float dt = SystemAPI.Time.DeltaTime;
@@ -246,7 +294,6 @@ namespace ProjectM.Client
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
@@ -271,7 +318,6 @@ namespace ProjectM.Client
PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
@@ -338,7 +384,6 @@ namespace ProjectM.Client
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
@@ -695,24 +740,124 @@ namespace ProjectM.Client
void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot)
{
if (prefab == null || _fxRoot == null) return;
if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC
var go = Object.Instantiate(prefab, pos, rot, _fxRoot);
go.transform.position = pos;
StripCosmetic(go);
var systems = go.GetComponentsInChildren<ParticleSystem>();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) });
if (_activeVfx.Count >= MaxActiveVfx) return; // in-flight cap: unchanged, this bounds live particles too
var inst = RentVfx(prefab, pos, rot);
if (inst == null) return;
_activeVfx.Add(new TimedVfx { Inst = inst, Kill = SystemAPI.Time.ElapsedTime + VfxLifetimeFor(prefab) });
// Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour).
DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f);
}
/// <summary>
/// Track B: take an instance from the per-prefab pool (or fill a new one) instead of Instantiating.
/// Order matters — transform BEFORE the particle restart, because a world-space ParticleSystem would
/// otherwise re-show the previous burst's particles at their OLD positions for a frame.
/// </summary>
VfxInstance RentVfx(GameObject prefab, Vector3 pos, Quaternion rot)
{
VfxInstance inst = null;
if (_vfxPool.TryGetValue(prefab, out var stack))
{
while (stack.Count > 0) // null-skip: a pooled entry destroyed out from under us is discarded
{
var cand = stack.Pop();
if (cand != null && cand.Go != null) { inst = cand; break; }
}
}
inst ??= FillVfx(prefab);
if (inst == null) return null;
var tr = inst.Tr;
tr.SetParent(_fxRoot, false);
tr.SetPositionAndRotation(pos, rot);
tr.localScale = _vfxPrefabScale.TryGetValue(prefab, out var s) ? s : Vector3.one; // never inherit the last rent's scale
inst.Go.SetActive(true);
for (int i = 0; i < inst.Trails.Length; i++)
if (inst.Trails[i] != null) inst.Trails[i].Clear(); // else a streak draws from the previous despawn point
for (int i = 0; i < inst.Systems.Length; i++)
{
var ps = inst.Systems[i];
if (ps == null) continue;
ps.Clear(true);
ps.Play(true);
}
inst.Rented = true;
return inst;
}
/// <summary>
/// Build one pooled instance. Instantiated under an INACTIVE root so Awake/Start never run, which is
/// also what makes the DestroyImmediate in StripCosmetic safe: a deferred Destroy would hand out an
/// instance still carrying a live Rigidbody + Collider for one frame if it were rented the same frame.
/// Component arrays are cached PER INSTANCE — component references are instance-scoped, so caching them
/// off the prefab asset would drive the asset instead.
/// </summary>
VfxInstance FillVfx(GameObject prefab)
{
if (_vfxFillRoot == null)
{
var fillGo = new GameObject("~VfxPool");
fillGo.transform.SetParent(_fxRoot, false);
fillGo.SetActive(false);
_vfxFillRoot = fillGo.transform;
}
var go = Object.Instantiate(prefab, _vfxFillRoot);
StripCosmetic(go);
var inst = new VfxInstance
{
Go = go,
Tr = go.transform,
Systems = go.GetComponentsInChildren<ParticleSystem>(true),
Trails = go.GetComponentsInChildren<TrailRenderer>(true),
Prefab = prefab,
};
for (int i = 0; i < inst.Systems.Length; i++)
{
// A prefab whose stopAction is Destroy/Disable would silently destroy the POOLED instance when
// the effect finishes, draining the pool and pushing dead objects onto the stack.
var main = inst.Systems[i].main;
main.stopAction = ParticleSystemStopAction.None;
}
if (!_vfxPrefabScale.ContainsKey(prefab)) _vfxPrefabScale[prefab] = prefab.transform.localScale;
if (!_vfxLifetime.ContainsKey(prefab)) _vfxLifetime[prefab] = VfxLifetime(inst.Systems);
return inst;
}
/// <summary>
/// Park an instance back on its OWN prefab's stack (keyed off the record, never a re-read of VFXConfig —
/// swapping a config field mid-play would otherwise file it under the wrong effect). The Rented flag is
/// the at-most-once guard: a double return would hand one instance to two callers.
/// </summary>
void ReturnVfx(VfxInstance inst)
{
if (inst == null || !inst.Rented) return;
inst.Rented = false;
if (inst.Go == null) return; // destroyed out from under us: drop it rather than pool a dead object
for (int i = 0; i < inst.Systems.Length; i++)
if (inst.Systems[i] != null) inst.Systems[i].Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
for (int i = 0; i < inst.Trails.Length; i++)
if (inst.Trails[i] != null) inst.Trails[i].Clear();
inst.Go.SetActive(false);
if (_vfxFillRoot != null) inst.Tr.SetParent(_vfxFillRoot, false);
if (!_vfxPool.TryGetValue(inst.Prefab, out var stack)) { stack = new Stack<VfxInstance>(); _vfxPool[inst.Prefab] = stack; }
if (stack.Count >= VfxPerPrefabRetain) { Object.Destroy(inst.Go); return; } // bound the retained set after a burst
stack.Push(inst);
}
double VfxLifetimeFor(GameObject prefab) => _vfxLifetime.TryGetValue(prefab, out var d) ? d : 1.0;
void PruneVfx()
{
double now = SystemAPI.Time.ElapsedTime;
for (int i = _activeVfx.Count - 1; i >= 0; i--)
{
if (now < _activeVfx[i].Kill) continue;
if (_activeVfx[i].Go != null) Object.Destroy(_activeVfx[i].Go);
ReturnVfx(_activeVfx[i].Inst); // pooled, not destroyed
_activeVfx.RemoveAt(i);
}
}
@@ -722,10 +867,11 @@ namespace ProjectM.Client
{
if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null)
{
// Config cleared mid-run: drop any orphaned trails so they don't linger.
// Config cleared mid-run: RETURN the orphans rather than destroying them, or the pool's
// bookkeeping under-counts and the instances leak out of it.
if (_projTrails.Count > 0)
{
foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value);
foreach (var kv in _projTrails) ReturnVfx(kv.Value);
_projTrails.Clear();
}
return;
@@ -739,15 +885,12 @@ namespace ProjectM.Client
Vector3 wp = (Vector3)xf.ValueRO.Position;
if (_projTrails.TryGetValue(entity, out var trail))
{
if (trail != null) trail.transform.position = wp;
if (trail != null && trail.Tr != null) trail.Tr.position = wp;
}
else
{
var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot);
StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only
var systems = go.GetComponentsInChildren<ParticleSystem>();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_projTrails[entity] = go;
var inst = RentVfx(cfg.ProjectileTrail, wp, Quaternion.identity);
if (inst != null) _projTrails[entity] = inst;
}
}
@@ -757,7 +900,7 @@ namespace ProjectM.Client
if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key);
for (int i = 0; i < _projStale.Count; i++)
{
if (_projTrails[_projStale[i]] != null) Object.Destroy(_projTrails[_projStale[i]]);
ReturnVfx(_projTrails[_projStale[i]]);
_projTrails.Remove(_projStale[i]);
}
}
@@ -767,27 +910,31 @@ namespace ProjectM.Client
// effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks.
static void StripCosmetic(GameObject go)
{
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
// DestroyImmediate, not Destroy: a deferred Destroy is only applied at end-of-frame, so an instance
// filled and rented in the SAME frame would still carry a live Rigidbody + Collider — exactly the
// self-propelling / secondary-spawn behaviour this strip exists to prevent. Legal here because the
// target is a freshly-instantiated runtime instance under an inactive root, never a prefab asset.
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.DestroyImmediate(rb);
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.DestroyImmediate(col);
// Cosmetic VFX must be particles ONLY. This used to disable by type-name substring ("Projectile" /
// "Move"), which let any other authored helper (auto-destroy timers, effect settings, light flicker)
// survive — harmless when the object was destroyed after one use, but a pooled instance re-runs
// OnEnable on EVERY rent, so a survivor would re-arm each time and could Destroy the pooled object.
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true))
{
if (mb == null) continue;
string n = mb.GetType().Name;
// Disable (not destroy) BEFORE Start runs so the mover's Start-spawned muzzle never fires.
if (n.IndexOf("Projectile", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
n.IndexOf("Move", System.StringComparison.OrdinalIgnoreCase) >= 0)
mb.enabled = false;
}
if (mb != null) mb.enabled = false;
}
// Real effect duration from the longest child ParticleSystem (clamped), so we don't force-kill early
// or hold a finished GameObject around on a blanket TTL.
static double VfxLifetime(GameObject go)
// Real effect duration from the longest ParticleSystem (clamped), so we don't force-kill early or hold a
// finished instance out of the pool on a blanket TTL. Takes the per-instance cache so the old
// GetComponentsInChildren-per-spawn is gone; the RESULT is per-prefab and memoised in _vfxLifetime.
static double VfxLifetime(ParticleSystem[] systems)
{
float longest = 0f;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
for (int i = 0; i < systems.Length; i++)
{
var main = ps.main;
if (systems[i] == null) continue;
var main = systems[i].main;
float d = main.duration + main.startLifetime.constantMax;
if (d > longest) longest = d;
}
@@ -796,6 +943,8 @@ namespace ProjectM.Client
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
const int AlphaSteps = 12; // Track B: fade quantisation for the floating numbers (see AnimateNumbers)
class FloatingNumber
{
public TextMesh Tm;
@@ -805,6 +954,7 @@ namespace ProjectM.Client
public Vector3 Vel;
public Color BaseColor;
public bool Active;
public int ShownAlphaStep; // Track B: quantised fade step last written to Tm.color (see AnimateNumbers)
}
FloatingNumber CreateNumber()
@@ -836,6 +986,7 @@ namespace ProjectM.Client
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
fn.Tm.color = fn.BaseColor;
fn.ShownAlphaStep = AlphaSteps; // BaseColor is fully opaque, i.e. the top fade step — keeps AnimateNumbers from re-writing on frame 1
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
fn.Vel = new Vector3(0f, 2.2f, 0f);
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
@@ -862,9 +1013,17 @@ namespace ProjectM.Client
fn.Tr.position += fn.Vel * dt;
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
var c = fn.BaseColor;
c.a = 1f - (fn.Age / fn.Life);
fn.Tm.color = c;
// Track B: legacy TextMesh bakes colour into VERTEX colours, so every colour write forces a
// text-mesh rebuild — up to 32 rebuilds a frame with a full pool. Quantising the fade to 12
// steps turns ~50 rebuilds per number into 12, with no visible difference over its <1 s life.
int step = (int)((1f - fn.Age / fn.Life) * AlphaSteps);
if (step != fn.ShownAlphaStep)
{
fn.ShownAlphaStep = step;
var c = fn.BaseColor;
c.a = step / (float)AlphaSteps;
fn.Tm.color = c;
}
}
}
@@ -914,7 +1073,7 @@ namespace ProjectM.Client
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign,
float innerFrac = 0.45f, float y = 0f, float angularWindowRad = 0f)
{
const int seg = 16;
const int seg = ArcSeg;
float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * innerFrac;
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
@@ -923,47 +1082,53 @@ namespace ProjectM.Client
float aBase = aStart;
if (angularWindowRad > 0f) // smear mode: only the trailing band behind the leading edge
aBase = sweepSign >= 0 ? Mathf.Max(aStart, aEnd - angularWindowRad) : Mathf.Min(aStart, aEnd + angularWindowRad);
var verts = new Vector3[(seg + 1) * 2];
var cols = new Color[(seg + 1) * 2];
var uvs = new Vector2[(seg + 1) * 2];
var tris = new int[seg * 6];
// UVs and triangles do not depend on ANY argument, so they are built once for the whole system
// instead of being regenerated (and re-uploaded) on every call.
if (!_arcStaticsBuilt)
{
for (int i = 0; i <= seg; i++)
{
_arcUvs[i * 2] = new Vector2(0.5f, 0.5f);
_arcUvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
}
for (int i = 0; i < seg; i++)
{
int b = i * 2;
_arcTris[i * 6 + 0] = b; _arcTris[i * 6 + 1] = b + 1; _arcTris[i * 6 + 2] = b + 2;
_arcTris[i * 6 + 3] = b + 1; _arcTris[i * 6 + 4] = b + 3; _arcTris[i * 6 + 5] = b + 2;
}
_arcStaticsBuilt = true;
}
for (int i = 0; i <= seg; i++)
{
float a = Mathf.Lerp(aBase, aEnd, i / (float)seg);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
verts[i * 2] = new Vector3(sx * r0, y, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
_arcVerts[i * 2] = new Vector3(sx * r0, y, cz * r0);
_arcVerts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
cols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
uvs[i * 2] = new Vector2(0.5f, 0.5f);
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
_arcCols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge
_arcCols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
}
for (int i = 0; i < seg; i++)
{
int b = i * 2;
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
}
mesh.Clear();
mesh.vertices = verts;
mesh.colors = cols;
mesh.uv = uvs;
mesh.triangles = tris;
mesh.RecalculateBounds();
}
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash.
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
void TryHold(int frames = 0) // 07-20 G5 (review C17): ONE hold path, per-verb frames (0 = the light default)
{
if (!FeelConfig.HitStopFreezeEnabled) return;
double now = SystemAPI.Time.ElapsedTime;
if (now - _lastHoldTime < 0.22) return;
_lastHoldTime = now;
PrototypeCameraRig.Hold(frames > 0 ? frames : FeelConfig.HitStopMaxFrames);
// First fill for THIS mesh — _slashMesh, _smearMesh and every remote arc each hit it once.
// Vertices must be uploaded before triangles or index validation fails on an empty mesh.
// Afterwards only the two channels that actually change are re-uploaded.
if (mesh.vertexCount != _arcVerts.Length)
{
mesh.Clear();
mesh.vertices = _arcVerts;
mesh.colors = _arcCols;
mesh.uv = _arcUvs;
mesh.triangles = _arcTris;
}
else
{
mesh.vertices = _arcVerts;
mesh.colors = _arcCols;
}
mesh.RecalculateBounds();
}
// 07-20 G2.1/G5 (review C14): the melee CONNECT package, fired when the blade actually LANDS. Recomputes
@@ -1002,7 +1167,6 @@ namespace ProjectM.Client
if (finisher)
{
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
TryHold(FeelConfig.FinisherHoldFrames); // G5: the heavy payoff beat, at contact
}
}
@@ -1042,8 +1206,10 @@ namespace ProjectM.Client
if (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing
}
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
// the range telegraph (MC-4 clarity) AND SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
{
if (_slashMr == null || _slashMat == null) return;
bool finisher = step >= comboLen;
@@ -85,6 +85,10 @@ namespace ProjectM.Client
if (_kindMats != null)
for (int i = 0; i < _kindMats.Length; i++)
if (_kindMats[i] != null) Object.Destroy(_kindMats[i]);
// Procedural clips are not owned by _fxRoot — see FeedbackFx.DestroyClip.
FeedbackFx.DestroyClip(ref _strikeBeepClip);
if (_kindGrowls != null)
for (int i = 0; i < _kindGrowls.Length; i++) FeedbackFx.DestroyClip(ref _kindGrowls[i]);
foreach (var kv in _dangerZones)
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
}
@@ -245,28 +249,52 @@ namespace ProjectM.Client
}
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
// Track B: the four arrays used to be allocated on EVERY call — once per winding enemy per frame
// (~840 B a time). seg is a compile-time constant, so they are hoisted to scratch and filled in place;
// UVs/triangles are argument-independent and upload to a given mesh only on its first fill.
static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
{
const int seg = 14;
var verts = new Vector3[seg + 2];
var cols = new Color[seg + 2];
var uvs = new Vector2[seg + 2];
var tris = new int[seg * 3];
const int seg = WedgeSeg;
if (!s_wedgeStaticsBuilt)
{
s_wedgeUvs[0] = new Vector2(0.5f, 0.5f);
for (int i = 0; i <= seg; i++) s_wedgeUvs[i + 1] = new Vector2(0.5f, 0.5f);
for (int i = 0; i < seg; i++) { s_wedgeTris[i * 3] = 0; s_wedgeTris[i * 3 + 1] = i + 1; s_wedgeTris[i * 3 + 2] = i + 2; }
s_wedgeStaticsBuilt = true;
}
float aCenter = 0.18f + 0.62f * intensity;
verts[0] = Vector3.zero; cols[0] = new Color(1f, 1f, 1f, aCenter); uvs[0] = new Vector2(0.5f, 0.5f);
s_wedgeVerts[0] = Vector3.zero;
s_wedgeCols[0] = new Color(1f, 1f, 1f, aCenter);
for (int i = 0; i <= seg; i++)
{
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
uvs[i + 1] = new Vector2(0.5f, 0.5f);
s_wedgeVerts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
s_wedgeCols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
}
if (mesh.vertexCount != s_wedgeVerts.Length)
{
mesh.Clear();
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
mesh.uv = s_wedgeUvs; mesh.triangles = s_wedgeTris;
}
else
{
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
}
for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; }
mesh.Clear();
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
mesh.RecalculateBounds();
}
// Wedge scratch (see BuildDangerMesh). Static is safe: presentation systems are main-thread only, and
// the contents are deterministic geometry, so surviving a domain reload leaves them valid.
const int WedgeSeg = 14;
static readonly Vector3[] s_wedgeVerts = new Vector3[WedgeSeg + 2];
static readonly Color[] s_wedgeCols = new Color[WedgeSeg + 2];
static readonly Vector2[] s_wedgeUvs = new Vector2[WedgeSeg + 2];
static readonly int[] s_wedgeTris = new int[WedgeSeg * 3];
static bool s_wedgeStaticsBuilt;
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
// already rotated to the enemy facing, so +Z is "toward the locked target".
@@ -30,6 +30,10 @@ namespace ProjectM.Client
public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg;
public float ShowTimer; public bool Visible;
public float LastHp; public float MaxHp; public float3 Pos;
// Track B: last values actually pushed to uGUI, so a bar that is merely showing pushes nothing.
// CreateHealthBar seeds these to -1 (a struct would otherwise default them to 0, which is a
// legitimate frac and would skip the first render of a fully-drained bar).
public float LastFrac; public float LastAlpha;
}
const int HealthBarPoolLimit = 24;
@@ -151,7 +155,8 @@ namespace ProjectM.Client
var entry = new HealthBarEntry
{
CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false,
LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos
LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos,
LastFrac = -1f, LastAlpha = -1f // force the first visual push (a real frac/alpha is never negative)
};
_healthBars[entity] = entry;
return entry;
@@ -209,8 +214,17 @@ namespace ProjectM.Client
}
float alpha = (!alwaysOn && entry.ShowTimer < 0f)
? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f;
if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f); }
if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; }
// Track B: these ran unconditionally for every visible bar every frame. Writing
// anchorMax triggers OnRectTransformDimensionsChange and an Image.color write dirties the
// canvas — so a bar that is merely SHOWING (not changing) used to keep re-laying-out uGUI.
// Epsilon-gated: a bar only pushes when its fill or fade actually moved.
if (entry.Fill != null && (math.abs(frac - entry.LastFrac) > 0.002f || math.abs(alpha - entry.LastAlpha) > 0.002f))
{
var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c;
entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f);
if (entry.Bg != null) { var bgc = entry.Bg.color; bgc.a = 0.82f * alpha; entry.Bg.color = bgc; }
entry.LastFrac = frac; entry.LastAlpha = alpha;
}
}
else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
@@ -49,7 +49,10 @@ namespace ProjectM.Client
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
_seen.Clear();
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp);
// Track B: created lazily — it only ever records on a newly-seen enemy, but it used to be built
// and played back (a sync point) every single frame.
EntityCommandBuffer ecb = default;
bool hasEcb = false;
foreach (var (health, entity) in
SystemAPI.Query<RefRO<Health>>().WithAny<EnemyTag, PlayerTag>().WithAll<LinkedEntityGroup>().WithEntityAccess())
{
@@ -64,13 +67,17 @@ namespace ProjectM.Client
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
entry.RenderKids.Add(c);
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
{
if (!hasEcb) { ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); hasEcb = true; }
ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White });
}
}
// Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame).
if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
}
ecb.Playback(EntityManager);
ecb.Dispose();
// Only pay the structural-change sync point on the frames that actually recorded something
// (i.e. a newly-seen enemy) instead of every frame.
if (hasEcb) { ecb.Playback(EntityManager); ecb.Dispose(); }
// Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
var bc = FeelConfig.BodyFlashColor;
@@ -139,7 +139,23 @@ namespace ProjectM.Client
public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
// Pooled 3D voices, NOT AudioSource.PlayClipAtPoint: that allocates a GameObject +
// AudioSource per call and schedules a delayed Destroy, ~20-33 times a second in combat.
// GameVolume.Sfx is read HERE (at play time) so the bus trim applies per cue, as before.
OneShotAudioPool.Play(clip, pos, vol * GameVolume.Sfx);
}
/// <summary>
/// Destroy a procedurally-built clip on world teardown. An <c>AudioClip.Create</c>d clip is a standalone
/// UnityEngine.Object — destroying the system's FX-root GameObject does NOT take it with it, so every
/// presentation system that builds clips leaked its native audio buffer on each client-world teardown
/// (unbounded growth across menu -> game -> menu cycles). Nulls the reference so a re-created system
/// rebuilds rather than holding a destroyed clip.
/// </summary>
public static void DestroyClip(ref AudioClip clip)
{
if (clip != null) Object.Destroy(clip);
clip = null;
}
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
@@ -39,14 +39,10 @@ namespace ProjectM.Client
public static float HitStopFovKickMin;
/// <summary>Max FOV kick (deg) on a heavy player-dealt hit (delta >= HitStopRefDamage).</summary>
public static float HitStopFovKickMax;
/// <summary>Reserved cap for the (deferred) true freeze-frame, in fixed frames.</summary>
public static int HitStopMaxFrames;
/// <summary>Damage delta that saturates the player-dealt punch to HitStopFovKickMax.</summary>
public static float HitStopRefDamage;
/// <summary>Tint for the (deferred) enemy material hit-flash — exposed now, wired in the ShaderGraph slice.</summary>
public static Color HitFlashColor;
/// <summary>Master gate for the (deferred) true freeze-frame hit-stop. FALSE for v1 (camera-punch only).</summary>
public static bool HitStopFreezeEnabled;
// ---- Feature 1/2: death camera punch ----
/// <summary>Camera shake on LOCAL player death (loudest event by design).</summary>
@@ -170,8 +166,6 @@ namespace ProjectM.Client
public static float MeleeArcIntensity;
/// <summary>Bubbles shed along a melee cleave (0 = off).</summary>
public static int MeleeArcBubbles;
/// <summary>Camera hold frames when the melee FINISHER lands (07-20 G5 impact ladder; light hits use HitStopMaxFrames).</summary>
public static int FinisherHoldFrames;
// ---- 07-21 enemy hit-reacts (G5; the Rukhanka-safe flinch that replaced the cut vibrate) ----
/// <summary>Seconds the light flinch pulse holds (0 = hit-reacts OFF).</summary>
@@ -252,10 +246,8 @@ namespace ProjectM.Client
HitStopDurationMs = 90f;
HitStopFovKickMin = 0.6f;
HitStopFovKickMax = 2.2f;
HitStopMaxFrames = 2; // C4: a 2-frame (~33ms) camera hold reads as crunch, not a lag-y stutter
HitStopRefDamage = 30f;
HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f);
HitStopFreezeEnabled = true; // C4: enable the finisher hit-stop hold (presentation-only camera freeze, bounded below)
// Feature 1/2 death
PlayerDeathShake = 0.50f;
@@ -318,7 +310,6 @@ namespace ProjectM.Client
CombatIdleHoldSec = 4f; // 07-18: Menacing01 combat-idle hold after the last swing
MeleeArcIntensity = 0.8f; // 07-21 HEAVY LOCK: the weapon is the read, the arc recedes further
MeleeArcBubbles = 6;
FinisherHoldFrames = 7; // 07-21 HEAVY LOCK: a fatter finisher beat (~117ms, still under the 8-frame ladder cap)
HitReactSeconds = 0.3f; // 07-21: light flinch pulse (clip plays at 1.4x -> the peak lands inside it)
HitStaggerSeconds = 0.55f; // heavy stagger pulse
HitReactStaggerDamage = 50f; // finisher (63 seeded) staggers, light (42) flinches -- tracks B2 poise
@@ -73,6 +73,7 @@ namespace ProjectM.Client
if (_discMat != null) Object.Destroy(_discMat);
if (_burstMat != null) Object.Destroy(_burstMat);
if (_discMesh != null) Object.Destroy(_discMesh);
FeedbackFx.DestroyClip(ref _eruptClip); // not owned by _fxRoot — see FeedbackFx.DestroyClip
}
protected override void OnUpdate()
@@ -90,6 +90,12 @@ namespace ProjectM.Client
float _downedSince = -1f;
float _prevHp, _flash;
bool _haveHp;
// Track B: last value pushed to each Label. A UITK text assignment is cheap, but BUILDING the string
// (int.ToString / concat) allocates every frame regardless — these gate the build, not just the write.
// int.MinValue rather than -1 so a legitimately negative or zero first value still renders.
int _shownAether = int.MinValue, _shownOre = int.MinValue, _shownBio = int.MinValue;
int _shownThreat = int.MinValue, _shownHp = int.MinValue, _shownMaxHp = int.MinValue;
int _shownRespawnSecs = int.MinValue;
// personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList, _equipList;
bool _invOpen;
@@ -161,9 +167,10 @@ namespace ProjectM.Client
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
}
}
_aetherNum.text = aether.ToString();
_oreNum.text = ore.ToString();
_bioNum.text = bio.ToString();
// Track B: three int.ToString()s every frame, for counts that change a few times a minute.
if (aether != _shownAether) { _shownAether = aether; _aetherNum.text = aether.ToString(); }
if (ore != _shownOre) { _shownOre = ore; _oreNum.text = ore.ToString(); }
if (bio != _shownBio) { _shownBio = bio; _bioNum.text = bio.ToString(); }
@@ -177,7 +184,7 @@ namespace ProjectM.Client
{
float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity);
_threatNum.text = huskCount.ToString();
if (huskCount != _shownThreat) { _shownThreat = huskCount; _threatNum.text = huskCount.ToString(); } // Track B: changes on a spawn/kill, not per frame
_threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, PanelDark);
@@ -225,7 +232,13 @@ namespace ProjectM.Client
_healthFill.style.backgroundColor = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
// Track B: `int + " / " + int` allocated a fresh string EVERY frame even at full health.
int hpI = Mathf.CeilToInt(Mathf.Max(0f, hp)), maxI = Mathf.CeilToInt(maxHp);
if (hpI != _shownHp || maxI != _shownMaxHp)
{
_shownHp = hpI; _shownMaxHp = maxI;
_healthText.text = hpI + " / " + maxI;
}
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
if (dead)
@@ -237,9 +250,16 @@ namespace ProjectM.Client
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
// Track B: the countdown only changes ~1x/second, but this ran every frame — the
// concat allocated a fresh string ~80x/second while dead.
int secsLeft = left > 0.05f ? Mathf.CeilToInt(left) : -1;
if (secsLeft != _shownRespawnSecs)
{
_shownRespawnSecs = secsLeft;
_downedText.text = secsLeft > 0 ? "RESPAWNING IN " + secsLeft : "RESPAWNING...";
}
}
else _downedSince = -1f;
else { _downedSince = -1f; _shownRespawnSecs = int.MinValue; } // re-render on the next death
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
else
@@ -57,9 +57,24 @@ namespace ProjectM.Client
protected override void OnDestroy()
{
// Read the clips off the sources BEFORE destroying _root: the AudioSources die with it, but the four
// AudioClip.Create'd loops are standalone objects that do not (~6.8 MB of native audio, leaked on
// every client-world teardown). See FeedbackFx.DestroyClip.
DestroySourceClip(_bass);
DestroySourceClip(_pad);
DestroySourceClip(_arp);
DestroySourceClip(_pulse);
if (_root != null) Object.Destroy(_root);
}
static void DestroySourceClip(AudioSource src)
{
if (src == null) return;
var clip = src.clip;
src.clip = null;
FeedbackFx.DestroyClip(ref clip);
}
AudioSource MakeSource(AudioClip clip)
{
var src = _root.AddComponent<AudioSource>();
@@ -53,7 +53,9 @@ namespace ProjectM.Client
foreach (var (clut, entity) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
Drive(entity, clut.ValueRO.Remaining, popDecay, minScale, popAmt, ecb);
ecb.Playback(EntityManager);
// Track B: this only ever records on a node's FIRST sighting, but Playback is a structural-change
// sync point and used to run every frame regardless.
if (!ecb.IsEmpty) ecb.Playback(EntityManager);
ecb.Dispose();
// Prune despawned (depleted/shattered) nodes — their PostTransformMatrix dies with the ghost.
@@ -0,0 +1,186 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Pooled 3D one-shot SFX voices — the allocation-free replacement for
/// <c>AudioSource.PlayClipAtPoint</c> that sits behind <see cref="FeedbackFx.PlayClip"/>.
/// <para>
/// <c>PlayClipAtPoint</c> allocates a fresh <c>GameObject</c> + <c>AudioSource</c> per call and
/// schedules a delayed <c>Destroy</c>. Every one-shot in the project funnels through
/// <see cref="FeedbackFx.PlayClip"/> (footsteps, hits, kills, telegraphs, growls, strike beeps,
/// swings, connects, socket fire, dash), measured at ~20-33 calls/s in light combat — i.e. hundreds
/// of create/destroy pairs per ten seconds of play. This ring replaces that with a fixed set of
/// long-lived voices, so a one-shot costs zero managed allocation.
/// </para>
/// <para>
/// PARITY with PlayClipAtPoint matters — 20 call sites are re-levelled at once by any deviation.
/// PlayClipAtPoint sets <c>spatialBlend = 1</c> explicitly (a fresh AudioSource defaults to 0 =
/// fully 2D) and leaves everything else at the stock defaults, so those are all re-stated in
/// <see cref="MakeVoice"/>. Two DELIBERATE divergences, both forced by the voices being long-lived
/// rather than per-event: <c>playOnAwake = false</c> (a pooled source would otherwise self-start on
/// any enable, replaying whatever clip is still assigned) and <c>dopplerLevel = 0</c> (a pooled
/// voice TELEPORTS between events — at the stock dopplerLevel of 1 a 20 m jump pitch-bends the clip,
/// a bug that cannot exist when the source is created at the position and never moves).
/// </para>
/// <para>
/// Lifetime: the root is <c>DontDestroyOnLoad</c> because <c>WorldLauncher</c> does
/// <c>LoadScene(..., Single)</c> while the client world is alive — a scene-parented pool would be
/// destroyed mid-session and every SFX would go silently dead. Statics survive fast-enter-playmode
/// domain reloads, so <see cref="ResetStatics"/> nulls them on play-enter (the house rule; without
/// it the second play session holds an array of destroyed objects and the first cue throws
/// <c>MissingReferenceException</c>). Both the root and each voice are additionally rebuilt on
/// fake-null, so anything that does destroy them heals instead of going quiet.
/// </para>
/// Main-thread only (every caller is a <c>PresentationSystemGroup</c> SystemBase) — no locking.
/// Asset-free: built from <c>new GameObject</c> + <c>AddComponent</c>, never a prefab or Resources.Load.
/// </summary>
public static class OneShotAudioPool
{
/// <summary>
/// Voice count. Observed peak is ~33 starts/s; the live combat cues top out at 0.45 s (the barrel boom),
/// which is ~15 concurrent voices worst case, so 32 leaves headroom and stealing never bites in practice.
/// The longest clip that can reach this pool at all is AmbientLifeSystem's 0.9 s thunder, and that sits
/// behind a currently-unreachable biome branch. Do not drop below 24: PlayClipAtPoint is effectively
/// unbounded in voice count, and bounded stealing is the one intentional behaviour change here.
/// </summary>
/// </summary>
const int RingSize = 32;
static GameObject s_root;
static AudioSource[] s_voices;
static float[] s_freeAt; // unscaled time each voice is expected to finish; 0 = idle
/// <summary>
/// Play-enter reset (the static-presentation-bridge rule). Statics survive a fast-enter-playmode
/// domain reload but the UnityEngine.Objects they point at do NOT survive exiting play mode — so
/// the array must be dropped, not reused, or session two rents destroyed voices.
/// </summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
s_root = null;
s_voices = null;
s_freeAt = null;
}
/// <summary>
/// Fire a positional one-shot. <paramref name="volume"/> is the FINAL level — the caller
/// (<see cref="FeedbackFx.PlayClip"/>) has already applied the <c>GameVolume.Sfx</c> bus trim,
/// exactly as the PlayClipAtPoint call did. Baked in at play time and never re-applied to a live
/// voice, so moving the SFX slider cannot retroactively re-level a cue already in flight.
/// </summary>
public static void Play(AudioClip clip, Vector3 pos, float volume)
{
if (clip == null || !Application.isPlaying) return;
var voice = Rent(clip.length);
if (voice == null) return;
// Order matters: position BEFORE Play so the voice is spatialised at the event, not at
// wherever the previous rent left it (a pooled voice parked at the origin loses 3D panning).
voice.transform.position = pos;
voice.clip = clip;
voice.pitch = 1f; // defensive: a future per-cue pitch jitter must never leak across a reuse
voice.volume = volume;
voice.Play();
}
/// <summary>
/// Stop every voice. Called on client-world teardown so in-flight combat SFX do not bleed into
/// the main menu (the pool outlives the world by design — it is DontDestroyOnLoad).
/// </summary>
public static void SilenceAll()
{
if (s_voices == null) return;
for (int i = 0; i < s_voices.Length; i++)
{
if (s_voices[i] != null) s_voices[i].Stop();
s_freeAt[i] = 0f;
}
}
/// <summary>
/// First idle voice, else steal the one nearest to finishing (smallest deadline). Deadlines come
/// from the actual <c>clip.length</c> — never a fixed constant: the project's SFX span 0.05 s
/// (strike beep) to 0.45 s (barrel boom), and a fixed recycle window would audibly truncate the
/// long ones. <c>Time.unscaledTime</c> because this project never touches <c>Time.timeScale</c>
/// (hit-stop is a camera punch by house rule).
/// </summary>
static AudioSource Rent(float clipLength)
{
Build();
if (s_voices == null) return null;
float now = Time.unscaledTime;
int steal = -1;
float oldest = float.MaxValue;
for (int i = 0; i < s_voices.Length; i++)
{
if (s_voices[i] == null) continue; // destroyed out from under us; Build() already retried it
if (s_freeAt[i] <= now)
{
s_freeAt[i] = now + Mathf.Max(0.01f, clipLength);
return s_voices[i];
}
if (s_freeAt[i] < oldest) { oldest = s_freeAt[i]; steal = i; }
}
if (steal < 0) return null;
s_freeAt[steal] = now + Mathf.Max(0.01f, clipLength);
return s_voices[steal];
}
/// <summary>Idempotent: builds the root and any missing voice, and heals fake-null entries.</summary>
static void Build()
{
if (s_root == null)
{
s_root = new GameObject("~OneShotAudioPool") { hideFlags = HideFlags.HideAndDontSave };
Object.DontDestroyOnLoad(s_root);
}
if (s_voices == null || s_voices.Length != RingSize)
{
s_voices = new AudioSource[RingSize];
s_freeAt = new float[RingSize];
}
for (int i = 0; i < RingSize; i++)
if (s_voices[i] == null) { s_voices[i] = MakeVoice(i); s_freeAt[i] = 0f; }
}
/// <summary>
/// One voice, configured for PlayClipAtPoint parity. Every stock default is re-stated explicitly
/// because a pooled source is long-lived and would otherwise drift with any future edit.
/// </summary>
static AudioSource MakeVoice(int index)
{
var go = new GameObject("Voice" + index);
go.transform.SetParent(s_root.transform, false);
var src = go.AddComponent<AudioSource>();
src.playOnAwake = false; // divergence #1: a long-lived source must never self-start
src.dopplerLevel = 0f; // divergence #2: pooled voices teleport; Doppler would pitch-bend them
src.spatialBlend = 1f; // the one thing PlayClipAtPoint sets explicitly (default is 2D)
src.loop = false;
src.mute = false;
src.rolloffMode = AudioRolloffMode.Logarithmic;
src.minDistance = 1f;
src.maxDistance = 500f;
src.spread = 0f;
src.priority = 128;
src.panStereo = 0f;
src.reverbZoneMix = 1f;
src.bypassEffects = false;
src.bypassListenerEffects = false;
src.bypassReverbZones = false;
src.pitch = 1f;
src.volume = 1f;
src.outputAudioMixerGroup = null; // no AudioMixer in this project: straight to the listener
return src;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bba17743370546d4e85c057b60b43653
@@ -58,10 +58,6 @@ namespace ProjectM.Client
s_fovLambda = 3f / durSec; // ~95% decayed after durSec (3 time constants)
}
/// <summary>C4 hit-stop: hold the follow camera for a couple frames on a heavy hit (the combo finisher) so the
/// impact lands with a beat of crunch. Presentation only — NEVER Time.timeScale (the deterministic sim keeps ticking).</summary>
static int s_holdFrames;
public static void Hold(int frames) { if (frames > s_holdFrames) s_holdFrames = frames; }
[Header("Angle (degrees)")]
@@ -95,6 +91,11 @@ namespace ProjectM.Client
Camera _cam;
Vector3 _leadOffset; // smoothed look-ahead offset (world units), eased toward the desired lead each frame
// The SMOOTHED follow position, kept free of shake. Reading the shaken transform back into the follow
// filter is what made the camera wander in combat (see LateUpdate). Seeded from the transform on the
// first frame so a scene-authored camera pose is honoured rather than snapped from the origin.
Vector3 _basePos;
bool _baseInit;
// 07-16c DEV ZOOM (LoL/SoD-style scroll-to-inspect; may not ship): the wheel moves a smoothed
// distance target between ZoomMinDistance and the rig's authored Distance — zoom IN only, scroll
@@ -156,15 +157,32 @@ namespace ProjectM.Client
// uninitialized -> no change). Multiplies the serialized FollowSharpness so scenes stay untouched.
float dragSharp = FollowSharpness * Mathf.Max(0.05f, FeelConfig.CameraDragMult <= 0f ? 1f : FeelConfig.CameraDragMult);
float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-dragSharp * Time.deltaTime);
Vector3 basePos;
if (s_holdFrames > 0) { s_holdFrames--; basePos = transform.position; } // C4: brief hit-stop hold (freeze the follow a couple frames)
else basePos = Vector3.Lerp(transform.position, desired, k);
// 2026-08-13, TWO fixes here — they are the whole "combat freezes on a kill / doesn't feel smooth" bug.
//
// (1) SHAKE IS A TRANSIENT OFFSET, NOT PART OF THE FOLLOW STATE. The smoothing used to run as
// Lerp(transform.position, desired, k) — and transform.position already contained LAST frame's
// random shake, so the filter saw shake as real positional error and integrated it, correcting
// only ~9 %/frame (FollowSharpness 8 at ~12 ms). Shake was never subtracted, only slowly lerped
// out while new shake was added on top, so in sustained combat the camera random-walked around
// its framing and never settled. Measured on a STATIONARY player (ideal motion = 0): one kill
// left it 0.45 units off-frame and still 0.20 off 24 frames later. The follow now smooths
// _basePos, which shake never touches; shake is added only when writing the transform.
//
// (2) The camera position-HOLD is gone. It froze the follow for a fixed number of RENDER frames on
// every kill/heavy hit, so the freeze length was framerate-dependent (7 frames = 49 ms at
// 144 fps, 233 ms at 30 fps), and it took its base from transform.position, permanently baking
// that frame's shake in — a measured 0.28-unit single-frame jump. A camera that stops while the
// world keeps moving reads as a hitch, not as crunch; impact rides the FOV punch + shake alone.
if (!_baseInit) { _basePos = transform.position; _baseInit = true; }
_basePos = Vector3.Lerp(_basePos, desired, k);
Vector3 writePos = _basePos;
if (s_shake > 0.0001f)
{
basePos += UnityEngine.Random.insideUnitSphere * s_shake;
writePos += UnityEngine.Random.insideUnitSphere * s_shake;
s_shake = Mathf.Lerp(s_shake, 0f, 1f - Mathf.Exp(-12f * Time.deltaTime));
}
transform.SetPositionAndRotation(basePos, rot);
transform.SetPositionAndRotation(writePos, rot);
}
}
@@ -68,6 +68,10 @@ namespace ProjectM.Client
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it
if (_fuseMat != null) Object.Destroy(_fuseMat);
if (_fuseDiscMesh != null) Object.Destroy(_fuseDiscMesh);
// Procedural clips are NOT children of _fxRoot — see FeedbackFx.DestroyClip.
FeedbackFx.DestroyClip(ref _chipClip);
FeedbackFx.DestroyClip(ref _clearClip);
FeedbackFx.DestroyClip(ref _boomClip);
}
protected override void OnUpdate()
@@ -15,7 +15,6 @@
"feel": [
{ "k": "MeleeArcIntensity", "v": "0.8" },
{ "k": "MeleeArcBubbles", "v": "6" },
{ "k": "FinisherHoldFrames", "v": "7" },
{ "k": "MeleeConnectFovKick", "v": "1.1" },
{ "k": "CombatIdleHoldSec", "v": "4" }
]
@@ -32,10 +32,6 @@
"k": "MeleeArcIntensity",
"v": "0.8"
},
{
"k": "FinisherHoldFrames",
"v": "7"
},
{
"k": "MeleeConnectFovKick",
"v": "1.1"
@@ -11,7 +11,6 @@
],
"feel": [
{ "k": "MeleeArcIntensity", "v": "1.1" },
{ "k": "FinisherHoldFrames", "v": "4" },
{ "k": "MeleeConnectFovKick", "v": "0.7" }
]
}
+13 -11
View File
@@ -83,21 +83,23 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **The player is a Unity Character Controller kinematic character** (NOT a dynamic Rigidbody; M5's `PlayerMoveSystem`/`PlayerPlanarConstraintSystem` deleted, predicted-physics infra kept). `PlayerControlSystem` maps input → `CharacterControl`; `CharacterProcessor` collide-and-slides in the relocated `KinematicCharacterPhysicsUpdateGroup`. CC 1.4.2 API = `IKinematicCharacterProcessor<T>` + `KinematicCharacterDataAccess` + static `KinematicCharacterUtilities.Update_*` (verify with `unity_reflect`).
- **`KinematicCharacterUtilities.BakeCharacter` aborts with a `Rigidbody`** and needs uniform (1,1,1) scale. **`CharacterInterpolation` must be PredictedClient-only** (a `DefaultVariantSystemBase` strips it from server + interpolated prefabs) — else double-interp on remotes. **Do NOT copy the CC sample's global `LocalTransform → DontSerializeVariant`** (project-wide; breaks non-character ghosts that rely on stock `LocalTransform` replication).
- **Top-down CC config:** `SnapToGround=false`, `InterpolateRotation=false` (rotation owned by `PlayerAimSystem`), `SimulateDynamicBody=false`; gravity handled by feeding `float3.zero` to `Update_GroundPushing`.
- **Hit/area tests must be SWEPT, not point checks** — a point check tunnels when the per-tick step exceeds the target radius (high speed *or* tick-batching); test the segment traversed this tick. **In a PLAIN `SimulationSystemGroup` system do NOT use `SystemAPI.Time.DeltaTime`** (wall-frame delta, not the fixed step) — store the per-tick step on the projectile (`Projectile.LastStep`, written in the fixed-step group) and rebuild the segment as `cur - dir*LastStep`. `ecb.DestroyEntity` **at-most-once** per tick (destroyed-bitset; double destroy throws at Playback). **TWO target types in one pass: UNIFY into one best-target loop + one shared bitset** (separate sweeps double-destroy a projectile overlapping both — DR-018). **A per-hit yield `(int)` cast that also gates despawn is an immortal-sink** (sub-1.0→0→no deposit, shot still consumed): guard `math.max(1,(int)yield)` + `[Min(1f)]` authoring.
- **Hit/area tests must be SWEPT, not point checks** — a point check tunnels when the per-tick step exceeds the target radius; test the segment traversed this tick. **In a PLAIN `SimulationSystemGroup` system do NOT use `SystemAPI.Time.DeltaTime`** (wall-frame delta, not the fixed step) — store the per-tick step on the projectile (`Projectile.LastStep`, written in the fixed-step group) and rebuild the segment as `cur - dir*LastStep`. `ecb.DestroyEntity` **at-most-once** per tick (destroyed-bitset; double destroy throws at Playback). **TWO target types in one pass: UNIFY into one best-target loop + one shared bitset** (DR-018). **A per-hit yield `(int)` cast that also gates despawn is an immortal-sink**: guard `math.max(1,(int)yield)` + `[Min(1f)]` authoring.
### Build / structures / grid
- **Grid math** (`BaseGridMath`, still live for spawn rings/respawn/lights): corner-origin, center-returning, **half-open** cell bounds, `math.floor`; lock cell size as a coordinate space once. Structures/placement themselves are deleted — recipe + atomicity rules in [[DR-014_M6_Build_Structures_Automation_Foundation]] if buildables return.
- **Ledger spends:** afford→act else SOFT-FAIL (no cooldown-burn), read LIVE in-loop (no hoist); a Health-less entity silently drops OUT of an aggro snapshot (snapshot ABOVE the early-return).
- **DR-051 purge (07-15) ★:** siege/cycle/core/turret/automation + legacy `AbilityRef` path + onboarding **DELETED** (git = the archive). **Retired byte VALUES stay reserved, never renumbered** (`StructureType` 1-4, `ResourceId.Charge`, `DebugOp` 3/10/11, `TuningKnob` 20-23); `DebugOp.SpawnWave`/`EndSiege` RE-MEANT (force-wave / quiet-arena). **Waves UNGATED** — a baked `WaveDirectorAuthoring` decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — **the Spark defs must be in EVERY gameplay subscene's `AbilityDatabaseAuthoring`; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0** (audit H2, live-proven). `FrameKind` = Bathynaut(2)/Harpooner(3); `PlayerClass` is gone (FrameId is the single frame identity). [[DR-051_Lantern_Realignment_Purge]] · **2026-08-07 audit purge deleted the whole base/expedition shell** (run FSM, meta shop, prep, boons, build/structures, storage, inventory/equipment, enemy variants + boss): [[DR-054_Audit_Purge_2026-08]].
- **Harvest is single-sink** (→ the shared ledger, via `HarvestMath.DepositYield`). The personal-bag/equipment layer was deleted 2026-08-07; reintroduce LANTERN's carried-vs-banked split *inside HarvestMath*, not at its two call sites.
- **DR-051 purge (07-15) ★:** the dead direction was **DELETED** (git = the archive; enumerated in the DR). **Retired byte VALUES stay reserved, never renumbered** (`StructureType` 1-4, `ResourceId.Charge`, `DebugOp` 3/10/11, `TuningKnob` 20-23); `DebugOp.SpawnWave`/`EndSiege` RE-MEANT (force-wave / quiet-arena). **Waves UNGATED** — a baked `WaveDirectorAuthoring` decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — **the Spark defs must be in EVERY gameplay subscene's `AbilityDatabaseAuthoring`; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0** (audit H2, live-proven). `FrameKind` = Bathynaut(2)/Harpooner(3); `PlayerClass` is gone (FrameId is the single frame identity). [[DR-051_Lantern_Realignment_Purge]] · **2026-08-07 audit purge deleted the whole base/expedition shell**: [[DR-054_Audit_Purge_2026-08]].
- **Harvest is single-sink** (→ the shared ledger, via `HarvestMath.DepositYield`; the personal-bag layer died 08-07). Reintroduce LANTERN's carried-vs-banked split *inside HarvestMath*, not at its two call sites.
- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned) ★:** **born-correct load**`CycleDirectorSpawnSystem` (now the ledger host only) applies the menu-staged `PendingSave` AT SPAWN. **v7 = a FRESH EPOCH: `MinLoadableVersion = CurrentVersion = 7`**; additive going forward — the save now carries only the ledger (structure/meta fields persist empty so v7 files still load). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
### Presentation / juice / VFX
- **All juice/HUD = client-only observe-only `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire), never mutates the sim. Read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO<T>()` — NOT MonoBehaviour `LateUpdate` (job-safety throw). `Entity` = a stable client dict key per ghost lifetime — **prune the cache each frame** (a pruned ghost = a kill/loss → death VFX); **never `DestroyEntity` a ghost client-side** (`GhostDespawnSystem` owns despawn). Hit-stop = camera punch, **never `Time.timeScale`**.
- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool; code-built **UI Toolkit**. Prefab-asset edits: `LoadPrefabContents`→modify→`SaveAsPrefabAsset``Unload`. Watch shared-material bleed on re-tint; ACES needs URP grading mode HDR. Detail → archive 07-16.
- **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (player-following ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to `CombatFeedbackSystem`; keep a procedural fallback). A **static presentation bridge must reset on play-enter** via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (statics survive fast-enter-playmode reloads → stale flash).
- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. The build palette (lazy from the client `StructureCatalog`) drives click-to-place: green/red `BuildPreviewMath` ghost → `BuildPlaceRequest` RPC, right-click/Esc cancel, `[`/`]`/R rotate. See [[DR-021_HUD_UITK_BuildPalette]].
- **HUD skin = build-safe `HudTheme` SO of serialized sprite refs** (runtime `Resources.Load` by name is build-stripped); tint MULTIPLIES, never set `unitySlice*` on 9-slices → archive 2026-07-06 + [[DR-024_HUD_Synty_Skin_Theme]].
- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool; code-built **UI Toolkit**. Prefab-asset edits: `LoadPrefabContents`→modify→`SaveAsPrefabAsset``Unload`. Watch shared-material bleed. Detail → archive 07-16.
- **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to `CombatFeedbackSystem`; keep a procedural fallback). A **static presentation bridge must reset on play-enter** via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (statics survive fast-enter-playmode reloads → stale flash; a `HideFlags.DontSave` object survives play EXIT → leaks one per reload).
- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. See [[DR-021_HUD_UITK_BuildPalette]] (build-palette half died with DR-051).
- **Camera feel ★:** shake/punch is a TRANSIENT offset — never let the follow filter read it back (`Lerp(transform.position, …)` integrates it as real error → the cam wanders); smooth a `_basePos` the offset never touches. No frame-counted holds (framerate-dependent); the position-hold hit-stop was DROPPED 08-13 (impact = FOV + shake).
- **Pooling + per-frame cost ★:** a per-frame value-CHANGE gate must derive its text FROM the quantised key (`Mathf.RoundToInt` = half-to-EVEN vs `ToString("0.0")` = half-away → the label latches stale). A pooled `AudioSource``PlayClipAtPoint`: `spatialBlend=1` (default is 2D), `dopplerLevel=0` (voices teleport), root `DontDestroyOnLoad` + `SubsystemRegistration` reset. `AudioClip.Create` clips aren't FX-root-owned — `FeedbackFx.DestroyClip`. **`GC.GetTotalMemory` quantises to 4 KB — attribute with `ProfilerRecorder("GC Allocated In Frame")`.** → archive 08-13.
- **HUD skin = build-safe `HudTheme` SO of serialized sprite refs** (runtime `Resources.Load` by name is build-stripped); tint MULTIPLIES, never set `unitySlice*` on 9-slices → [[DR-024_HUD_Synty_Skin_Theme]].
### Art import (HDRP store packs → URP)
- Synty = URP-native. (BefourStudios HDRP pack deleted 2026-08-07 — 4 reachable textures kept in `_Project/Textures/Env`.)
@@ -107,7 +109,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **VolumeProfile.Add persistence + the URP `m_AssetVersion` build blocker** → archive 2026-07-06 (+ native memory `urp-global-settings-version-blocks-build`).
- **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong).
- **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). **World collision = subscene-only ★:** `Environment`-layer boundary ring (`SM_Env_Rock_Cliff` rim) + landmark colliders, player blocked via the layer matrix; enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem`. **★ that slide has NO pathfinding — re-validate movers aren't frozen whenever you add Environment cover** (`EnemyMoveUtil.Depenetrate` + tangent-slide + a COVER-AWARE `EnemyNavState` nudge; full 07-07/07-10 history → archive). See [[2026-06-08_World_Collision_HUD_Scaling]].
- **A GA "projectile" prefab self-propels** — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`); verify *components*, not the name.
- **A GA "projectile" prefab self-propels** — `CombatFeedbackSystem.StripCosmetic` strips every MonoBehaviour/Rigidbody/Collider at pool-fill, under an INACTIVE root so `Start` never runs.
### Aim / facing (SoD model — DR-052) ★
- **`PlayerFacing` is body-yaw ONLY** (moves→face movement; cast window→turn to aim; idle→hold; never passively track the cursor). **Every gameplay direction reads `FacingMath.ResolveAim(PlayerInput.Aim, facing)`** (all AbilityFireSystem archetypes + assist seed + melee cleave) and aim-readout presentation reads the SAME resolver; Movement-archetype sockets never open a cast window (`TickWindowMath`); PlayerAimSystem stays UN-gated (integrator over the snapshot-restored [GhostField]). Scheme byte KBM=0/Gamepad=1; KBM reticle re-raycasts. [[DR-052_SoD_Facing_Underwater_Feel]] + archive 2026-07-06.
@@ -125,7 +127,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
### MCP / editor workflow ★
- **Edit Assets `.cs` ONLY via MCP `apply_text_edits` / `create_script`** (Unity's scripting pipeline) — the raw `Write` tool does NOT reliably trigger a recompile on an unfocused editor → tests/`execute_code` run a **stale assembly**; a raw-`Write`-created NEW `.cs` gets **no `.meta` / no test-discovery** until `refresh_unity scope=all mode=force`. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.) `script_apply_edits` **`anchor_replace`** (regex) + **`delete_method`** work even on a `struct : ISystem`.
- **`apply_text_edits` with MULTIPLE non-adjacent edits in one call can MISALIGN** — one edit per call (or strict bottom-first), always with `precondition_sha256` (it returns the current SHA on mismatch). **★ One edit can SWALLOW an adjacent attribute/comment line** (07-06 `_portalMat` NRE · 07-07 `[RuntimeInitializeOnLoadMethod]` off `WorldFeelConfig.ResetDefaults` → feedback slice silently dead) — re-read neighbors after editing beside attributes; silent presentation slice → probe its config's `Enabled` in-play. **`create_script` won't overwrite**; full-file rewrites = whole-span `apply_text_edits` (its brace-balance validator guards botched spans) or `manage_script delete`+`create_script` (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). `script_apply_edits replace_method` is safe for class methods but **can't target a `struct : ISystem`**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
- **`apply_text_edits` with MULTIPLE non-adjacent edits in one call can MISALIGN** — one edit per call (or strict bottom-first), always with `precondition_sha256` (it returns the current SHA on mismatch). **★ One edit can SWALLOW an adjacent attribute/comment line — ALWAYS re-read neighbors after editing** (2 shipped bugs → archive 07-06/07-07); a silently-dead presentation slice → probe its config's `Enabled` in-play. **`create_script` won't overwrite**; full-file rewrites = whole-span `apply_text_edits` or `manage_script delete`+`create_script` (NON-GUID-referenced files only). `script_apply_edits replace_method` is safe for class methods but **can't target a `struct : ISystem`**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
- **`execute_code` runs as a method body** — no `using` directives (parsed as statements); fully-qualify every type. Identify worlds by `world.Name == "ServerWorld"/"ClientWorld"` (flags overlap a shared `Game` bit).
- **`manage_gameobject create` / `manage_prefabs modify_contents` `component_properties` SILENTLY DROP enum + Vector3 fields** — set those via a follow-up `manage_components set_property` and VERIFY through `mcpforunity://scene/gameobject/{id}/component/{Type}` (or read the baked component in `execute_code` after Play). `manage_material set_renderer_color` uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
- **New ghost prefab recipe:** `manage_asset duplicate` a correctly-configured ghost (`UpgradePickup.prefab`) → swap the authoring MB (ownerless/interpolated `GhostAuthoring` + LEG come free). **Runtime-spawn shared ghosts** via a one-shot server spawner (dodges the prespawn handshake); wire baked spawners via `manage_scene load additive``set_active`→create→`save``close_scene`. Detail → archive 07-16.
@@ -136,9 +138,9 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
## Bootstrap & worlds
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld`/`ClientWorld`); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
- **Scenes (the DR-051 contract — exactly these four):** `MainMenu.unity` (build 0, UITK frontend) · `Game.unity` (build 1, the seabed arena; subscene `Gameplay.unity`) · `DevSandbox.unity` (renamed from Gym; dev tooling + subscene `GymSub.unity`; the `DebugOverlay`/F1-F2 dev scripts gate on this scene NAME) · `ArtStaging.unity` (art viewing, no player; the look's source of truth). All share the LANTERN look (see World bullet). The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
- **Direction = LANTERN ★ — pivot LOCKED 2026-07-13 ([[DR-048_Lantern_Adoption_Full_Pivot]]).** Co-op action-RPG, *light is territory* (seed-pinned pocket-graph; SoD manual-aim skillshots; suit-frames + Sparks + wild mutations). Operative roadmap [[Roadmap_Lantern_Slice]]; surviving code is **QUARRY, not foundation** ([[Lantern_Strip_Mothball_Inventory]]). World-model review **PASSED** ([[DR-049_Lantern_World_Model_Design]]) → build against [[World_Model_Build_Spec]], **re-anchoring it first** (its step 1 assumes SaveData `MinLoadableVersion` < 7, which DR-051 shipped past; its RegionTag→PocketTag anchors moved). Engine fork **PARKED — Unity stays** ([[DR-053_Engine_Fork_Bevy_Parked]], operator 08-07). The co-op-Hades core loop was DELETED 08-07 (audit H1); git is the archive. ★ **a serialized prefab/component value ignores the C# initializer — change it on the instance.**
- **Direction = LANTERN ★ — pivot LOCKED 2026-07-13 ([[DR-048_Lantern_Adoption_Full_Pivot]]).** Co-op action-RPG, *light is territory* (seed-pinned pocket-graph; SoD manual-aim skillshots; suit-frames + Sparks + wild mutations). Operative roadmap [[Roadmap_Lantern_Slice]]; surviving code is **QUARRY, not foundation** ([[Lantern_Strip_Mothball_Inventory]]). World-model review **PASSED** ([[DR-049_Lantern_World_Model_Design]]) → build against [[World_Model_Build_Spec]], **re-anchoring it first** (its SaveData/RegionTag anchors moved past DR-051). Engine fork **PARKED — Unity stays** ([[DR-053_Engine_Fork_Bevy_Parked]]). ★ **a serialized prefab/component value ignores the C# initializer — change it on the instance.**
## DOTS / ECS conventions (authoritative summary)
@@ -0,0 +1,228 @@
---
date: 2026-08-13
topic: Combat "freeze on kill" diagnosis + Track B (allocation / pooling)
status: shipped (code uncommitted at time of writing)
---
# 2026-08-13 — Combat freeze diagnosis + Track B allocation pass
Operator report: *"on a kill the combat feels like it freezes up the game and overall doesn't feel smooth."*
## Part 1 — Diagnosis (measured, not inferred)
Instrumented Play with an `EditorApplication.update` guardian injecting **real lethal `DamageEvent`s**
server-side (the true death path — setting `Health.Current = 0` directly does NOT work, because
`HealthApplyDamageSystem` skips any entity whose `DamageEvent` buffer is empty and so never stamps `Dying`).
**Ruled out by measurement:**
| Hypothesis | Verdict |
|---|---|
| CPU hitch on kill | **No.** Median frame 12.2 ms, p99 20.1 ms; a kill costs ~15 ms. |
| Wave respawn stalls on the last kill | **No.** Enemies drip back ~1 per 29 frames, no spike. |
| Corpses block movement for the 0.9 s `Dying` window | **No.** Enemies carry **no `PhysicsCollider` at all**. |
| `Time.timeScale` abuse | **No.** The codebase is disciplined about this. |
**What it actually is — the camera.** With a *stationary* player (so ideal camera motion is exactly zero,
making every measured value an artifact), one kill produces:
```
f51 shake=0.301 hold=2 fovKick=2.20 dev=0.111 <- kill lands
f52 step=0.283 hold=1 dev=0.269 <- 28 cm camera jump in ONE frame
f58 dev=0.447 <- peak, 45 cm off-frame
f75 dev=0.199 <- still 20 cm off, 24 frames later
```
Three root causes, all in `PrototypeCameraRig.LateUpdate`:
1. **Shake is integrated by the follow filter.** `basePos = Vector3.Lerp(transform.position, desired, k)` reads
back a `transform.position` that already contains last frame's random shake, so the smoother treats shake as
real positional error and corrects only ~9 %/frame (`FollowSharpness 8` at 12 ms → k≈0.09). Shake is never
subtracted, only slowly lerped out while new shake is added. In sustained combat the camera random-walks
around its ideal framing and never settles. **This is the "doesn't feel smooth".**
2. **The hit package and the kill package both fire on the lethal frame** — measured `shake = 0.301`
(`HitShakeRemote 0.10` + `KillShake 0.20` stacking) and `fovKick = 2.20` (the hit punch winning over the
kill's 1.0). One kill fires 2 SFX + 4 particle bursts + 2 light flashes + a damage number + 2 FOV punches +
a shake + a hold + a rumble, simultaneously.
3. **`PrototypeCameraRig.Hold()` freezes the follow, counted in FRAMES.** `HitStopMaxFrames = 2` on every kill,
`FinisherHoldFrames = 7`. The comment claims "~117 ms" but that assumes 60 fps — it is 49 ms at 144 fps and
233 ms at 30 fps. The hold branch also does `basePos = transform.position`, permanently baking in the shake
offset (that is the 0.283-unit single-frame step at f52).
Plus a **one-off 41 ms spike on the first kill** (+44 KB) — first-use warmup of the death VFX/audio path;
later kills cost ~15 ms.
A full remediation plan was presented (Track A camera, Track B allocation, Track C feel). The operator chose
**Track B** first, then — on the hold fork — chose **drop**, and Track A shipped in the same session (Part 3).
Track C remains open.
## Part 2 — Track B shipped
### Measurement instrument matters (a retracted result)
The first attribution run used `System.GC.GetTotalMemory(false)` deltas per frame and produced a per-system
table that was **entirely noise** — that API quantises to 4 KB pages, so every per-system median came back as 0
or exactly one page and the "savings" column was negative nonsense. **Discarded.** `ProfilerRecorder(
ProfilerCategory.Memory, "GC Allocated In Frame")` is the correct instrument and gives real bytes/frame.
The corrected bulk measurement then reframed the whole track:
| Condition | median B/frame | p90 |
|---|---|---|
| All on | 15 151 | 49 073 |
| **All 29 `ProjectM.Client` systems OFF** | **11 586** | 12 125 |
With *every* client system disabled the editor still allocates ~11.6 KB/frame. Our entire presentation layer is
only ~1025 % of the median — but it owns the **spiky tail** (p90 49 KB → 12 KB with our systems off), and that
tail is what triggers collections in bursts. A design-review critic also found the reason the floor is so high:
`PixelArtDevControls` is an **editor-only** dev tool that self-spawns via `[RuntimeInitializeOnLoadMethod]` +
`DontDestroyOnLoad` into **Game.unity**, and its `OnGUI` draws a `GUI.Button` on every IMGUI pass *before* its
early-out. It is a MonoBehaviour, so disabling ECS systems never touched it. It does not ship — but it polluted
every editor-side allocation number taken this session.
### B1 — pooled one-shot SFX (`OneShotAudioPool.cs`, new)
All 21 one-shot call sites funnel through the single line `FeedbackFx.PlayClip`
`AudioSource.PlayClipAtPoint`, which allocates a `GameObject` + `AudioSource` **per call** and schedules a
delayed `Destroy` — ~2033 calls/s in light combat. Replaced with a 32-voice ring; `PlayClip`'s signature is
unchanged so **all 20 consuming call sites compile untouched**.
Parity traps the design review caught before they shipped:
- **`spatialBlend = 1`** — a fresh `AudioSource` defaults to **2D**; `PlayClipAtPoint` is the only thing setting
it. Missing it would silently make every combat cue non-positional.
- **`dopplerLevel = 0`** — pooled voices *teleport* between events; at the stock `1` a 20 m jump pitch-bends the
clip. This bug cannot exist when the source is created at the position and never moves.
- **`DontDestroyOnLoad`** — `WorldLauncher` does `LoadScene(..., Single)` while the client world is alive; a
scene-parented pool would be destroyed mid-session and every SFX would go silently dead.
- **`[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` reset** — statics survive fast-enter-playmode but
the `UnityEngine.Object`s they point at do not; session two would hold an array of destroyed voices and throw
on the first cue.
- Recycle deadline from the actual `clip.length` (cues span 0.05 s0.45 s), steal-oldest when saturated,
`pitch` reset per rent, `GameVolume.Sfx` read at play time (never cached, never double-applying master).
### B2 — pooled authored VFX (`CombatFeedbackSystem`)
Per-impact `Object.Instantiate`/`Destroy` replaced with a per-prefab pool (`RentVfx`/`FillVfx`/`ReturnVfx`).
Traps handled, all from the risk pass:
- Component arrays cached **per instance**, not per prefab (component refs are instance-scoped; arrays captured
off the prefab asset would drive the asset).
- `main.stopAction = None` forced at fill — a prefab set to `Destroy`/`Disable` would silently drain the pool.
- Instances fill under an **inactive** root so `Awake`/`Start` never run, which is what makes `DestroyImmediate`
in `StripCosmetic` safe (a deferred `Destroy` would hand out an instance still carrying a live Rigidbody +
Collider for one frame).
- `StripCosmetic` now disables **all** MonoBehaviours, not two name substrings — a pooled instance re-runs
`OnEnable` on every rent, so a surviving helper would re-arm each time.
- Transform (position/rotation/**scale**/parent) rewritten per rent; `ps.Clear(true)` before `Play` (a
world-space system would re-show the previous burst); `TrailRenderer.Clear()` after the reposition.
- `Rented` flag = at-most-once guard against a double `Return` aliasing one instance to two callers.
- Returned by the prefab stored **on the record**, never a re-read of `VFXConfig` (an inspector swap mid-play
would file it under the wrong effect). In-flight cap `MaxActiveVfx = 40` unchanged; separate retained cap
`VfxPerPrefabRetain = 10`.
### B3 — per-frame allocation
- `BuildSlashInto` + `BuildDangerMesh`: four arrays each (~1.7 KB / ~840 B) allocated on **every** call — the
first runs twice a frame while a swing arc is alive, the second once per winding enemy per frame. Hoisted to
scratch fields; UVs/triangles are argument-independent so they now upload to a mesh only on its first fill.
- `HudSystem`, `AbilityBarSystem`: unguarded per-frame `Label.text` builds gated on a changed value.
- `CombatFeedbackSystem.AnimateNumbers`: legacy `TextMesh` bakes colour into vertex colours, so every colour
write rebuilds the text mesh — fade quantised to 12 steps.
- `EnemyHealthBarSystem`: per-bar uGUI writes epsilon-gated (an `anchorMax` write triggers
`OnRectTransformDimensionsChange`).
- `EnemyHitFlashSystem` / `NodeFeedbackSystem`: stopped playing back an **empty** `EntityCommandBuffer` (a
structural-change sync point) every frame.
- **`AudioClip` leak, closed across all seven clip-owning systems.** An `AudioClip.Create`d clip is a standalone
`UnityEngine.Object` — destroying a system's FX-root does **not** take it with it, so every client-world
teardown leaked its native audio buffer (`MusicSystem` alone ~6.8 MB, `AmbientAudioSystem` ~2 MB). Helper
promoted to `FeedbackFx.DestroyClip(ref AudioClip)`.
## Validation
- **L1** console clean (0 errors) against the session baseline.
- **L2** EditMode **304/304 green** (304 is the expected post-purge count, matching 2026-08-07).
- **L3 live**, the structural proofs that are immune to editor noise:
- **`oneShotAudioObjectsSeen = 0`** — `PlayClipAtPoint`'s signature `"One shot audio"` GameObject never
appeared once across 270 frames of combat with kills.
- VFX pool filled to its `VfxPerPrefabRetain = 10` cap and **stabilised** (bounded reuse, not churn).
- Audio verified live end-to-end: real cues (`husk_hit`, `strike`, `growl_grunt` ×2) serviced through the
ring within 2 s of a non-lethal injected hit.
- Clean-run frame time **median 8.6 ms / p99 13.2 ms** (pre-change runs measured 12.213.3 ms / 2021 ms).
Caveat: the two harnesses are not byte-identical (the pre-change one built a string per frame), so treat
the direction as solid and the exact delta as indicative.
- Allocation medians stayed inside the editor noise band — expected, given our layer is a minority of it.
**A player build is required for a true allocation number.**
### A false negative worth remembering
An intermediate audio check reported `framesWithAudio = 0/280` and looked exactly like a silent-audio
regression. It was a **dead window** — the previous run had killed every enemy, so nothing was cueing. Direct
probing (`OneShotAudioPool.Play``isPlaying = true`, plus `s_freeAt` showing four voices serviced in the last
0.4 s) disproved it. *Rule: before believing a "feature is dead" measurement, prove the stimulus actually
occurred.*
## Reviews
- **Pre-code audit** (4 lenses + completeness critic + refactor-risk verifier, 6/6 agents, 0 failures): produced
the parity spec and the trap list above. Caught `spatialBlend`, Doppler, `LoadScene(Single)`, and the
play-enter static reset **before** they shipped, plus the `PixelArtDevControls` measurement contaminant.
- **Post-impl diff review** (3 lenses + 19 adversarial verifiers, 22/22 agents, 0 failures): **19 findings → 5
confirmed**, all fixed:
1. **Real regression I introduced**`Mathf.RoundToInt` rounds **half-to-even** while `ToString("0.0")`
rounds **half-away-from-zero**, so my ability-bar gate key and the string it guarded disagreed at
midpoints; the label latched a stale, too-high reading and skipped a tenth on every cooldown. Fixed by
single-sourcing the text from the quantised integer and switching to `CeilToInt` (a countdown should never
read lower than the true remainder), with the branch bool cached so the format still switches at 597 ticks.
2. The clip-leak fix was **half-closed** — only `CombatFeedbackSystem`'s ten clips. Completed across all seven
owners.
35. Two duplicated comment blocks left by structured edits, and a doc-accuracy nit on the ring-size rationale.
## Part 3 — Track A shipped (the camera), + the dev-tool contaminant
Operator resolved the fork: **drop the position hold**, impact rides the FOV punch + shake.
**A2 — the hold is gone.** `PrototypeCameraRig.Hold`/`s_holdFrames`, `CombatFeedbackSystem.TryHold` and its four
call sites, and the `HitStopMaxFrames` / `HitStopFreezeEnabled` / `FinisherHoldFrames` knobs all retired; the
three saved feel profiles drop the dead keys (`FeelProfileService` skips unknown keys with a warning, so this
was safe either way — verified before removing).
**A1 — the shake channel, which the measurement forced.** Dropping the hold *alone* measured **worse** (max
deviation 0.653 vs 0.447): the hold had been partly masking the real bug by pinning the camera during the
frames shake was loudest. So A1 landed too — the follow now smooths a `_basePos` that shake never touches, and
shake is applied only when writing the transform.
| | max per-frame step | max deviation | mean deviation |
|---|---|---|---|
| Original | 0.283 | 0.447 (still 0.199 at +24 frames) | — |
| Hold dropped only | 0.429 | 0.653 | — |
| **Both fixes** | 0.428 (the shake impulse itself) | **0.329, back to 0.000 within a few frames** | **0.038** |
Deviation at f42/f45/f50 after the f40 kill is now literally `0.000` (was 0.269/0.429/0.399). Shake reads as a
crisp transient punch instead of a drift the follow filter spends half a second digesting.
**`PixelArtDevControls` is now opt-in.** Gated behind an EditorPrefs toggle (checked menu item, mirroring
`ProjectM/Boot Into Menu (Editor)`), off by default. Extra find: the object carries `HideFlags.DontSave`, so it
**survives play-mode exit** while the static `_instance` does not — the old code leaked one instance per domain
reload, and two live strays were cleared out of the editor.
## Notes / loose ends
- `Assets/_Project/Shaders/PixelOutline.mat` was dirtied by `PixelArtDevControls` writing to the shared material
asset during the Play sessions (`_MasterEnabled` 0→1). **Reverted on the operator's instruction** — it was not
an intentional change. The dev tool is now opt-in, so it cannot recur silently.
- **CLAUDE.md is at 39 936 / 40 960 bytes** — exactly the ≥1 KB-headroom target. Two ★ rules were added (camera
feel; pooling + per-frame cost) and paid for under the net-zero rule by retiring the DR-051 build-palette text
(that code died with the purge), the two purge enumerations now carried by their DRs, and prose trims across
the MCP-edit, swept-hit, LANTERN-direction and presentation bullets. It is still tight — a dedicated
condensation pass would buy room for the next few sessions.
## Next session
1. **Track A leftovers** (the two smaller items from the original plan, not yet done): **de-stack the lethal
frame** — the hit package and the kill package still both fire on the kill (measured shake 0.10+0.20 stacking,
two FOV punches, two SFX, four bursts) — and **prewarm** the first-kill VFX/audio path to kill the one-off
41 ms spike.
2. **Track C — feel**: enemies have **no collider** (you walk straight through them, which is the biggest
remaining feel gap); FOV pumps on every hit; the 0.9 s corpse window wants re-judging now the camera is calm.
3. Measure allocation in a **player build** to get a number free of editor contamination.
4. **Eyes-on the camera.** The numbers say it settles; whether the impact still reads as *impact* with the hold
gone is a feel call only the operator can make.
@@ -553,3 +553,93 @@ long forms are preserved here.
shaking."* An `EditorApplication.update` trace quantified it instantly (y 1.157↔13.559, 7.43 units/tick; after
the fix span 0.051, max step 0.004). Gate such effects on the presence of the owning component
(`GetComponent<PrototypeCameraRig>()`), never on a scene name.
## 2026-08-13 — Combat-freeze diagnosis + Track B (allocation / pooling)
Session log: [[2026-08-13_Combat_Freeze_Diagnosis_Track_B_Allocation]]. Payment for the CLAUDE.md line added
this session (net-zero rule): the six items below stay here, one condensed pointer lives inline.
1. **`GC.GetTotalMemory(false)` is USELESS for per-frame allocation attribution — it quantises to 4 KB pages.**
A 29-system toggle sweep produced medians of exactly `0` or `4096` and a "savings" column of pure noise; I
nearly shipped a refactor ranked off it. The correct instrument is
`ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame")`, which reports real
bytes/frame. Retract and re-measure rather than reasoning over a quantised signal.
2. **Attribute allocation by BULK toggle before ranking per-system work.** Disabling all 29 `ProjectM.Client`
systems at once dropped the median from ~15.1 KB to ~11.6 KB/frame — i.e. the whole presentation layer was
only ~1025 % of the editor-side number, while owning the spiky tail (p90 49 KB → 12 KB). Per-system deltas
were inside the noise; only the bulk delta was legible. Rank work off the bulk number, not the per-item one.
3. **★ An editor-only IMGUI dev tool can dominate your allocation measurement.** `PixelArtDevControls`
self-spawns via `[RuntimeInitializeOnLoadMethod(AfterSceneLoad)]` + `DontDestroyOnLoad` into **Game.unity**
(not just DevSandbox) and its `OnGUI` draws a `GUI.Button` on EVERY IMGUI pass *before* its `if (!_open)
return`. It is a MonoBehaviour, so an ECS-system toggle sweep never touches it. Exclude it before
attributing any editor-measured allocation to the game.
4. **★ A value-CHANGE gate must be single-sourced with the value it renders.** Gating a label on
`Mathf.RoundToInt(x)` while the string is built by `ToString("0.0")` is a live bug: `Mathf.RoundToInt` is
round-half-to-**EVEN**, custom numeric formats round half-**AWAY-from-zero**. They disagree at midpoints, so
the gate latches and the label holds a stale, too-high reading and visibly skips a value. Derive the rendered
text FROM the quantised key, never re-derive it from the raw input. For a countdown prefer `CeilToInt` so the
readout never reads lower than the true remainder. (Caught by the post-impl review, not by 304 green tests.)
5. **Pooling an `AudioSource` is not a drop-in for `AudioSource.PlayClipAtPoint` — three properties bite.**
(a) a fresh `AudioSource` defaults to `spatialBlend = 0` (**2D**); PlayClipAtPoint is the only thing setting
it to 1, so a naive pool silently makes every cue non-positional and no test catches it. (b) `dopplerLevel`
defaults to 1 and a pooled voice **teleports** between events — a 20 m jump pitch-bends the clip, a failure
that cannot exist when the source is created at the position and never moves; set 0. (c) `playOnAwake`
defaults true, which is harmless for a one-frame object and wrong for a long-lived one. Also: the pool root
needs `DontDestroyOnLoad` (WorldLauncher does `LoadScene(..., Single)` while the client world is alive) and a
`[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` reset, or session two holds destroyed voices.
6. **Pooling a ParticleSystem prefab: `SetActive(false)/(true)` does NOT reset it.** Rent order is transform →
`SetActive(true)``ps.Clear(true); ps.Play(true)` (a world-space system otherwise re-shows the previous
burst at its OLD positions) and `TrailRenderer.Clear()` AFTER the reposition (else a streak draws from the
last despawn point). Force `main.stopAction = None` at fill — a prefab set to `Destroy` silently drains the
pool. Cache component arrays **per instance** (component refs are instance-scoped; arrays captured off the
prefab asset drive the asset). Fill under an **inactive** root so `Awake`/`Start` never run, which is what
makes `DestroyImmediate` of the Rigidbody/Collider safe — a deferred `Destroy` would hand out an instance
still carrying them for one frame. Guard `Return` with a `Rented` flag: a double return aliases one instance
to two callers.
7. **An `AudioClip.Create`d clip is NOT owned by the GameObject that played it.** Destroying a system's FX-root
in `OnDestroy` leaves every procedural clip alive — seven presentation systems here leaked their native audio
buffers on every client-world teardown (`MusicSystem` ~6.8 MB, `AmbientAudioSystem` ~2 MB), growing unbounded
across menu → game → menu cycles. Shared helper: `FeedbackFx.DestroyClip(ref AudioClip)`.
8. **Before believing a "the feature is dead" measurement, prove the STIMULUS occurred.** A 280-frame watch
reported `framesWithAudio = 0` and looked exactly like a silent-audio regression from the new pool. It was a
dead window — the prior run had killed every enemy, so nothing was cueing. Direct probing (a synthetic
`PlayClip``isPlaying = true`, plus `s_freeAt` showing four voices serviced within 0.4 s) disproved it in
one call. The same false-negative class as the 07-21 auto-recast retraction.
9. **Injecting a kill: append a lethal `DamageEvent`, never write `Health.Current = 0`.**
`HealthApplyDamageSystem` early-`continue`s on an empty `DamageEvent` buffer, so a direct Health write is
never seen by the death branch and no `Dying` is ever stamped.
### 2026-08-13b — camera feel (Track A, same session)
10. **★ Never let a transient offset (shake, recoil, punch) live in the same value your smoothing filter reads
back.** `Lerp(transform.position, desired, k)` where `transform.position` already carries last frame's shake
makes the filter treat shake as real positional error and INTEGRATE it — at `FollowSharpness 8` / ~12 ms
that is ~9 %/frame, so it washes out over half a second while new shake piles on. Measured on a stationary
player (ideal motion = 0): one kill left the camera 0.447 units off-frame, still 0.199 off 24 frames later.
Fix: smooth a private `_basePos` the offset never touches; add the offset only when writing the transform.
After: deviation returns to 0.000 within a few frames, mean 0.038.
11. **A frame-counted hold is a framerate-dependent freeze.** `FinisherHoldFrames = 7` was documented as
"~117 ms" — true only at 60 fps; it is 49 ms at 144 and 233 ms at 30. Anything that gates on *feel duration*
must be time-based. Worse, the hold branch took its base from `transform.position`, permanently baking that
frame's shake in (a measured 0.283-unit single-frame jump).
12. **★ A masking fix can make the metric WORSE before it makes it better — measure the intermediate state.**
Dropping the camera hold alone measured worse than leaving it (max deviation 0.653 vs 0.447), because the
hold had been pinning the camera during the frames shake was loudest and thereby hiding the integration bug
in #10. Had I shipped the drop on its own and stopped, the "fix" would have been a regression. Re-measure
after each half of a two-part fix.
13. **`HideFlags.DontSave` objects SURVIVE play-mode exit.** A self-spawning dev tool created with
`HideAndDontSave` + `DontDestroyOnLoad` outlives the play session while its `static _instance` guard is
cleared by the domain reload — so it leaks one live instance per reload (two were found). If you gate such
a bootstrap off, also sweep the existing strays with
`Resources.FindObjectsOfTypeAll<GameObject>()``GameObject.Find` alone will not show you the history.