Diagnostics utility for s&box networking. Defines a ProbeMarker component attached to scene objects and a NetProbe static class that creates labeled probe objects, spawns them with different conditions, reports what each machine (host and clients) sees, and clears them.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// A LABELLED OBJECT WHOSE ONLY JOB IS TO BE ASKED ABOUT FROM BOTH MACHINES.
///
/// ⚠️ `Tag` IS A PLAIN `[Property]` ON PURPOSE — that is one of the things being measured. If a
/// property set before `NetworkSpawn` arrives on the other machine, this reads it back there.
/// </summary>
public sealed class ProbeMarker : Component
{
/// <summary>Which question this object answers. See <see cref="NetProbe"/>.</summary>
[Property] public string Probe { get; set; } = "?";
/// <summary>Set before the spawn on some, after it on others — see the table.</summary>
[Property] public string Stamp { get; set; } = "";
/// <summary>Should the host push it up and down? Only the host ever moves one.</summary>
[Property] public bool Moves { get; set; }
Vector3 _origin;
protected override void OnStart() => _origin = WorldPosition;
protected override void OnUpdate()
{
// ⛔ MOVED BY THE HOST ALONE, AND NEVER GATED ON `IsProxy`. Whether `IsProxy` is
// trustworthy is one of the things this probe exists to find out, so using it here would
// make the experiment depend on its own conclusion.
if ( !Moves || !NZGame.IsHost ) return;
WorldPosition = _origin + Vector3.Up * (MathF.Sin( Time.Now * 2f ) * 40f);
}
}
/// <summary>
/// MEASURE THE ENGINE ONCE, INSTEAD OF GUESSING AT IT REPEATEDLY.
///
/// ⛔ EVERY MULTIPLAYER BUG IN THIS PROJECT SO FAR CAME FROM AN UNVERIFIED ASSUMPTION ABOUT
/// s&box NETWORKING, AND EACH ONE COST A FULL TWO-MACHINE TEST CYCLE TO DISPROVE:
///
/// "IsProxy means belongs to someone else" → camera hijack, invisible player, desynced zombies
/// "OwnerId matches Connection.Local.Id" → the client had no body at all
/// "TakeOwnership sets the owner" → it does not; found by accident
/// "a Snapshot object replicates its transform" → the host stood frozen for 86 seconds
///
/// Each was plausible from the documentation. Each was wrong. Reading more documentation is not
/// the fix — the fix is to ask the engine directly, once, and write the answers down.
///
/// ⚠️ HOW IT WORKS: the host builds one object per question, each labelled, then every machine
/// reports what it sees. **The clients report TO THE HOST**, so both views land in one console —
/// which is the console that can be read remotely. That is the whole point: a disagreement is
/// only visible when both answers are in the same place.
///
/// nz_probe on the host, with a client connected
/// nz_probe_say each machine reports (the host asks everyone automatically)
/// nz_probe_clear tidy up
/// </summary>
public static class NetProbe
{
/// <summary>The questions, and what each object is set up to answer.</summary>
static readonly (string Id, string Question)[] Questions =
{
("A_unowned", "NetworkSpawn() with NO owner - does it replicate, and is it a proxy anywhere?"),
("B_owned", "NetworkSpawn(host) - the control. This one is known to work."),
("C_prop_before", "a [Property] written BEFORE the spawn - does it cross?"),
("D_prop_after", "the same property written AFTER the spawn - does a later change cross?"),
("E_disabled", "spawned while DISABLED - does the spawn take at all?"),
("F_toggled", "spawned enabled then disabled - does Enabled replicate?"),
};
/// <summary>
/// `nz_probe` — build the experiment. Host only, with at least one client connected.
/// </summary>
[ConCmd( "nz_probe" )]
public static void Run()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-probe] no scene" ); return; }
if ( !NZGame.IsHost )
{
Log.Warning( "[nz-probe] the HOST builds the experiment. Run it there." );
return;
}
if ( !Networking.IsActive )
Log.Warning( "[nz-probe] ⚠ networking is not active — this will only measure the "
+ "local half and every answer will look fine. Host first." );
Clear();
var at = Vector3.Zero;
var local = Connection.Local;
// ⚠️ A ROW OF THEM, SPACED, so a screenshot or a trace can tell them apart too.
var i = 0;
foreach ( var (id, _) in Questions )
{
var go = scene.CreateObject();
go.Name = $"probe_{id}";
go.Flags |= GameObjectFlags.NotSaved;
go.WorldPosition = at + Vector3.Right * (i++ * 64f) + Vector3.Up * 64f;
var m = go.Components.Create<ProbeMarker>();
m.Probe = id;
m.Moves = id is "A_unowned" or "B_owned";
// ⚠️ WRITTEN BEFORE THE SPAWN for C; D is deliberately left until after.
if ( id == "C_prop_before" ) m.Stamp = "set-before-spawn";
if ( id == "E_disabled" ) go.Enabled = false;
// ── the spawn itself, which is the variable ──────────────────────────────
if ( id == "B_owned" ) go.NetworkSpawn( local );
else go.NetworkSpawn();
if ( id == "D_prop_after" ) m.Stamp = "set-after-spawn";
if ( id == "F_toggled" ) go.Enabled = false;
}
Log.Info( $"[nz-probe] built {Questions.Length} probe objects. Asking everyone to report…" );
Say();
NZNet.ProbeAsk();
}
/// <summary>
/// `nz_probe_say` — report what THIS machine sees. Every machine runs it; clients send the
/// lines to the host.
///
/// ⚠️ ONE LINE PER OBJECT, FIXED WIDTH, so the host's two blocks can be read as a diff by eye.
/// </summary>
[ConCmd( "nz_probe_say" )]
public static void Say()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
// ⛔ RE-SAMPLING IS THE WHOLE POINT FOR THE MOVING ONES. The first report was taken in
// the same frame the objects were created, so nothing had ticked and every z read as its
// spawn height — which looks like "the transform agrees" and proves nothing at all. Asking
// again a second later is what actually tests replication.
if ( NZGame.IsHost ) NZNet.ProbeAsk();
var who = NZGame.IsHost ? "HOST " : "CLIENT";
var seen = scene.GetAllComponents<ProbeMarker>().Where( m => m.IsValid() ).ToList();
// ⚠️ DISABLED OBJECTS TOO — E and F are the whole point of that.
var all = scene.GetAllObjects( false )
.Select( o => o.Components.Get<ProbeMarker>( FindMode.EverythingInSelf ) )
.Where( m => m.IsValid() )
.ToList();
Emit( $"[nz-probe] {who} sees {all.Count} of {Questions.Length} probe object(s)"
+ $" ({seen.Count} enabled)" );
foreach ( var (id, question) in Questions )
{
var m = all.FirstOrDefault( x => x.Probe == id );
if ( m is null )
{
Emit( $"[nz-probe] {who} {id,-14} ⛔ ABSENT — it never arrived here" );
continue;
}
var go = m.GameObject;
Emit( $"[nz-probe] {who} {id,-14} proxy={go.Network.IsProxy,-5}"
+ $" owner={Short( go.Network.OwnerId )}"
+ $" enabled={go.Enabled,-5}"
+ $" stamp='{m.Stamp}'"
+ $" z={go.WorldPosition.z:0.0}" );
}
if ( NZGame.IsHost )
foreach ( var (id, question) in Questions )
Log.Info( $"[nz-probe] {id,-14} asks: {question}" );
}
/// <summary>
/// Print locally on the host; SEND to the host from a client.
///
/// ⛔ THIS IS THE POINT OF THE WHOLE FILE. Two machines each writing a correct-looking answer
/// into their own log is what every previous round produced, and comparing them meant a human
/// copying one of them into a chat window. Both answers belong in one console.
/// </summary>
static void Emit( string line )
{
if ( NZGame.IsHost ) Log.Info( line );
else NZNet.ProbeSay( line );
}
/// <summary>`nz_probe_clear` — remove the experiment.</summary>
[ConCmd( "nz_probe_clear" )]
public static void Clear()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var gone = 0;
foreach ( var m in scene.GetAllObjects( false )
.Select( o => o.Components.Get<ProbeMarker>( FindMode.EverythingInSelf ) )
.Where( m => m.IsValid() )
.ToList() )
{
m.GameObject.Destroy();
gone++;
}
if ( gone > 0 ) Log.Info( $"[nz-probe] cleared {gone} probe object(s)" );
}
static string Short( Guid id ) => id == default ? "UNOWNED " : id.ToString()[..8];
}