Diagnostics/NetProbe.cs

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.

Networking
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&amp;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];
}