Diagnostics/LightBake.cs

Utility class exposing console commands to create, inspect, bake and apply IndirectLightVolume probe-based indirect lighting for scenes. It reports light counts and probe textures, creates/removes volumes, runs async bake with safety checks, applies shipped textures, and exposes bounds/density/inside-geometry controls.

File Access
using Sandbox;
using System;
using System.Linq;
using System.Threading;

namespace NZombies;

/// <summary>
/// BAKED INDIRECT LIGHTING — the closest thing s&box has to a lightmap.
///
/// ⛔ THERE IS NO SHADOW BAKING IN THIS ENGINE. `Light` exposes exactly four properties —
/// Contribution, LegacyData, LightColor, Shadows — with no static or baked mode, and nothing named
/// "lightmap" exists in the API at all. Shadows are dynamic every frame or they do not exist.
/// `Light.LegacyData` is documented as carrying a baking flag "without a public property yet", which
/// makes it an opaque blob rather than a lever.
///
/// ⛔ SO THIS IS THE TRADE, AND IT IS WHY THE SHADOWS COULD BE TURNED OFF AT ALL. In Source 1 the
/// canyon map's lighting was BAKED — vrad wrote lightmaps and its 71 light entities were static data
/// costing nothing. Porting the entities into Source 2 turned all 71 into dynamic shadow-casters:
/// GPU-bound on every frame, with a 3.5x fps swing by view direction. Killing the shadows recovers
/// that and flattens the look; an IndirectLightVolume bakes the bounce back in, once, into volume
/// textures.
///
/// ⚠️ DDGI IS NOT A LIGHTMAP AND WILL NOT LOOK LIKE ONE. It is a 3D probe grid storing irradiance —
/// soft, low-frequency, and it cannot give a hard shadow edge. What it does give is rooms that are
/// still lit and surfaces that still have direction to their light, which is what shadowless local
/// lights lose.
/// </summary>
public static class LightBake
{
	/// <summary>
	/// Probes per 1024 world units.
	///
	/// ⚠️ A GUESS, AND MEANT TO BE TUNED. Probe count scales with the CUBE of this over the volume,
	/// so doubling it is 8x the bake and 8x the volume texture. Start low and raise it only if the
	/// lighting reads as blotchy.
	/// </summary>
	public static int Density { get; set; } = 4;

	/// <summary>
	/// True while a bake is in flight.
	///
	/// ⛔ OVERLAPPING BAKES BECAME DANGEROUS THE MOMENT BAKING STARTED BY DESTROYING THE OLD
	/// VOLUME. A second `nz_light_bake` while the first is still running destroys the object the
	/// first one is about to report on, and the first's continuation then dereferences a dead
	/// component. That surfaced as "BAKE FAILED: NullReferenceException" pointing at this file — a
	/// fault in the diagnostic, wearing the costume of the engine bug it was written to catch.
	/// </summary>
	static bool _baking;

	/// <summary>The volume in the scene, or null.</summary>
	public static IndirectLightVolume Find()
		=> Game.ActiveScene?.GetAllComponents<IndirectLightVolume>().FirstOrDefault( v => v.IsValid() );

	/// <summary>
	/// `nz_light_report` — what the scene's lighting actually costs.
	/// </summary>
	[ConCmd( "nz_light_report" )]
	public static void Report()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Info( "[nz-light] no scene" ); return; }

		var lights = scene.GetAllComponents<Light>().Where( l => l.IsValid() ).ToList();
		var shad = lights.Count( l => l.Shadows );
		var dir = lights.Count( l => l is DirectionalLight );

		Log.Info( $"[nz-light] {lights.Count} light(s)   {shad} casting shadows"
			+ $"   {dir} directional" );

		// ⚠️ THE PER-TYPE SPLIT IS THE ACTIONABLE PART. One shadowing directional is the sun and is
		// fine; sixty shadowing spots is the bill.
		foreach ( var g in lights.GroupBy( l => l.GetType().Name ) )
			Log.Info( $"[nz-light]   {g.Key,-18} {g.Count(),4}"
				+ $"   {g.Count( l => l.Shadows ),4} shadowing" );

		var vol = Find();
		if ( !vol.IsValid() )
		{
			Log.Info( "[nz-light] no IndirectLightVolume — nz_light_bake creates and bakes one" );
			return;
		}

		Log.Info( $"[nz-light] volume: bounds {vol.Bounds.Size}   density {vol.ProbeDensity}"
			+ $"   probes {vol.ProbeCounts}" );

		Spacing( vol );
		Textures( vol );

		// ⛔ THE KNOBS THAT DECIDE WHETHER PROBES CONTRIBUTE AT ALL. A volume can hold perfectly
		// good textures and still light nothing if its probes were deactivated for sitting inside
		// geometry — and Basalt is dense concrete slabs, so a probe grid at 179-unit spacing puts a
		// lot of them inside a wall. None of this is visible from bounds, density or probe counts,
		// which is why a dead volume passed every earlier readout.
		Log.Info( $"[nz-light]   insideGeometry {vol.InsideGeometry}"
			+ $"   normalBias {vol.NormalBias:0.###}   contrast {vol.Contrast:0.###}" );

		Log.Info( "[nz-light]   insideGeometry options: "
			+ string.Join( ", ", Enum.GetNames( vol.InsideGeometry.GetType() ) ) );
	}

	/// <summary>
	/// `nz_light_bake [density]` — create the volume if needed, size it to the map, and bake.
	///
	/// ⚠️ DENSITY IS AN INT — probes per 1024 units, the engine's own unit. It is not a scale factor,
	/// so 4 and 5 are a real step apart, not a nudge.
	///
	/// ⛔ IT NO LONGER SIZES ITSELF FROM THE SCENE BY DEFAULT, AND THAT CHANGE CAME FROM A MAP THAT
	/// WENT BLACK. `ExtendToSceneBounds` covers EVERYTHING in the scene — including the 3D skybox,
	/// which on Basalt is a forest sitting thousands of units above the map. Measured: the volume
	/// went from 9320x5314x**3106** to 9320x5314x**20927**, so the same probe budget was stretched
	/// over seven times the vertical range and the playable rooms got one probe per ~520 units. The
	/// map rendered black, exactly as it did at density 2.
	///
	/// ⚠️ SO `LightingSettings.BakeSize` WINS WHEN SET. A hand-entered bounds is wrong the first
	/// time a different map loads — which is why it lives in the MAP'S CONFIG rather than here.
	///
	/// ⚠️ THE BAKE IS NOT INSTANT AND NOT SILENT. Probe counts scale cubically with density, so this
	/// says what it is about to do before doing it — a command that appears to hang is worse than
	/// one that warns.
	/// </summary>
	[ConCmd( "nz_light_bake" )]
	public static void Bake( int density = -1 )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Info( "[nz-light] no scene" ); return; }

		// ⛔ TWO DENSITIES EXISTED AND THEY DISAGREED SILENTLY. `nz_atmos_set density 6` writes the
		// MAP'S CONFIG; this command used its own static and baked at 4 anyway, reporting "density 4"
		// one line after the config said 6. The config wins when nothing is passed explicitly, so
		// there is one number and the readout cannot contradict the thing that saves.
		var cfg = ActiveConfig.Current?.Lighting;

		if ( _baking )
		{
			Log.Warning( "[nz-light] a bake is already running — ignoring. Wait for it to report." );
			return;
		}

		if ( density > 0 ) Density = density;
		else if ( cfg is not null && cfg.BakeDensity > 0 ) Density = cfg.BakeDensity;

		// ⛔ ALWAYS A FRESH VOLUME. BAKING OVER AN EXISTING ONE DESTROYS ITS TEXTURES AND PRODUCES
		// NOTHING — reproduced deterministically on Basalt, one re-bake apart:
		//
		//     20:18:35  irradiance 320x208   distance 640x416    (before)
		//     20:18:37  irradiance NULL      distance NULL       (after)
		//
		// Every good bake in this project came from a volume created moments earlier; every black
		// map came from a second bake onto a volume that already had data. `nz_atmos_rebake` was a
		// reliable way to destroy a working one.
		//
		// ⚠️ AND THE WRECKAGE IS WORSE THAN NO BAKE, which is why this is not left to the caller.
		// An IndirectLightVolume REPLACES the ambient whether or not its probes hold anything, so a
		// failed re-bake does not leave the map as it was — it turns it black. Destroying first
		// costs one object and removes the entire failure mode.
		var old = Find();

		if ( old.IsValid() )
		{
			old.GameObject?.Destroy();
			Log.Info( "[nz-light] dropped the previous volume (baking in place yields NULL textures)" );
		}

		IndirectLightVolume vol;

		{
			// ⚠️ NotSaved. The volume is rebuilt and re-baked on demand rather than written into the
			// map scene, because the bake's own textures are runtime state — and a half-baked volume
			// serialised into a shipped map is worse than none.
			var go = scene.CreateObject();
			go.Name = "Indirect Light Volume";
			go.Flags |= GameObjectFlags.NotSaved;

			vol = go.Components.Create<IndirectLightVolume>();
			Log.Info( "[nz-light] created an IndirectLightVolume" );
		}

		vol.ProbeDensity = Density;

		if ( cfg is not null && cfg.BakeSize.Length > 1f )
		{
			vol.Bounds = new BBox( cfg.BakeCenter - cfg.BakeSize * 0.5f,
				cfg.BakeCenter + cfg.BakeSize * 0.5f );
		}
		else
		{
			vol.ExtendToSceneBounds();
		}

		Log.Info( $"[nz-light] baking: bounds {vol.Bounds.Size}   density {Density}"
			+ $"   probes {vol.ProbeCounts}"
			+ $"   {( cfg is not null && cfg.BakeSize.Length > 1f ? "(config bounds)" : "(scene bounds)" )}"
			+ "  — this takes a moment" );

		Spacing( vol );

		// ⛔ THE RESULT IS OBSERVED NOW, AND IT WAS NOT BEFORE. `BakeProbes` is async, and this
		// called it fire-and-forget — so when it threw, the exception surfaced on the FINALIZER
		// thread as "A Task's exception(s) were not observed", minutes later, detached from the
		// command that caused it. Nothing ever said the bake had failed, so an empty volume read as
		// a slow one and then as a black map. Awaiting it is the difference between a bug you can
		// see and one you infer three screenshots later.
		// ⛔ RAISE THE AMBIENT FOR THE PROBES, THEN PUT IT BACK. `BakeAmbient` exists because the
		// bake needs far more ambient than the map should be lit with — and that same value is the
		// fallback for everything OUTSIDE the volume, so leaving it raised lights the whole map
		// below the probe band at twice its original brightness.
		if ( cfg is not null && cfg.BakeAmbient > 0f )
		{
			NZAtmosphere.BakeOverride = cfg.BakeAmbient;
			NZAtmosphere.Apply( scene );

			Log.Info( $"[nz-light] ambient raised to x{cfg.BakeAmbient:0.##} for the bake"
				+ $" (back to x{cfg.Ambient:0.##} after)" );
		}

		_baking = true;
		_ = Watch( vol, vol.BakeProbes( CancellationToken.None ) );

		Log.Info( "[nz-light] bake started. nz_light_report shows the volume;"
			+ " look at the map to judge it." );

		// ⛔ SAID AT THE START, NOT DISCOVERED AT THE END. A bake in play cannot save, and the failed save turns its distance
		// texture into the engine's error texture (CHANGELOG 2026-09-16 22:02) — the map goes dark once the bake is done.
		// Reproduced 2026-09-27: 17 minutes of baking, then "distance 32x32", and *"it stays dark, we had this problem before
		// but im unsure how we fixed it"*. The fix then and now is the editor's bake.
		Log.Warning( "[nz-light] ⚠️ a bake in PLAY does not keep: it cannot be saved, and its distance texture turns into the"
			+ " error texture when it finishes — the rooms go dark. To keep one, bake in the editor, not playing: on basalt,"
			+ " Basalt.scene open, nZombies ▸ Basalt light bake ▸ Bake now; elsewhere, Scene ▸ Bake Indirect Light Volumes." );
	}

	/// <summary>
	/// Await the bake and say what came of it.
	///
	/// ⚠️ A VOLUME THAT BAKED NOTHING IS WORSE THAN NO VOLUME, which is why this reports rather
	/// than shrugging: an IndirectLightVolume REPLACES the ambient term whether or not its probes
	/// hold anything, so a failed bake does not leave the map as it was — it turns it black.
	/// </summary>
	static async System.Threading.Tasks.Task Watch( IndirectLightVolume vol,
		System.Threading.Tasks.Task bake )
	{
		System.Exception failure = null;

		try { await bake; }
		catch ( Exception e ) { failure = e; }

		// ⛔ THE THROW ARRIVES BEFORE THE TEXTURES DO, AND CLASSIFYING IN THE CATCH GOT IT WRONG
		// TWICE. Checked the instant the exception surfaced, `IrradianceTexture` is still null and
		// the bake looks like a total failure; a second later the same volume reports
		// `irradiance 320x208 / distance 640x416` and lights the map correctly. So the verdict waits
		// for the data rather than racing it.
		try { await System.Threading.Tasks.Task.Delay( 750 ); } catch { }

		// ⛔ RE-FOUND, NOT THE REFERENCE WE STARTED WITH. Holding a component across an await is
		// unsafe in this editor: a hotload swaps instances, so the captured `vol` goes invalid while
		// the volume itself is alive and healthy. That produced "volume was replaced while baking"
		// on a bake that had in fact just succeeded — a diagnostic lying about a working system,
		// which is the most expensive kind. `Find()` asks the scene what is there now.
		vol = Find();

		if ( !vol.IsValid() )
		{
			Log.Info( "[nz-light] no volume after the bake — nothing to report" );
			_baking = false;
			return;
		}

		var ok = vol.IrradianceTexture is not null;

		if ( ok && failure is not null )
		{
			// ⚠️ THIS IS THE NORMAL OUTCOME IN PLAY MODE, NOT AN ERROR. The engine's
			// `IndirectLightVolume.SaveTexture` raises a NullReferenceException on a runtime scene
			// because there is no scene folder to write baked data into — the engine's own docs
			// describe that folder as where "envmap textures, lightmaps, baked data" go. The probes
			// themselves are computed and bound, so the map lights correctly; the bake just does not
			// survive the session.
			Log.Info( $"[nz-light] bake OK — probe data present. The engine could not SAVE it "
				+ $"({failure.GetType().Name}), so it will not persist past this session." );
			Textures( vol );
		}
		else if ( ok )
		{
			Log.Info( "[nz-light] bake OK" );
			Textures( vol );
		}
		else
		{
			Log.Error( "[nz-light] BAKE PRODUCED NOTHING"
				+ ( failure is null ? "" : $" ({failure.GetType().Name}: {failure.Message})" ) );
			Log.Warning( "[nz-light] the volume is OVERRIDING the ambient with nothing — the map "
				+ "will be black. `nz_light_volume 0` to drop it." );
		}

		// ⚠️ RESTORED HERE, NOT AFTER THE BakeProbes CALL. The bake is async — putting the ambient
		// back on the next line would restore it before a single probe had rendered, which is the
		// same "looks like it did nothing" failure as issuing `nz_dark` before the map has loaded.
		if ( NZAtmosphere.BakeOverride is not null )
		{
			NZAtmosphere.BakeOverride = null;
			NZAtmosphere.Apply( Game.ActiveScene );
			Log.Info( "[nz-light] ambient restored" );
		}

		_baking = false;
	}

	/// <summary>
	/// Did the bake actually produce probe data?
	///
	/// ⛔ THE ONLY DIRECT ANSWER TO "IS THIS VOLUME EMPTY". Bounds, density and probe counts are
	/// all set the moment the volume is configured, so they read exactly the same for a good bake
	/// and a failed one — which is precisely how a dead volume passed three separate readouts.
	/// </summary>
	static void Textures( IndirectLightVolume vol )
	{
		if ( !vol.IsValid() ) return;

		var irr = vol.IrradianceTexture;
		var dist = vol.DistanceTexture;

		Log.Info( $"[nz-light]   irradiance {( irr is null ? "NULL" : $"{irr.Width}x{irr.Height}" )}"
			+ $"   distance {( dist is null ? "NULL" : $"{dist.Width}x{dist.Height}" )}" );

		if ( irr is null )
			Log.Warning( "[nz-light]   no irradiance texture — this volume holds NO light and is "
				+ "blacking the map out. `nz_light_volume 0`." );

		// ⛔ THE COLLAPSE, NAMED. The error texture is 32x32; a real distance texture is the irradiance's size or bigger (640x416
		// against 320x208, 2026-09-16). Printed as a size alone it read as a result, not a failure.
		else if ( dist is not null && dist.Width <= 32 && dist.Height <= 32 && irr.Width > 32 )
			Log.Error( "[nz-light]   the DISTANCE texture is the engine's 32x32 ERROR texture — the failed save of a bake in"
				+ " play. Light cannot spread between the probes: the map will be dark. Bake in the editor instead"
				+ " (on basalt: nZombies ▸ Basalt light bake ▸ Bake now)." );
	}

	/// <summary>
	/// How far apart the probes actually are, in world units.
	///
	/// ⛔ THE NUMBER THAT MAKES A BAD BAKE VISIBLE. Probe COUNT tells you nothing on its own —
	/// 38x22x40 sounds generous and was one probe per 520 units of height, which is useless for a
	/// room 300 units tall. Both times this map went black, the spacing was the tell and nothing
	/// else in the readout was.
	///
	/// ⚠️ A PROBE SHOULD SIT WELL INSIDE A ROOM. Once spacing exceeds room height the probes
	/// straddle floors and ceilings, relocation deactivates the ones stuck in geometry, and what is
	/// left describes the building rather than the room you are standing in.
	/// </summary>
	static void Spacing( IndirectLightVolume vol )
	{
		if ( !vol.IsValid() ) return;

		var size = vol.Bounds.Size;
		var n = vol.ProbeCounts;

		var sx = n.x > 1 ? size.x / ( n.x - 1 ) : size.x;
		var sy = n.y > 1 ? size.y / ( n.y - 1 ) : size.y;
		var sz = n.z > 1 ? size.z / ( n.z - 1 ) : size.z;

		// ⚠️ NO TEXTURE CHECK HERE. This runs BEFORE the bake as well as after, and a volume
		// created one line earlier is empty BY DESIGN — warning about it there cried wolf on every
		// single bake, which is how a real "holds no light" warning would have been missed.
		Log.Info( $"[nz-light]   probe spacing {sx:0}x{sy:0}x{sz:0} units" );

		// ⚠️ 400 IS NOT A TUNED THRESHOLD, IT IS "TALLER THAN ANY ROOM IN THESE MAPS". Basalt's
		// playable band is 1185..1696 — about 510 units for the WHOLE map vertically.
		if ( MathF.Max( sx, MathF.Max( sy, sz ) ) > 400f )
			Log.Warning( "[nz-light]   probes are further apart than a room is tall — interiors will "
				+ "come out BLACK. Shrink the volume (nz_light_bounds) or raise the density." );
	}

	/// <summary>
	/// `nz_light_bounds &lt;cx&gt; &lt;cy&gt; &lt;cz&gt; &lt;sx&gt; &lt;sy&gt; &lt;sz&gt;` — confine the bake to the playable
	/// area. No arguments reports; `nz_light_bounds auto` goes back to whole-scene sizing.
	///
	/// ⚠️ THE POINT IS TO STOP SPENDING PROBES ON SKY. A 3D skybox is scene geometry like any
	/// other, so the automatic sizing swallows it and the rooms starve.
	/// </summary>
	[ConCmd( "nz_light_bounds" )]
	public static void SetBounds( string cx = "", float cy = 0f, float cz = 0f,
		float sx = 0f, float sy = 0f, float sz = 0f )
	{
		var cfg = ActiveConfig.Current?.Lighting;
		if ( cfg is null ) { Log.Warning( "[nz-light] no config" ); return; }

		if ( string.Equals( cx, "auto", System.StringComparison.OrdinalIgnoreCase ) )
		{
			cfg.BakeSize = Vector3.Zero;
			Log.Info( "[nz-light] bake bounds: AUTO (whole scene — includes the 3D skybox)" );
			return;
		}

		if ( string.IsNullOrWhiteSpace( cx ) || sx <= 0f )
		{
			Log.Info( cfg.BakeSize.Length > 1f
				? $"[nz-light] bake bounds: centre {cfg.BakeCenter} size {cfg.BakeSize}"
				: "[nz-light] bake bounds: AUTO (whole scene)" );
			Log.Info( "[nz-light] nz_light_bounds <cx> <cy> <cz> <sx> <sy> <sz>   |   nz_light_bounds auto" );
			return;
		}

		if ( !float.TryParse( cx, out var x ) )
		{
			Log.Warning( $"[nz-light] '{cx}' is not a number" );
			return;
		}

		cfg.BakeCenter = new Vector3( x, cy, cz );
		cfg.BakeSize = new Vector3( sx, sy, sz );

		Log.Info( $"[nz-light] bake bounds: centre {cfg.BakeCenter} size {cfg.BakeSize}"
			+ " — nz_light_bake to apply, nz_save to keep" );
	}

	/// <summary>
	/// Bake, but only if this map's config asked for one. Called at the END of `NZGame.ShowConfig`.
	///
	/// ⚠️ A SEPARATE ENTRY POINT SO THE ORDERING IS THE CALLER'S, NOT A TIMER'S. See the note at
	/// the call site: what the probes capture is whatever exists at the moment they render, so the
	/// bake has to be the last thing that happens after the world is built.
	/// </summary>
	public static void BakeIfConfigured()
	{
		var cfg = ActiveConfig.Current?.Lighting;
		if ( cfg is null || !cfg.Bake ) return;

		// ⛔ A SHIPPED BAKE ALWAYS WINS OVER BAKING AGAIN. The runtime bake is not merely slower,
		// it is self-destroying: `SaveTexture` throws on a play-mode scene and the engine then swaps
		// the distance texture for an error texture, so the map lights correctly for about a minute
		// and then goes black. If the map ships textures, load them and never bake.
		if ( cfg.HasBakedTextures && ApplyBaked( Game.ActiveScene, cfg ) ) return;

		Bake();
	}

	/// <summary>
	/// Build the volume from the map's shipped bake. True when it worked.
	///
	/// ⚠️ BOUNDS AND DENSITY STILL COME FROM THE CONFIG, because the textures encode a probe GRID
	/// and nothing else — point them at a differently sized volume and the irradiance is sampled at
	/// the wrong places, which looks like a subtly wrong bake rather than a mismatch.
	/// </summary>
	public static bool ApplyBaked( Scene scene, LightingSettings cfg )
	{
		if ( !scene.IsValid() || cfg is null ) return false;

		// ⚠️ `LoadFromFileSystem`, WITH THE ARGUMENTS THE OTHER WAY ROUND. The old
		// `Texture.Load( fileSystem, path )` is obsolete and its replacement takes the PATH first —
		// so a mechanical swap of the method name alone compiles and looks up a texture named after
		// the file system. Both textures below are checked for null, which is the only reason that
		// mistake would have been caught here rather than shipping as "the bake is missing".
		var irr = Texture.LoadFromFileSystem( cfg.BakedIrradiance, FileSystem.Mounted );
		var dist = Texture.LoadFromFileSystem( cfg.BakedDistance, FileSystem.Mounted );

		if ( irr is null || dist is null )
		{
			// ⚠️ SAYS WHICH ONE, because "the lighting is wrong" with a silent missing texture is
			// indistinguishable from a bad bake — and this project has now chased that twice.
			Log.Warning( $"[nz-light] shipped bake missing:"
				+ $"{( irr is null ? " irradiance" : "" )}{( dist is null ? " distance" : "" )}"
				+ " — falling back to a runtime bake, which will not persist" );
			return false;
		}

		var old = Find();
		if ( old.IsValid() ) old.GameObject?.Destroy();

		var go = scene.CreateObject();
		go.Name = "Indirect Light Volume (baked)";
		go.Flags |= GameObjectFlags.NotSaved;

		var vol = go.Components.Create<IndirectLightVolume>();

		vol.ProbeDensity = cfg.BakeDensity;

		if ( cfg.BakeSize.Length > 1f )
			vol.Bounds = new BBox( cfg.BakeCenter - cfg.BakeSize * 0.5f,
				cfg.BakeCenter + cfg.BakeSize * 0.5f );
		else
			vol.ExtendToSceneBounds();

		vol.IrradianceTexture = irr;
		vol.DistanceTexture = dist;

		var reloc = Texture.LoadFromFileSystem( cfg.BakedRelocation, FileSystem.Mounted );
		if ( reloc is not null ) vol.RelocationTexture = reloc;

		Log.Info( $"[nz-light] loaded a SHIPPED bake — irradiance {irr.Width}x{irr.Height}"
			+ $"   distance {dist.Width}x{dist.Height}"
			+ $"   relocation {( reloc is null ? "absent" : "ok" )}   (no runtime bake)" );

		Spacing( vol );
		return true;
	}

	/// <summary>
	/// `nz_light_inside &lt;name&gt;` — what the bake does with probes that land inside geometry.
	///
	/// ⚠️ PARSED BY NAME AT RUNTIME rather than hard-coded, because the enum lives in the engine
	/// and guessing its members is how you write a setter that compiles and sets nothing.
	/// `nz_light_report` prints the available options.
	/// </summary>
	[ConCmd( "nz_light_inside" )]
	public static void Inside( string name = "" )
	{
		var vol = Find();
		if ( !vol.IsValid() ) { Log.Warning( "[nz-light] no volume — nz_light_bake first" ); return; }

		var type = vol.InsideGeometry.GetType();

		if ( string.IsNullOrWhiteSpace( name ) )
		{
			Log.Info( $"[nz-light] insideGeometry is {vol.InsideGeometry}"
				+ $"   options: {string.Join( ", ", Enum.GetNames( type ) )}" );
			return;
		}

		try
		{
			vol.InsideGeometry = (IndirectLightVolume.InsideGeometryBehavior)Enum.Parse( type, name, true );
			Log.Info( $"[nz-light] insideGeometry = {vol.InsideGeometry}"
				+ " — nz_light_bake to apply it" );
		}
		catch
		{
			Log.Warning( $"[nz-light] '{name}' is not one of: {string.Join( ", ", Enum.GetNames( type ) )}" );
		}
	}

	/// <summary>
	/// `nz_light_volume 0` — remove the volume, to A/B the bake against no indirect at all.
	/// </summary>
	[ConCmd( "nz_light_volume" )]
	public static void Volume( int on = -1 )
	{
		var vol = Find();

		if ( on == 0 )
		{
			if ( !vol.IsValid() ) { Log.Info( "[nz-light] no volume to remove" ); return; }
			vol.GameObject?.Destroy();
			Log.Info( "[nz-light] volume removed — the map now has no baked indirect" );
			return;
		}

		if ( on == 1 && !vol.IsValid() ) { Bake(); return; }

		Log.Info( vol.IsValid()
			? $"[nz-light] volume present, density {vol.ProbeDensity}, probes {vol.ProbeCounts}"
			: "[nz-light] no volume — nz_light_bake makes one" );
	}
}