A diagnostic utility that measures which parts of the lobby UI cost UI time by repeatedly hiding parts of the lobby and recording the engine UI timing averages. It builds a list of steps (hide whole lobby, disable other panels, descend into children) and runs each step after waiting Settle and measuring for Hold, then reports savings and restores all changes.
using Sandbox;
using Sandbox.Diagnostics;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// `nz_lobby_ab`: WHICH PART OF THE LOBBY TAKES THE UI'S TIME, MEASURED BY TURNING EACH PART OFF IN TURN.
/// </summary>
///
/// ⛔ IT EXISTS BECAUSE THE PUBLISHED LOBBY SPENDS ~15 MS A FRAME IN `ui` AND THE EDITOR'S ~1.7, AND READING THE CODE DID NOT SAY
/// WHY (2026-10-05). What was ruled out, from the probe's own lines:
/// - the screen size: a minimised window, 160x28, still spent 14.7;
/// - the lobby redrawing: 0 renders a second;
/// - the s&box menu: its UI counts in `ui` too (`EngineLoop.SimulateUI`), but its scene is suspended while a game runs, and its
/// panels with it;
/// - the HUD: in a round the same published copy spends ~1 ms.
/// What is left is something in the lobby's tree that only the packaged game pays for.
///
/// ⚠️ SO THE GAME MEASURES IT ON ITSELF, AND DRILLS DOWN. Each step holds one change for `Hold` seconds after `Settle`, reads the
/// engine's own averages for exactly those frames, and puts everything back:
/// as is → no preview stage → the whole lobby hidden → every other UI panel off → as is again.
/// A step that saves `Worth` ms or more is split into its parts, each measured the same way straight after it: the lobby into
/// its children, and theirs, down to the one that costs; the other panels into each one alone. One line per step, then the
/// biggest savings, all `[nz-lobby-ab]` in `sbox/logs/sbox.log`.
///
/// ⚠️ NOTHING IS DELETED, AND EVERY CHANGE IS UNDONE. A part is hidden with `display: none`, a HUD's component switched off and
/// on, the stage through `LobbyState.PreviewStage`. Each step undoes itself when it ends, and the one running is undone when
/// the lobby closes, the map changes, something throws, or on `nz_lobby_ab stop`.
///
/// ⚠️ NO STEP OUTLIVES A RUN. The steps hold lambdas, and a lambda stored in a static outlives a hotload badly (INSTRUCTIONS,
/// "A LAMBDA STORED IN A STATIC DELEGATE"), so the list is emptied whenever a run ends.
public static class LobbyAb
{
/// <summary>Seconds after each change before measuring, so the re-layout it causes is not counted.</summary>
public static float Settle { get; set; } = 0.75f;
/// <summary>Seconds measured per step.</summary>
public static float Hold { get; set; } = 2.5f;
/// <summary>Milliseconds of `ui` a step must save before its parts are measured one by one.</summary>
const double Worth = 2.0;
/// <summary>How deep the lobby's tree is followed, and how many steps one run may grow to.</summary>
const int MaxDepth = 8, MaxSteps = 90;
sealed class Step
{
public string Label;
public Action Apply;
public Action Restore;
/// <summary>Runs with this step's saving once measured: how a step adds its own parts after itself.</summary>
public Action<double> Then;
}
static readonly List<Step> _steps = new();
static readonly List<(string Label, double Ui, double Saved)> _results = new();
static int _at = -1;
static bool _applied;
static float _since, _measured;
static int _frames;
static double _baseline;
static string _map;
public static bool Running => _at >= 0;
/// <summary>`nz_lobby_ab` runs it, `nz_lobby_ab stop` ends it early and puts everything back.</summary>
[ConCmd( "nz_lobby_ab" )]
public static void Cmd( string arg = "" )
{
if ( arg.Equals( "stop", StringComparison.OrdinalIgnoreCase ) )
{
if ( Running ) Stop( "stopped by hand" );
else Log.Info( "[nz-lobby-ab] not running" );
return;
}
if ( Running ) { Log.Info( "[nz-lobby-ab] already running · `nz_lobby_ab stop` ends it" ); return; }
if ( LobbyState.IsOpen?.Invoke() != true ) { Log.Warning( "[nz-lobby-ab] open the lobby first: this measures the lobby" ); return; }
var scene = Game.ActiveScene;
var lobby = scene.IsValid()
? scene.GetAllComponents<PanelComponent>().FirstOrDefault( p => p.Panel is not null && p.Panel.HasClass( "lobby" ) )
: null;
if ( lobby is null ) { Log.Warning( "[nz-lobby-ab] no lobby panel in the scene" ); return; }
_steps.Clear();
_results.Clear();
_baseline = 0;
_map = NZMap.Current;
Add( "as is" );
if ( LobbyState.PreviewStage )
_steps.Add( new Step { Label = "no preview stage", Apply = PreviewOff, Restore = PreviewOn } );
_steps.Add( HideStep( lobby.Panel, "the whole lobby", 0 ) );
var others = scene.GetAllComponents<PanelComponent>().Where( p => p != lobby && p.Enabled ).ToList();
if ( others.Count > 0 )
{
_steps.Add( new Step
{
Label = $"every other UI panel off ({others.Count})",
Apply = () => { foreach ( var p in others ) if ( p.IsValid() ) p.Enabled = false; },
Restore = () => { foreach ( var p in others ) if ( p.IsValid() ) p.Enabled = true; },
Then = saved =>
{
if ( saved < Worth ) return;
var at = _at + 1;
foreach ( var p in others )
{
if ( _steps.Count >= MaxSteps ) break;
_steps.Insert( at++, new Step
{
Label = $"{p.GetType().Name} off ('{p.GameObject?.Name}')",
Apply = () => { if ( p.IsValid() ) p.Enabled = false; },
Restore = () => { if ( p.IsValid() ) p.Enabled = true; },
} );
}
},
} );
}
Add( "as is, again" );
Log.Info( $"[nz-lobby-ab] {_steps.Count} steps of {Settle + Hold:0.##}s to start with, more where one matters ·"
+ $" leave the lobby open and the mouse still · `nz_lobby_ab stop` ends it" );
_at = -1;
Next();
}
static void PreviewOff() => LobbyState.PreviewStage = false;
static void PreviewOn() => LobbyState.PreviewStage = true;
static void Add( string label ) => _steps.Add( new Step { Label = label } );
/// <summary>Hide one panel for a step, and when that saves enough, measure each of its shown children after it.</summary>
static Step HideStep( Panel p, string label, int depth )
{
DisplayMode? was = null;
return new Step
{
Label = label ?? Describe( p ),
Apply = () =>
{
if ( !p.IsValid() ) return;
was = p.Style.Display;
p.Style.Display = DisplayMode.None;
},
Restore = () =>
{
if ( p.IsValid() ) p.Style.Display = was;
},
Then = saved =>
{
if ( saved < Worth || depth >= MaxDepth || !p.IsValid() ) return;
// ⚠️ ONLY THE SHOWN ONES: hiding a panel that is already hidden measures nothing
var at = _at + 1;
foreach ( var child in p.Children.Where( c => c.IsValid() && c.IsVisible ).ToList() )
{
if ( _steps.Count >= MaxSteps ) break;
_steps.Insert( at++, HideStep( child, null, depth + 1 ) );
}
},
};
}
static string Describe( Panel p )
{
var classes = (p.Classes ?? "").Trim();
var name = p.ElementName + (classes.Length > 0 ? "." + string.Join( ".", classes.Split( ' ', StringSplitOptions.RemoveEmptyEntries ) ) : "");
return $"hide {name} ({p.Descendants.Count() + 1} panels)";
}
/// <summary>One frame, from `LobbyProbe.Frame`, so it runs wherever the lobby's own update does.</summary>
public static void Frame( bool inLobby )
{
if ( !Running ) return;
try
{
if ( !inLobby ) { Stop( "the lobby closed" ); return; }
if ( NZMap.Current != _map ) { Stop( "the map changed" ); return; }
_since += RealTime.Delta;
if ( _since < Settle ) return;
_frames++;
_measured += RealTime.Delta;
if ( _since < Settle + Hold ) return;
Finish();
}
catch ( Exception e )
{
Stop( $"{e.GetType().Name}: {e.Message}" );
}
}
static void Finish()
{
var step = _steps[_at];
// ⚠️ THE ENGINE'S OWN AVERAGES OVER EXACTLY THE FRAMES MEASURED (it keeps 600), the same numbers `LobbyProbe` prints
var n = Math.Clamp( _frames, 1, 600 );
double ui = PerformanceStats.Timings.Ui.AverageMs( n );
double Scope( string name ) => PerformanceStats.Timings.Get( name ).AverageMs( n );
var tick = Scope( "Tick Panels" );
var layout = Scope( "Pre Layout" ) + Scope( "Layout" ) + Scope( "Post Layout" );
var build = Scope( "Build Command Lists" ) + Scope( "Combine Command Lists" );
var fps = _frames / Math.Max( 0.01f, _measured );
if ( _at == 0 ) _baseline = ui;
var saved = _baseline - ui;
_results.Add( (step.Label, ui, saved) );
Log.Info( $"[nz-lobby-ab] {_at + 1}/{_steps.Count} {step.Label}: ui {ui:0.0} ms (tick {tick:0.0}, layout {layout:0.0},"
+ $" build {build:0.0}) · {fps:0} fps"
+ (_at == 0 ? "" : $" · {(saved >= 0 ? "saves" : "costs")} {Math.Abs( saved ):0.0} ms") );
Undo();
step.Then?.Invoke( saved );
Next();
}
static void Next()
{
_at++;
_since = _measured = 0f;
_frames = 0;
if ( _at >= _steps.Count )
{
Summary();
End();
return;
}
var step = _steps[_at];
if ( step.Apply is null ) return;
step.Apply();
_applied = true;
}
static void Undo()
{
if ( !_applied || _at < 0 || _at >= _steps.Count ) return;
_applied = false;
try { _steps[_at].Restore?.Invoke(); }
catch ( Exception e ) { Log.Warning( $"[nz-lobby-ab] could not undo '{_steps[_at].Label}': {e.Message}" ); }
}
static void Stop( string why )
{
Undo();
Log.Warning( $"[nz-lobby-ab] ended early ({why}) after {_results.Count} of {_steps.Count} steps · everything it changed is back" );
if ( _results.Count > 1 ) Summary();
End();
}
static void End()
{
_at = -1;
_applied = false;
_steps.Clear();
}
static void Summary()
{
var top = _results.Skip( 1 ).Where( r => !r.Label.StartsWith( "as is" ) ).OrderByDescending( r => r.Saved ).Take( 6 ).ToList();
var again = _results.LastOrDefault( r => r.Label == "as is, again" );
Log.Info( $"[nz-lobby-ab] done · as is {_baseline:0.0} ms of ui"
+ (again.Label is null ? "" : $", {again.Ui:0.0} again at the end")
+ " · biggest savings: "
+ (top.Count == 0 ? "none" : string.Join( ", ", top.Select( r => $"'{r.Label}' {r.Saved:0.0}" ) )) );
}
}