Runtime diagnostic tool that records door/link state, barricade changes, zombie stalls and related events to a log file (or relays rows from clients to the host). Samples at a configured interval, snapshots initial state, writes change rows and special "GHOST" rows when a barrier is standing while its nav link is open. Includes a driver component to call Tick each frame.
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Linq;
namespace NZombies;
/// <summary>
/// A whole-session trace of doors, barricades and zombie pathing, written to disk.
/// `nz_watch start` … play … `nz_watch stop`.
///
/// ⛔ IT EXISTS BECAUSE THE BUG WILL NOT REPRODUCE ON DEMAND. The report is *"the zombies path as
/// if the doors are closed, including collisions"*, and the answer is a three-way split that
/// `nz_debris_why` can only settle IN THE MOMENT — link open with the prop still standing, prop
/// gone with the nav blocker surviving it, or everything removed and the tiles simply not
/// regenerated. Asking someone to type a command at the instant a horde is on them is asking for
/// the one reading nobody can take. User: *"i could not replicate it — also running commands mid
/// game is hard."*
///
/// ⛔ EVENTS AND TRANSITIONS, NEVER A PER-FRAME DUMP. A snapshot every frame of fifty zombies and
/// forty barriers is a megabyte a minute in which nothing stands out. Every row here is something
/// CHANGING or a condition that should never be true — so an empty log after a clean round is the
/// correct output, and any row at all is worth reading.
///
/// ⚠️ THE ROW THAT MATTERS IS `GHOST`. A link recorded open while its barrier is still standing is
/// the reported bug, stated in one line, with the moment it began. It is mirrored to the console as
/// well as the file precisely because it should be rare enough to be worth interrupting for.
///
/// ⚠️ SAMPLED AT 5 Hz, NOT EVERY FRAME. The things being watched change on the scale of a door
/// opening or a zombie standing still for a second; a frame-rate sample would cost more than it
/// could ever reveal and would itself be a reason the bug moved.
///
/// ⚠️ BUFFERED AND APPENDED, matching `NavLinkLog` and `PerfLog` rather than inventing a third
/// shape. Those two already established where diagnostics live and how they are flushed.
/// </summary>
public static class SessionWatch
{
const string Dir = "watch";
/// <summary>Seconds between samples. 0.2 = 5 Hz.</summary>
public static float Interval { get; set; } = 0.2f;
/// <summary>
/// How long a chasing zombie may stand still before it is reported, in seconds. 1.5.
///
/// ⚠️ DELIBERATELY SHORTER THAN `StuckTimeout` (5s), WHICH IS THE POINT. The anti-stuck already
/// logs when it fires; by then the zombie has been relocated and the evidence of WHY is gone.
/// This catches the same zombie three and a half seconds earlier, while it is still standing in
/// front of whatever is stopping it.
/// </summary>
public static float StallSeconds { get; set; } = 1.5f;
/// <summary>Units a zombie must move to count as moving. 8.</summary>
public static float StallRadius { get; set; } = 8f;
public static string Path { get; private set; }
/// <summary>
/// This machine is a CLIENT feeding the host's log rather than writing one.
///
/// ⛔ THE WHOLE POINT IS ONE FILE, NOT TWO. Two logs on two machines is what every previous
/// round of this produced: each one internally consistent, neither one able to say the two
/// disagreed. `NetProbe`'s header makes the same argument. So a client records nothing locally
/// and sends its rows to the host, which interleaves them into the single file by timestamp.
/// </summary>
static bool _relay;
public static bool Active => !string.IsNullOrEmpty( Path ) || _relay;
public static int Rows { get; private set; }
/// <summary>Rows a client may relay per second before it starts dropping them. 20.</summary>
public static int RelayBudget { get; set; } = 20;
static int _relayedThisSecond;
static RealTimeSince _relayWindow;
static int _relayDropped;
static readonly List<string> _buffer = new();
static RealTimeSince _sinceSample;
static RealTimeSince _sinceStart;
// ── remembered state, so only CHANGES are written ────────────────────────
static readonly Dictionary<string, bool> _linkOpen = new();
static readonly Dictionary<int, bool> _debrisStanding = new();
static readonly Dictionary<int, string> _barricade = new();
static readonly Dictionary<ZombieAI, (Vector3 at, float since, bool reported)> _zombies = new();
static int _lastUnstuck;
// ══ commands ════════════════════════════════════════════════════════════
/// <summary>
/// `nz_watch [start|stop|reset]` — trace doors, barricades and zombie pathing to a file.
///
/// ⚠️ ONE COMMAND WITH SUBCOMMANDS, matching `nz_navlink_intent`. Three separate commands is
/// three things to remember mid-game, which is the complaint this whole file answers.
/// </summary>
[ConCmd( "nz_watch" )]
public static void Cmd( string arg = "" )
{
switch ( arg.ToLowerInvariant() )
{
case "start": Start(); return;
case "stop": Stop(); return;
case "reset": Reset(); return;
}
if ( !Active )
{
Log.Info( "[nz-watch] not recording. `nz_watch start` to begin, `stop` to finish." );
Log.Info( "[nz-watch] records door/link changes, barricade changes, zombies that"
+ " stall, and any barrier still standing on an OPEN link." );
return;
}
Log.Info( $"[nz-watch] recording to {Path} — {Rows} row(s), {_sinceStart:0}s elapsed" );
}
public static void Start()
{
if ( Active )
{
Log.Info( $"[nz-watch] already recording to {Path} — {Rows} row(s)" );
return;
}
if ( !FileSystem.Data.DirectoryExists( Dir ) )
FileSystem.Data.CreateDirectory( Dir );
for ( int i = 1; i < 10000; i++ )
{
var candidate = $"{Dir}/watch_{i:000}.log";
if ( FileSystem.Data.FileExists( candidate ) ) continue;
Path = candidate;
break;
}
if ( !Active ) { Log.Warning( "[nz-watch] could not find a free log name" ); return; }
_buffer.Clear();
_linkOpen.Clear();
_debrisStanding.Clear();
_barricade.Clear();
_zombies.Clear();
Rows = 0;
_sinceStart = 0;
_sinceSample = 0;
_lastUnstuck = ZombieAI.UnstuckCount;
FileSystem.Data.WriteAllText( Path, "" );
EnsureDriver();
Snapshot();
Flush();
// ⚠️ AND THE CLIENTS START TOO, DRIVEN FROM HERE. The precedent is `NZNet.HandsFixAsk`,
// whose own note says it exists so a diagnostic does not depend on somebody typing into the
// client's console — a command added after that instance launched may simply not be there.
// Requested in the same words: run it on the host, get the client's side as well.
if ( Networking.IsActive && NZGame.IsHost )
NZNet.WatchAsk( true );
Log.Info( $"[nz-watch] recording to {Path} — `nz_watch stop` when the bug happens" );
if ( Networking.IsActive && NZGame.IsHost )
Log.Info( "[nz-watch] clients are recording into this same file, tagged by name" );
}
/// <summary>
/// A client begins feeding the host's log. Nothing is written here.
///
/// ⚠️ NO FILE, NO DRIVER SNAPSHOT TO DISK — but the snapshot still RUNS, because the
/// client's opening state is exactly what the host's own cannot tell you. A door the two
/// machines disagree about at second zero is the finding.
/// </summary>
public static void StartRelay()
{
if ( Active ) return;
_buffer.Clear();
_linkOpen.Clear();
_debrisStanding.Clear();
_barricade.Clear();
_zombies.Clear();
_once.Clear();
Rows = 0;
_sinceStart = 0;
_sinceSample = 0;
_relayedThisSecond = 0;
_relayDropped = 0;
_relayWindow = 0;
_relay = true;
EnsureDriver();
Snapshot();
Log.Info( "[nz-watch] relaying to the host's log" );
}
/// <summary>Stop relaying. Called from the host's `nz_watch stop`.</summary>
public static void StopRelay()
{
if ( !_relay ) return;
if ( _relayDropped > 0 )
Write( "NOTE", $"{_relayDropped} row(s) dropped — over the {RelayBudget}/s relay budget" );
_relay = false;
_driver?.Destroy();
_driver = null;
Log.Info( "[nz-watch] stopped relaying" );
}
public static void Stop()
{
if ( !Active ) { Log.Info( "[nz-watch] was not recording" ); return; }
if ( Networking.IsActive && NZGame.IsHost )
NZNet.WatchAsk( false );
Write( "STOP", $"{_sinceStart:0}s elapsed" );
Flush();
_driver?.Destroy();
_driver = null;
Log.Info( $"[nz-watch] stopped — {Rows} row(s) in {Path}" );
Log.Info( "[nz-watch] the file is under the s&box data folder for this project,"
+ $" in '{Dir}'." );
Path = null;
}
/// <summary>Stop and delete every log, for a clean slate.</summary>
public static void Reset()
{
Path = null;
_buffer.Clear();
if ( !FileSystem.Data.DirectoryExists( Dir ) ) { Log.Info( "[nz-watch] nothing to clear" ); return; }
var gone = 0;
foreach ( var f in FileSystem.Data.FindFile( Dir, "*.log" ).ToList() )
{
FileSystem.Data.DeleteFile( $"{Dir}/{f}" );
gone++;
}
Log.Info( $"[nz-watch] cleared {gone} log(s)" );
}
// ══ the tick ════════════════════════════════════════════════════════════
/// <summary>
/// Called every frame from the game loop; samples on its own interval.
///
/// ⚠️ IT COSTS ONE FLOAT COMPARE WHEN OFF, which is why it can be called unconditionally
/// rather than gated at the call site. A diagnostic the caller has to remember to guard is a
/// diagnostic that eventually gets guarded wrong.
/// </summary>
public static void Tick()
{
if ( !Active ) return;
if ( _sinceSample < Interval ) return;
_sinceSample = 0;
SampleDoors();
SampleBarricades();
// ⛔ A CLIENT'S ZOMBIES ARE NOT AUTHORITATIVE AND WOULD BE NOISE, NOT EVIDENCE. Zombie
// objects replicate, so `ZombieAI.All` is populated here — but `State`, `Target` and the
// crossing latches are all decided on the host and are whatever the local copy last
// defaulted to. Every client zombie would look permanently stalled with `state=Idle
// target=none`, which is a page of rows a second saying nothing. The client's value is
// its view of DOORS and BARRICADES, which is the half the host cannot see.
if ( !_relay ) SampleZombies();
if ( _buffer.Count >= 32 ) Flush();
}
// ══ what is watched ═════════════════════════════════════════════════════
/// <summary>
/// Doors: a link changing state, and the bug itself — a barrier standing on an open link.
///
/// ⛔ `GHOST` IS THE WHOLE REASON THIS FILE EXISTS. `DoorLinks.IsOpen` says the door is open and
/// `PropAt` says the barrier is still in the world: pathing blocked, collisions blocked, flags
/// correct. That is the report, in one row, with a timestamp.
///
/// ⚠️ REPORTED ONCE PER DEBRIS, NOT ONCE PER SAMPLE. A ghost persists, and five rows a second
/// saying the same thing would bury the door event that caused it.
/// </summary>
static void SampleDoors()
{
var list = ActiveConfig.Current?.Debris;
var mgr = DebrisManager.Instance;
if ( list is null || mgr is null ) return;
for ( int i = 0; i < list.Count; i++ )
{
var d = list[i];
var open = DoorLinks.IsOpen( d.Link );
if ( !_linkOpen.TryGetValue( d.Link, out var was ) || was != open )
{
_linkOpen[d.Link] = open;
if ( was != open || !_linkOpen.ContainsKey( d.Link ) )
Write( "DOOR", $"link '{d.Link}' -> {(open ? "OPEN" : "closed")}" );
}
var go = mgr.PropAt( i );
var standing = go.IsValid() && go.Enabled;
if ( !_debrisStanding.TryGetValue( i, out var wasStanding ) || wasStanding != standing )
{
_debrisStanding[i] = standing;
Write( "PROP", $"#{i} link '{d.Link}' -> {(standing ? "standing" : "gone")}" );
}
// the bug
if ( open && standing )
{
var blocker = go.Components
.GetAll<NavMeshArea>( FindMode.EverythingInSelfAndDescendants )
.Any( a => a.IsValid() && a.Enabled && a.IsBlocker );
WriteOnce( $"ghost{i}", "GHOST",
$"#{i} link '{d.Link}' is OPEN but the barrier is STILL STANDING"
+ $" at {go.WorldPosition:0} — navBlocker={(blocker ? "yes" : "no")}"
+ " <<< this is the door bug", mirror: true );
}
}
}
/// <summary>Barricades: boards torn or repaired, and the nav link appearing or vanishing.</summary>
static void SampleBarricades()
{
for ( int i = 0; i < Barricade.All.Count; i++ )
{
var b = Barricade.All[i];
if ( !b.IsValid() ) continue;
var now = $"{b.Planks}|{b.NavLinked}|{b.CrossValid}";
if ( _barricade.TryGetValue( i, out var was ) && was == now ) continue;
_barricade[i] = now;
// ⚠️ THE FIRST SAMPLE IS NOT A CHANGE. Without this every barricade writes a row at
// startup and the snapshot above is printed twice in two formats.
if ( was is null ) continue;
Write( "BARR", $"#{i} planks {b.Planks} navLinked={b.NavLinked}"
+ $" crossValid={b.CrossValid}" );
}
}
/// <summary>
/// Zombies: anyone chasing who has stopped moving, and the anti-stuck firing.
///
/// ⚠️ IT REPORTS EARLIER THAN THE ANTI-STUCK AND SAYS MORE. `Unstick` fires at 5s and then
/// teleports, which destroys the evidence; this fires at 1.5s and records what the zombie
/// thought it was doing — state, target, whether it believed it was crossing or on a link, and
/// the nearest barrier. That last one is what ties a stall to a specific door.
/// </summary>
static void SampleZombies()
{
if ( ZombieAI.UnstuckCount != _lastUnstuck )
{
Write( "UNSTUCK", $"anti-stuck has now fired {ZombieAI.UnstuckCount} time(s)"
+ $" this session (+{ZombieAI.UnstuckCount - _lastUnstuck})" );
_lastUnstuck = ZombieAI.UnstuckCount;
}
// drop the dead so the dictionary cannot grow for the life of the round
foreach ( var gone in _zombies.Keys.Where( z => !z.IsValid() ).ToList() )
_zombies.Remove( gone );
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() ) continue;
var at = z.WorldPosition;
if ( !_zombies.TryGetValue( z, out var prev ) )
{
_zombies[z] = (at, 0f, false);
continue;
}
if ( at.Distance( prev.at ) > StallRadius )
{
_zombies[z] = (at, 0f, false);
continue;
}
var stalled = prev.since + Interval;
// ⚠️ ONE ROW PER STALL, not one per sample — `reported` latches until it moves again.
// ⛔ ONLY STILLNESS THE GAME ITSELF CALLS WRONG. The first log was 196 STALL rows of
// which 162 were `state=Dead`, `Attacking` or `Idle` — a corpse, a zombie tearing
// boards and a zombie with nobody to chase are all SUPPOSED to be still, and
// `ZombieAI.StillnessIsExpected` says so before the anti-stuck will touch them. Logging
// them buried the 34 rows that meant something under five times their number in noise.
//
// ⚠️ THE SAME CONDITIONS THAT GATE THE ANTI-STUCK, deliberately mirrored rather than
// invented: Chasing, a live target, and not already mid-crossing. If this list and that
// one drift, the log stops describing the system it is watching — §2.
var worthReporting = z.State == ZombieState.Chasing
&& z.Target.IsValid()
&& !z.CrossingLink
&& !z.OnLink;
if ( stalled >= StallSeconds && !prev.reported && worthReporting )
{
_zombies[z] = (prev.at, stalled, true);
Write( "STALL", $"'{z.GameObject.Name}' still for {stalled:0.0}s at {at:0}"
+ $" state={z.State} target={(z.Target.IsValid() ? z.Target.Name : "none")}"
+ $" crossing={z.CrossingLink} onLink={z.OnLink}"
+ $" {z.AgentDebug}"
+ $" nearest: {NearestBarrier( at )}" );
continue;
}
_zombies[z] = (prev.at, stalled, prev.reported);
}
}
/// <summary>
/// The closest debris or barricade to a point, and whether it is open.
///
/// ⚠️ THIS IS WHAT TURNS A STALL INTO A LEAD. "A zombie stopped at -1731,1066" is a
/// coordinate; "a zombie stopped 40 units from debris #12, whose link is open" is the bug.
/// </summary>
static string NearestBarrier( Vector3 at )
{
var best = "nothing within 300u";
var bestDist = 300f;
var list = ActiveConfig.Current?.Debris;
var mgr = DebrisManager.Instance;
if ( list is not null && mgr is not null )
{
for ( int i = 0; i < list.Count; i++ )
{
var go = mgr.PropAt( i );
if ( !go.IsValid() ) continue;
var d = go.WorldPosition.Distance( at );
if ( d >= bestDist ) continue;
bestDist = d;
best = $"debris #{i} at {d:0}u, link '{list[i].Link}'"
+ $" {(DoorLinks.IsOpen( list[i].Link ) ? "OPEN" : "closed")}";
}
}
for ( int i = 0; i < Barricade.All.Count; i++ )
{
var b = Barricade.All[i];
if ( !b.IsValid() ) continue;
var d = b.WorldPosition.Distance( at );
if ( d >= bestDist ) continue;
bestDist = d;
// ⛔ THE TWO NUMBERS `BlockingBarricade` ACTUALLY TESTS, because the first log reported
// neither and could not settle the question. That gate is
// `InReach( pos, BarricadeReach )`, which is `DistanceToRun <= 38` AND
// `|z - runZ| <= VerticalReach (64)`. `DistanceToRun` is measured to the window's RUN
// and IGNORES Z, while the straight-line distance logged before is to the barricade's
// ORIGIN in 3D — so the two are not comparable, and reading 42u told us nothing about
// whether a 38u gate had passed. Naming both halves is what makes the next log
// decisive: too far along the wall, or standing on the wrong deck.
var runZ = (b.RunA.z + b.RunB.z) * 0.5f;
var toRun = b.DistanceToRun( at );
var dz = MathF.Abs( at.z - runZ );
best = $"barricade #{i} at {d:0}u, planks {b.Planks}, navLinked={b.NavLinked}"
+ $", toRun {toRun:0.0}u, dz {dz:0.0}u, inReach={b.InReach( at, 38f )}";
}
return best;
}
// ══ writing ═════════════════════════════════════════════════════════════
/// <summary>Everything worth knowing before the first event, so the log reads standalone.</summary>
static void Snapshot()
{
Write( "START", $"map '{NZMap.Current}' mode {NZGame.Mode}"
+ $" {(Networking.IsActive ? (NZGame.IsHost ? "HOST" : "CLIENT") : "solo")}"
+ $" round {(RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0)}" );
var list = ActiveConfig.Current?.Debris;
var mgr = DebrisManager.Instance;
if ( list is not null && mgr is not null )
{
for ( int i = 0; i < list.Count; i++ )
{
var open = DoorLinks.IsOpen( list[i].Link );
var go = mgr.PropAt( i );
var standing = go.IsValid() && go.Enabled;
_linkOpen[list[i].Link] = open;
_debrisStanding[i] = standing;
Write( "SNAP", $"debris #{i} link '{list[i].Link}'"
+ $" {(open ? "OPEN" : "closed")} {(standing ? "standing" : "gone")}" );
}
}
for ( int i = 0; i < Barricade.All.Count; i++ )
{
var b = Barricade.All[i];
if ( !b.IsValid() ) continue;
_barricade[i] = $"{b.Planks}|{b.NavLinked}|{b.CrossValid}";
Write( "SNAP", $"barricade #{i} planks {b.Planks}"
+ $" navLinked={b.NavLinked} crossValid={b.CrossValid}" );
}
}
static readonly HashSet<string> _once = new();
/// <summary>A row that must not repeat while the condition persists.</summary>
static void WriteOnce( string key, string kind, string text, bool mirror = false )
{
if ( !_once.Add( key ) ) return;
Write( kind, text, mirror );
}
static void Write( string kind, string text, bool mirror = false )
{
if ( !Active ) return;
// ⚠️ A CLIENT SENDS AND KEEPS NOTHING. The host stamps its own arrival time, so the two
// machines' rows interleave on ONE clock rather than on two that started at different
// moments — which is the comparison this is for.
if ( _relay )
{
if ( _relayWindow >= 1f ) { _relayWindow = 0; _relayedThisSecond = 0; }
// ⚠️ A BUDGET, BECAUSE A RELAY IS A NETWORK CALL PER ROW. Rows are transitions and
// should be rare; if something starts producing them by the hundred, dropping the
// excess and SAYING SO at the end beats a diagnostic that degrades the session it is
// supposed to be measuring.
if ( _relayedThisSecond++ >= RelayBudget ) { _relayDropped++; return; }
NZNet.WatchRow( Connection.Local?.DisplayName ?? "client", kind, text );
Rows++;
return;
}
// ⚠️ CAST, OR THE FORMAT IS IGNORED. `RealTimeSince` is a struct with an implicit
// conversion, and interpolating it directly printed `0.0023994148` where `0.00` was
// asked for — the numeric format only applies once it is actually a float.
_buffer.Add( $"{(float)_sinceStart,8:0.00} {kind,-8} {text}" );
Rows++;
// ⚠️ MIRRORED ONLY FOR THE ROW THAT MATTERS. Everything else is read from the file after
// the fact; putting it all on screen would recreate the console spam this avoids.
if ( mirror ) Log.Warning( $"[nz-watch] {text}" );
}
static GameObject _driver;
/// <summary>
/// Something has to call <see cref="Tick"/>, and it cannot be the scene file.
///
/// ⛔ CREATED FROM CODE, `NotSaved`, exactly as `PerfLog.EnsureDriver` does it. A component
/// placed in `nzombies.scene` would be the obvious alternative and is not available: this
/// project does not rewrite scene files from scripts, and a diagnostic that requires someone to
/// add an object by hand before it works is a diagnostic that is not there when it is needed.
///
/// ⚠️ REBUILT ON EVERY `Start` RATHER THAN ONCE, because a GameObject made from code does
/// not survive a hotload — the same reason `RecoilTuner.EnsureHost` re-checks.
/// </summary>
static void EnsureDriver()
{
if ( _driver.IsValid() ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-watch] no scene — nothing to watch" ); return; }
_driver = scene.CreateObject();
_driver.Name = "Session Watch Driver";
_driver.Flags |= GameObjectFlags.NotSaved;
_driver.Components.Create<SessionWatchDriver>();
}
/// <summary>
/// A row arriving from a client. Host only — see <see cref="NZNet.WatchRow"/>.
///
/// ⚠️ TAGGED WITH WHO SENT IT AND STAMPED WITH THE HOST'S CLOCK. The tag is what makes a
/// disagreement visible at a glance; the host's clock is what makes the two machines' rows
/// comparable at all, since a client's own elapsed time starts whenever its relay did.
/// </summary>
public static void Receive( string who, string kind, string text )
{
if ( string.IsNullOrEmpty( Path ) ) return;
_buffer.Add( $"{(float)_sinceStart,8:0.00} {kind,-8} [{who}] {text}" );
Rows++;
if ( kind == "GHOST" ) Log.Warning( $"[nz-watch] ({who}) {text}" );
if ( _buffer.Count >= 32 ) Flush();
}
static void Flush()
{
if ( !Active || _buffer.Count == 0 ) return;
var block = string.Concat( _buffer.Select( r => r + "\n" ) );
_buffer.Clear();
try
{
// BYTES, NOT A StreamWriter. `new StreamWriter( Stream )` is not on the s&box
// whitelist -- it compiles under `dotnet build` and is REFUSED by the editor, which is
// the one compiler whose opinion ships. NavLinkLog and PerfLog both already write
// through Encoding.UTF8.GetBytes for this reason; this is the third file to need it and
// the second time this session that `dotnet build` passing has meant nothing.
using var stream = FileSystem.Data.OpenWrite( Path, FileMode.Append );
var bytes = Encoding.UTF8.GetBytes( block );
stream.Write( bytes, 0, bytes.Length );
}
catch ( Exception e )
{
// SAID OUT LOUD AND THE LOG IS CLOSED, the rule NavLinkLog and PerfLog both state:
// a logger that silently stops writing hands back a truncated file that looks complete.
Log.Warning( $"[nz-watch] log write failed, stopping: {e.Message}" );
Path = null;
}
}
}
/// <summary>
/// Calls <see cref="SessionWatch.Tick"/> every frame.
///
/// ⚠️ `OnUpdate`, NOT `OnFixedUpdate`. The thing being watched is a door disappearing and a
/// zombie standing still, both of which are frame-rate events rather than physics ones — and
/// `Tick` rate-limits itself to 5 Hz anyway, so the tighter loop costs one compare.
/// </summary>
public sealed class SessionWatchDriver : Component
{
protected override void OnUpdate() => SessionWatch.Tick();
}