UI/ToolSettings.cs

UI helper for the in-game tool/settings panel. Defines a Row record and builders for different control types, holds transient tool state (wallbuy choice, pack/category/weapon lists), and builds lists of editable rows for each tool or settings group by reading and writing MapConfig/MapEditor and related managers; it notifies ActiveConfig on changes.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The rows shown in the panel beside the Q menu — the settings for whatever
/// you are holding, or for the settings group you opened.
///
/// ⚠️ THE DATA LIVES WHERE IT ALREADY LIVED. Nothing is duplicated here: a row
/// is a label plus a getter and a nudge that read and write MapConfig or
/// MapEditor directly. A settings panel that keeps its own copy is a settings
/// panel that disagrees with the game the moment a console command changes
/// something.
/// </summary>
public static class ToolSettings
{
	// ── weapon buy ───────────────────────────────────────────────────────────
	//
	// ⚠️ Held HERE, not on the tool, because a placeable's settings are chosen
	// BEFORE it exists — the panel has nothing to edit until the first one is
	// down. Debris solves this the same way.

	/// <summary>Weapon the next wallbuy will sell.</summary>
	public static string WallBuyWeapon { get; set; } = "prefabs/weapons/nz_m1911.prefab";

	/// <summary>Price of the next wallbuy. Ammo is half, floored to 10.</summary>
	public static int WallBuyPrice { get; set; } = 500;

	/// <summary>
	/// Rarity the next wallbuy sells at, 0-4. Common by default.
	///
	/// ⚠️ IT IS THE CHALK COLOUR AS WELL AS THE TIER. The mapper picks one thing and the player
	/// reads it off the wall from across the room, before they can afford it.
	/// </summary>
	public static int WallBuyRarity { get; set; }

	/// <summary>
	/// The five tier names, in order, so index == tier.
	///
	/// ⚠️ BUILT FROM Rarity.NameFor RATHER THAN TYPED OUT. A hardcoded list here would be a
	/// second place for "Epic" to be renamed, and the picker maps the chosen string back to a tier
	/// by its INDEX — so a list that drifted out of order would assign the wrong tier silently.
	///
	/// ⛔ TO LEGENDARY, NOT GODLY: Godly is basalt's Easter egg's, and a wall selling it would hand out what only the egg gives.
	/// </summary>
	static string[] RarityNames => Enumerable.Range( 0, Rarity.LegendaryTier + 1 )
		.Select( Rarity.NameFor ).ToArray();

	/// <summary>Pack currently being browsed.</summary>
	public static string WallBuyPack { get; set; } = "";

	/// <summary>Category within the pack. "All" shows every weapon in it.</summary>
	public static string WallBuyCategory { get; set; } = "All";

	/// <summary>
	/// The three picker rows, drilling down.
	///
	/// ⚠️ PACK → CATEGORY → WEAPON, not one flat list: the pack has 135 weapons
	/// and a single dropdown of that length cannot be used. This mirrors the
	/// original tool's own grouping, and for the same reason.
	///
	/// ⚠️ Selecting a pack or category RESETS what follows it. Leaving a stale
	/// weapon selected from a different pack is how you place an MPL from the
	/// "Handguns" list and cannot work out why.
	/// </summary>
	static string[] Packs => WeaponLibrary.Packs;

	static string[] Categories
	{
		get
		{
			var cats = WeaponLibrary.CategoriesIn( CurrentPack ).ToList();
			cats.Insert( 0, "All" );
			return cats.ToArray();
		}
	}

	static string CurrentPack =>
		string.IsNullOrEmpty( WallBuyPack ) ? (Packs.FirstOrDefault() ?? "Other") : WallBuyPack;

	static string[] WeaponNames =>
		WeaponLibrary.In( CurrentPack, WallBuyCategory ).Select( e => e.Name ).ToArray();

	static string CurrentWeaponName =>
		WeaponLibrary.Find( WallBuyWeapon )?.Name ?? "";

	/// <summary>
	/// One editable line.
	///
	/// <paramref name="Nudge"/> takes a direction (-1 / +1) rather than a value,
	/// because every type steps differently — ints by 1 or 50, floats by 0.05,
	/// bools by flipping — and the panel should not have to know which is which.
	/// </summary>
	/// <param name="Text">Set from a typed string. Null = not typeable.</param>
	/// <param name="Kind">
	/// What control to draw. ⚠️ STATED, NOT INFERRED. The panel used to guess
	/// from the label, which meant a new free-text setting silently got − / +
	/// buttons and could not be edited at all.
	/// </param>
	/// <param name="Options">
	/// The choices, for <see cref="RowKind.Options"/>. ⚠️ A FUNC, not an array —
	/// the weapon list depends on the pack and category chosen in the rows above
	/// it, so a snapshot taken when the panel was built would show the previous
	/// pack's weapons until the panel was reopened.
	/// </param>
	public record Row( string Label, Func<string> Value, Action<int> Nudge, string Hint = "",
		Action<string> Text = null, RowKind Kind = RowKind.Number,
		Func<string[]> Options = null );

	public enum RowKind
	{
		/// <summary>Typeable, and steppable for quick tweaks.</summary>
		Number,

		/// <summary>Typeable only — a name, with no "next" value to step to.</summary>
		Text,

		/// <summary>On/off, toggled by clicking it.</summary>
		Toggle,

		/// <summary>Read-only. For tools that genuinely have no settings.</summary>
		Info,

		/// <summary>
		/// Pick one of a known set by clicking it.
		///
		/// ⚠️ NOT a text box with − / +. Stepping through 135 weapons one at a
		/// time to reach the one you want is not selection, it is scrolling — and
		/// typing a name means knowing it exactly before you can choose it.
		/// </summary>
		Options,

		/// <summary>
		/// A row of swatches, clicked to pick.
		///
		/// ⛔ SWATCHES, NOT A HEX BOX. Typing #ffeeb8 is not choosing a colour, it is
		/// knowing one — the same objection `Options` records about stepping through 135
		/// weapons. The value is still a hex string underneath, so a colour the palette does
		/// not carry can be typed over the console.
		///
		/// ⚠️ It reuses `Options` for the value list; the only difference is that the panel
		/// draws each entry AS its colour rather than as its name.
		/// </summary>
		Colour,
	}

	/// <summary>Groups that are not tools — opened from the Settings tab.</summary>
	public const string Players = "settings_players";
	public const string Zombies = "settings_zombies";
	public const string Gameplay = "settings_gameplay";

	/// <summary>Special rounds — when, how many, how hard. See SpecialSettings.</summary>
	public const string Specials = "settings_specials";

	/// <summary>Boss round schedule. See BossSettings.</summary>
	public const string Bosses = "settings_bosses";

	/// <summary>Pack-a-Punch tiers, prices and damage. See PapSettings.</summary>
	public const string Pap = "settings_pap";

	/// <summary>Ammo box prices and its per-round escalation. See AmmoBoxSettings.</summary>
	public const string Ammo = "settings_ammo";

	/// <summary>The rooms' names, one per flag — what the HUD's top left says on walking through. See RoomNames.</summary>
	public const string Rooms = "settings_rooms";

	/// <summary>
	/// Stats of the weapon currently in hand.
	///
	/// ⛔ NOT A NEW PANEL. This one already says "shows the settings for whatever you
	/// are holding" at the top of ToolPanel, and it already solved typeable values,
	/// steppers, hints and scrolling. A second editor panel would be a second set of
	/// all of those to keep in sync.
	/// </summary>
	public const string WeaponStats = "settings_weapon";

	static MapConfig Cfg => ActiveConfig.Current;

	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	/// <summary>
	/// Push an in-place edit to whatever has already read the old value.
	///
	/// ⚠️ WITHOUT THIS THE PANEL LOOKS WIRED AND IS NOT. ZombieStats caches
	/// health per round, and PlayerController copies walk/sprint/jump once at
	/// spawn — so both keep serving the previous numbers while the panel happily
	/// shows the new ones. Only ActiveConfig.Set invalidated them, and Set only
	/// runs on a whole-config swap, never on editing a field.
	/// </summary>
	static void Changed() => ActiveConfig.NotifyChanged();

	/// <summary>A free-text row — for values that are not on a numeric axis.</summary>
	static Row Str( string label, Func<string> get, Action<string> set, string hint = "" )
		=> new( label, get, _ => { }, hint, v => { set( DoorLinks.Clean( v ) ); Changed(); },
			RowKind.Text );

	// ── row builders ─────────────────────────────────────────────────────────

	// Every setter goes through Changed() — a row that edits the config without
	// announcing it is a row that does nothing.

	static Row Int( string label, Func<int> get, Action<int> set, int step = 1,
		int min = 0, int max = int.MaxValue, string hint = "" )
		=> new( label, () => get().ToString(),
			d => { set( Math.Clamp( get() + d * step, min, max ) ); Changed(); }, hint,
			// Typed too — stepping to 60000 in 1000s is not a thing anyone should
			// have to do.
			v => { if ( int.TryParse( v, out var n ) ) { set( Math.Clamp( n, min, max ) ); Changed(); } } );

	static Row Num( string label, Func<float> get, Action<float> set, float step = 1f,
		float min = 0f, float max = float.MaxValue, string hint = "" )
		=> new( label, () => get().ToString( "0.##" ),
			d => { set( Math.Clamp( get() + d * step, min, max ) ); Changed(); }, hint,
			v => { if ( float.TryParse( v, out var n ) ) { set( Math.Clamp( n, min, max ) ); Changed(); } } );

	static Row Bool( string label, Func<bool> get, Action<bool> set, string hint = "" )
		=> new( label, () => get() ? "on" : "off", _ => { set( !get() ); Changed(); }, hint,
			null, RowKind.Toggle );

	/// <summary>Cycle through a fixed list — for things with no numeric axis.</summary>
	static Row Pick( string label, string[] options, Func<string> get, Action<string> set,
		string hint = "" )
		=> Pick( label, () => options, get, set, hint );

	/// <summary>
	/// A click-to-choose row whose options are resolved when drawn.
	///
	/// ⚠️ Nudge is kept working so the keyboard and the console can still step
	/// through — the chips are an addition, not a replacement.
	/// </summary>
	static Row Pick( string label, Func<string[]> options, Func<string> get, Action<string> set,
		string hint = "" )
		=> new( label, get, d =>
		{
			var opts = options();
			if ( opts.Length == 0 ) return;
			var i = Array.IndexOf( opts, get() );
			if ( i < 0 ) i = 0;
			set( opts[(i + d + opts.Length * 2) % opts.Length] );
			Changed();
		}, hint, v => { set( v ); Changed(); }, RowKind.Options, options );

	/// <summary>The palette a `Colour` row offers.
	///
	/// ⚠️ CHOSEN FOR READABILITY ON A WALL, not for coverage of the spectrum. A clue is
	/// outlined text over whatever surface the mapper picked, so the useful range is
	/// "bright enough to read" — muddy mid-tones are absent on purpose.</summary>
	public static readonly string[] Swatches =
	{
		"#ffffff", "#ffeeb8", "#ffd24a", "#ff8a3d",
		"#ff4d4d", "#ff6bd6", "#a97bff", "#5aa9f0",
		"#4fe0c0", "#7ae06a", "#c9c9c9", "#2b2b2b",
	};

	/// <summary>A swatch row. The value is a hex string, the same as a `Str` row.
	///
	/// ⚠️ THE NUDGE STEPS THROUGH THE PALETTE, exactly as `Pick` does. `Row`'s third
	/// argument is `Action&lt;int&gt;` — the − / + and the keyboard both drive it — so a row
	/// that only knows how to be clicked would break stepping for this one kind.</summary>
	static Row Colour( string label, Func<string> get, Action<string> set, string hint = "" )
		=> new( label, get, d =>
		{
			var i = Array.IndexOf( Swatches, get() );
			if ( i < 0 ) i = 0;
			set( Swatches[(i + d + Swatches.Length * 2) % Swatches.Length] );
			Changed();
		}, hint, v => { set( v ); Changed(); }, RowKind.Colour, () => Swatches );

	// ── the sets ─────────────────────────────────────────────────────────────

	/// <summary>Rows for a tool id (NZTools.*) or a settings group.</summary>
	public static List<Row> For( string context )
	{
		var e = Editor;

		return context switch
		{
			NZTools.ZombieSpawn => e is null ? Empty : new()
			{
				// ⚠️ A TYPED FIELD, not a stepper. A flag is a NAME — "1",
				// "upstairs", "power_room" — and there is no meaningful "next"
				// flag to step to, so − / + was the wrong control for it.
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name · blank or 0 = always open" ),
				Bool( "Needs power", () => e.SpawnRequiresPower,
					v => e.SpawnRequiresPower = v, "gated on the electricity" ),
				Int( "Active from round", () => e.SpawnActiveRound,
					v => e.SpawnActiveRound = v, 1, 0, 200, "0 = from the start" ),
			},

			NZTools.SpecialSpawn => e is null ? Empty : new()
			{
				// ⚠️ A PICKER, not a typed name. The id is persisted into every
				// saved config, so a typo here does not fail loudly — it writes a
				// spawner that falls back to the default forever.
				Pick( "Enemy", () => SpecialEnemies.Names,
					() => e.SpecialEnemy, v => e.SpecialEnemy = v,
					SpecialEnemies.DescriptionFor( e.SpecialEnemy ) ),

				// Same three gates as a zombie spawn, and deliberately identical:
				// a special spawner behind a locked door should stay shut for the
				// same reason a walker one does.
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name · blank or 0 = always open" ),
				Bool( "Needs power", () => e.SpawnRequiresPower,
					v => e.SpawnRequiresPower = v, "gated on the electricity" ),
				Int( "Active from round", () => e.SpawnActiveRound,
					v => e.SpawnActiveRound = v, 1, 0, 200, "0 = from the start" ),

				new( "Placed", () => ActiveConfig.Current.SpecialSpawns.Count.ToString(),
					_ => { }, "special rounds need at least one", null, RowKind.Info ),
			},

			NZTools.BossSpawn => e is null ? Empty : new()
			{
				// ⚠️ THE ROSTER IS `BossNames`, NOT `Names`. Registering Brutus as a special made
				// him appear in the special-spawn picker too, which would let a boss be placed as a
				// dog-round enemy. The two pickers read filtered lists so neither can offer the
				// other's roster.
				Pick( "Boss", () => SpecialEnemies.BossNames,
					() => e.BossEnemy, v => e.BossEnemy = v,
					SpecialEnemies.DescriptionFor( e.BossEnemy ) ),

				// The same three gates as every other spawn, deliberately identical — a boss behind
				// a locked door should stay shut for the reason a walker one does.
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name — blank or 0 = always open" ),
				Bool( "Needs power", () => e.SpawnRequiresPower,
					v => e.SpawnRequiresPower = v, "gated on the electricity" ),
				Int( "Active from round", () => e.SpawnActiveRound,
					v => e.SpawnActiveRound = v, 1, 0, 200, "0 = from the start" ),

				new( "Placed", () => ActiveConfig.Current.BossSpawns.Count.ToString(),
					_ => { }, "bosses cannot appear without one", null, RowKind.Info ),

				// ⛔ THE SIZE WARNING IS A ROW RATHER THAN A LOG LINE, because this is where the
				// mistake is made. A boss is far larger than a walker — Brutus is 80u tall with a
				// 22u radius — so a point that works for a special can wedge him, and it stays
				// invisible until one spawns there.
				new( "Needs room", () => "open floor, not doorways",
					_ => { }, "a boss is much larger than a walker", null, RowKind.Info ),
			},

			NZTools.NavLink => e is null ? Empty : new()
			{
				Bool( "Jump up", () => e.NavLinkJump, v => e.NavLinkJump = v,
					"low point -> high point" ),
				Bool( "Drop down", () => e.NavLinkDrop, v => e.NavLinkDrop = v,
					"high point -> low point" ),
				Bool( "Drop zone (1 click)", () => e.NavLinkDropMode,
					v => e.NavLinkDropMode = v,
					"stand on the ledge, click once · finds the landing itself" ),
				Bool( "Walk through", () => e.NavLinkWalk, v => e.NavLinkWalk = v,
					"no jump or drop clip · normal speed · no pause at either end" ),
				Num( "Radius", () => e.NavLinkRadius, v => e.NavLinkRadius = v, 4f, 8f, 256f,
					"how wide the mouth is · wider funnels a crowd in" ),
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name · blank or 0 = always on" ),

				new( "Placed", () => ActiveConfig.Current.NavLinks.Count.ToString(),
					_ => { }, "LMB the ledge, then LMB where they land", null, RowKind.Info ),
			},

			// ⛔ THE PIECE PICKER IS THE WHOLE REASON THIS TOOL NEEDS A PANEL. Which of the three
			// it places was a console-only setting (`nz_buildpart_set`), so selecting the tool from
			// the Q menu gave an EMPTY column and no way to choose — you would place three of the
			// same piece and only find out when the weapon could not be built.
			NZTools.BuildPart => e is null ? Empty : new()
			{
				Pick( "Piece", () => BuildPieces, () => BuildPiece( MapEditor.BuildPartNext ),
					v => MapEditor.BuildPartNext = BuildPieceOf( v ),
					"which of the three this places · also nz_buildpart_set 1..3" ),
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"door flag that must be open before it can be taken · blank = always" ),
				Bool( "Requires electricity", () => e.SpawnRequiresPower,
					v => e.SpawnRequiresPower = v, "off by default · cannot be picked up unpowered" ),
				Num( "Pick-up reach", () => BuildPartManager.Reach,
					v => BuildPartManager.Reach = v, 5f, 16f, 400f,
					"how close you must stand · tighter than the table's, a part is a small thing" ),

				// ⚠️ IT COUNTS WHAT IS MISSING, NOT JUST WHAT IS PLACED. A map short of one piece is
				// unwinnable and looks identical to a finished one from here.
				new( "Placed", () => BuildPlacedSummary(), _ => { },
					"all three must exist somewhere or the weapon cannot be built",
					null, RowKind.Info ),
			},

			NZTools.BuildTable => e is null ? Empty : new()
			{
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"door flag that must be open before it serves · blank = always" ),
				Bool( "Requires electricity", () => e.SpawnRequiresPower,
					v => e.SpawnRequiresPower = v, "off by default · will not build unpowered" ),
				Bool( "Permanent", () => e.BuildTablePermanent,
					v => e.BuildTablePermanent = v,
					"off = one time, the weapon is gone once taken · on = anyone, unlimited" ),
				Num( "Hold to build", () => BuildTable.HoldSeconds,
					v => BuildTable.HoldSeconds = v, 0.5f, 0.5f, 30f,
					"seconds of held E · 4 is the design" ),
				Num( "Reach", () => BuildTable.UseRange, v => BuildTable.UseRange = v,
					5f, 16f, 400f, "how close you must stand · the prompt uses this too" ),
				Num( "Yaw offset", () => BuildTableManager.YawOffset,
					v => BuildTableManager.YawOffset = v, 15f, -180f, 180f,
					"90 turns the bench to face its long side · the mesh's forward is its short edge" ),

				new( "Placed", () => (ActiveConfig.Current?.BuildTables.Count ?? 0).ToString(),
					_ => { }, "LMB place · RMB remove", null, RowKind.Info ),
			},

			NZTools.SoulBox => e is null ? Empty : new()
			{
				Int( "Kills to fill", () => e.SoulBoxTarget, v => e.SoulBoxTarget = v, 1, 1, 999,
					"zombies that must die in range · 20 is upstream's" ),
				Num( "Range", () => e.SoulBoxRange, v => e.SoulBoxRange = v, 25f, 32f, 4000f,
					"how far a kill counts · 500 is upstream's, and it covers a room" ),
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"ALL boxes on this flag must fill before it opens" ),
				Bool( "Requires electricity", () => e.SoulBoxRequiresPower,
					v => e.SoulBoxRequiresPower = v, "off by default · won't take souls unpowered" ),
				Bool( "Powerup on fill", () => e.SoulBoxPowerup, v => e.SoulBoxPowerup = v,
					"random powerup when THIS box fills · 5 boxes pay 5" ),
				Num( "Box size", () => e.SoulBoxSize, v => e.SoulBoxSize = v, 4f, 8f, 512f,
					"placeholder width · not the collection range" ),
				Num( "Box height", () => e.SoulBoxHeight, v => e.SoulBoxHeight = v, 4f, 4f, 512f,
					"placeholder height" ),
				Pick( "Box model", SoulBoxModels, () => e.SoulBoxModel ?? "",
					v => e.SoulBoxModel = v, "blank = the plain box, which is resizable" ),
				Pick( "Box material", Materials, () => e.SoulBoxMaterial ?? "",
					v => e.SoulBoxMaterial = v, "only used when Box model is blank" ),
				new( "Flag rule", () => "all, not any", _ => { },
					"unlike every other flag: every box carrying it must be full",
					null, RowKind.Info ),
			},

			NZTools.Teleporter => e is null ? Empty : new()
			{
				Int( "Price", () => e.TeleporterPrice, v => e.TeleporterPrice = v, 50, 0, 100000,
					"per trip · 0 = free · charged once, to whoever presses" ),
				Num( "Warmup", () => e.TeleporterWarmup, v => e.TeleporterWarmup = v,
					0.5f, 1.1f, 30f, "press to departure · riders are latched at the END of it" ),
				Num( "Transit", () => e.TeleporterTransit, v => e.TeleporterTransit = v,
					0.5f, 0.2f, 30f, "frozen, immune, watching the overlay" ),
				Num( "Cooldown", () => e.TeleporterCooldown, v => e.TeleporterCooldown = v,
					5f, 0f, 600f, "seconds before it can be used again · 0 = none · per machine" ),
				Num( "Pad size", () => e.TeleporterPadSize, v => e.TeleporterPadSize = v,
					8f, 16f, 512f, "how wide · also who counts as standing on it" ),
				Num( "Pad height", () => e.TeleporterPadHeight, v => e.TeleporterPadHeight = v,
					2f, 1f, 64f, "box thickness · above ~18 it becomes a kerb to jump" ),
				Bool( "Requires electricity", () => e.TeleporterRequiresPower,
					v => e.TeleporterRequiresPower = v, "off by default · a route, not a purchase" ),
				Pick( "Pad model", TeleporterModels, () => e.TeleporterModel ?? "",
					v => e.TeleporterModel = v, "blank = the plain box, which is resizable" ),
				Pick( "Box material", Materials, () => e.TeleporterMaterial ?? "",
					v => e.TeleporterMaterial = v, "only used when Pad model is blank" ),
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name · blank or 0 = always usable" ),
				new( "How to place", () => "pad, then exit", _ => { },
					"first click is the pad · second is where it sends you · R clears",
					null, RowKind.Info ),
			},

			// ⚠️ THE NUMBERS GO ONTO THE NEXT PAD PLACED, AND ONTO ONE CLICKED INSIDE: tuning a placed pad is a click on it.
			NZTools.Springboard => e is null ? Empty : new()
			{
				Num( "Strength", () => e.SpringboardStrength, v => e.SpringboardStrength = v, 50f, 0f, 5000f,
					"upward speed, u/s · 700 = the perk's, about 300 up · 1000 is about 625" ),
				Num( "Radius", () => e.SpringboardRadius, v => e.SpringboardRadius = v, 8f, 8f, 2048f,
					"how wide, flat, in units · 48 = the perk's" ),
				Bool( "Safe landing", () => e.SpringboardSafeLanding, v => e.SpringboardSafeLanding = v,
					"no fall damage on the landing after a throw · off = a trap" ),
				new( "Throws", () => $"about {SpringboardSystem.ApexHeight( e.SpringboardStrength, e.Scene ):0} up",
					_ => { }, "units, from the scene's gravity · a run-up adds distance, not height", null, RowKind.Info ),
				new( "Placed", () => (ActiveConfig.Current?.Springboards?.Count ?? 0).ToString(),
					_ => { }, "LMB place · LMB inside one re-sets it · RMB removes", null, RowKind.Info ),
			},

			NZTools.PerkMachine => e is null ? Empty : new()
			{
				// ⚠️ SHOWS NAMES, STORES IDS. The config keys on the id, so the row translates at
				// both ends rather than saving whatever text the chip happened to read.
				//
				// ⚠️ Only perks that HAVE a machine model are offered — PerkRegistry.All would
				// list perks the tool then refuses to place, which reads as the tool being broken.
				Pick( "Perk",
					() => PerkMachineManager.Placeable
						.Select( id => PerkRegistry.Find( id )?.Name ?? id ).ToArray(),
					() => e.PerkMachinePerkInfo?.Name ?? e.PerkMachinePerk,
					v => e.PerkMachinePerk =
						PerkRegistry.All.FirstOrDefault( p => p.Name == v )?.Id ?? v,
					"the one perk this machine sells · augments stay on the Wunderfizz" ),

				// ⚠️ A HAND-BUILT ROW rather than Int(), because -1 has to READ as "auto". A row
				// showing "-1" where every other price shows a number invites someone to "fix" it
				// to 0, which is a free perk machine and a completely different thing.
				new( "Price",
					() => e.PerkMachinePrice < 0
						? $"auto ({Wunderfizz.PerkPriceRule( brief: true )})"
						: e.PerkMachinePrice.ToString( "N0" ),
					d =>
					{
						var cur = e.PerkMachinePrice;
						e.PerkMachinePrice = cur < 0
							? (d > 0 ? 0 : -1)
							: Math.Clamp( cur + d * 100, -1, 100000 );
						Changed();
					},
					"auto = the Wunderfizz's price, more for each perk owned · a number is a flat price",
					v =>
					{
						if ( int.TryParse( v, out var n ) )
						{ e.PerkMachinePrice = Math.Clamp( n, -1, 100000 ); Changed(); }
					} ),

				Int( "Starting round", () => e.PerkMachineStartRound,
					v => e.PerkMachineStartRound = v, 1, 0, 200,
					"earliest usable round · 0 = from the start" ),
				Bool( "Requires electricity", () => e.PerkMachineRequiresPower,
					v => e.PerkMachineRequiresPower = v, "on by default · needs the power on" ),
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name · blank or 0 = always usable" ),
			},

			NZTools.Wunderfizz => e is null ? Empty : new()
			{
				Int( "Base price", () => e.WunderfizzPrice, v => e.WunderfizzPrice = v,
					100, 0, 100000, "first roll · 1500 is the nZombies default" ),
				Int( "Price increment", () => e.WunderfizzIncrement,
					v => e.WunderfizzIncrement = v, 100, 0, 100000,
					"added after each roll · 0 = flat price" ),
				Int( "Starting round", () => e.WunderfizzStartRound,
					v => e.WunderfizzStartRound = v, 1, 0, 200,
					"earliest usable round · 0 = from the start" ),
				Bool( "Requires electricity", () => e.WunderfizzRequiresPower,
					v => e.WunderfizzRequiresPower = v, "needs the power on" ),
				Int( "Perk slot price", () => e.WunderfizzSlotPrice,
					v => e.WunderfizzSlotPrice = v, 100, 0, 100000,
					"cost of the FIRST extra perk slot · 0 = not offered" ),
				Int( "Perk slot increment", () => e.WunderfizzSlotIncrement,
					v => e.WunderfizzSlotIncrement = v, 100, 0, 100000,
					"added per slot already bought · 0 = flat price" ),
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name · blank or 0 = always usable" ),

				new( "Placed", () => ActiveConfig.Current.Wunderfizzes.Count.ToString(),
					_ => { }, "LMB on the floor · RMB removes", null, RowKind.Info ),
			},

			NZTools.Arsenal => e is null ? Empty : new()
			{
				Int( "Armor tier 1", () => e.ArsenalTier1Price,
					v => e.ArsenalTier1Price = v, 100, 0, 100000,
					"SALVAGE, not points · 700 is the locked default" ),
				Int( "Armor tier 2", () => e.ArsenalTier2Price,
					v => e.ArsenalTier2Price = v, 100, 0, 100000, "salvage · 2,000" ),
				Int( "Armor tier 3", () => e.ArsenalTier3Price,
					v => e.ArsenalTier3Price = v, 100, 0, 100000,
					"salvage · 5,000 · 7,700 for a full vest" ),
				Int( "Starting round", () => e.ArsenalStartRound,
					v => e.ArsenalStartRound = v, 1, 0, 200,
					"earliest usable round · 0 = from the start" ),
				Bool( "Requires electricity", () => e.ArsenalRequiresPower,
					v => e.ArsenalRequiresPower = v, "needs the power on" ),
				Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
					"any name · blank or 0 = always usable" ),

				new( "Placed", () => ActiveConfig.Current.Arsenals.Count.ToString(),
					_ => { }, "LMB on the floor · RMB removes", null, RowKind.Info ),
			},

			NZTools.Pressable => e is null ? Empty : PressRows( e ),

			NZTools.Shootable => e is null ? Empty : ShootRows( e ),

			NZTools.Clue => e is null ? Empty : new()
			{
				Str( "Text", () => e.ClueText, v => e.ClueText = v,
					"what it says · blank = image only" ),
				// ⛔ A TYPED PATH, NOT A PICKER. A `Pick` row was tried and reverted: with the
				// clues folder empty it collapsed to a single chip for the one blank option and
				// read as a toggle, and even filled it can only ever offer what is in that one
				// folder. `nz_clue_images <filter>` finds a path anywhere in the project; this
				// row is where it goes.
				Str( "Image", () => e.ClueImage, v => e.ClueImage = v,
					"a .png · a bare name is looked for in materials/clues · blank = text only · nz_clue_images lists them" ),
				Num( "Font size", () => e.ClueFontSize, v => e.ClueFontSize = v, 2f, 4f, 256f,
					"in panel units" ),
				Colour( "Colour", () => e.ClueColor, v => e.ClueColor = v,
					"click a swatch · any hex still works over the console" ),
				Num( "Opacity", () => e.ClueOpacity, v => e.ClueOpacity = v, 0.01f, 0.001f, 1f,
					"0.001 = all but invisible · 1 = solid" ),
				// ⚠️ PANEL UNITS, TWENTY TO A WORLD UNIT — a world panel's own scale (`RingsClue.PanelScale`) — and for the NEXT
				// clue placed: these rows are stamped at placement, and change nothing already on a wall.
				Num( "Width", () => e.ClueSize.x,
					v => e.ClueSize = new Vector2( v, e.ClueSize.y ), 50f, 16f, 4096f,
					"the next clue placed · panel units, twenty to a world unit · big is fine, the board has no background" ),
				Num( "Height", () => e.ClueSize.y,
					v => e.ClueSize = new Vector2( e.ClueSize.x, v ), 50f, 16f, 4096f,
					"the next clue placed · panel units, twenty to a world unit · big is fine, the board has no background" ),

				// ⚠️ THE COLOR RINGS CLUE ALREADY ON THE WALL, LIVE — not the next one placed, as every row above is. Asked for
				// as *"i want to be able to change the scale"*; `nz_hex_rings_scale` and `nz_hex_rings_clue` do it by console.
				Num( "Rings size", () => RingsClue.Across, v => RingsClue.Across = v, 8f, 8f, 4096f,
					"the Color Rings clue already placed, live · world units across · nz_hex_rings_scale 1.5 = half as big again" ),

				new( "Placed", () => (ActiveConfig.Current?.Clues.Count ?? 0).ToString(),
					_ => { }, "LMB on a wall or floor · RMB removes", null, RowKind.Info ),
			},

			// ⚠️ NOTHING TO SET, like the misery device: a slot is a place and a facing, and what it shows is rolled again
			// every round. How big they all are is one number, `nz_hex_slot_size`.
			NZTools.HexSlot => e is null ? Empty : new()
			{
				new( "Placed", () => (ActiveConfig.Current?.HexSlots.Count ?? 0).ToString(),
					_ => { }, "LMB on a wall · RMB removes · four at most", null, RowKind.Info ),

				new( "Showing", () => HexSlotManager.Showing(),
					_ => { }, "rolled again every round · nz_hex_slots_roll rolls now", null, RowKind.Info ),
			},

			NZTools.Misery => e is null ? Empty : new()
			{
				new( "Placed", () => (ActiveConfig.Current?.Miseries.Count ?? 0).ToString(),
					_ => { }, "LMB on the floor · RMB removes", null, RowKind.Info ),

				new( "State", () => MiseryDevice.Running ? "ON" : "off",
					_ => { }, "starts off every run · nz_misery toggles", null, RowKind.Info ),
			},

			NZTools.BuyableEnding => e is null ? Empty : new()
			{
				Int( "Price", () => e.EndingPrice, v => e.EndingPrice = v, 100, 0, 1000000,
					"points to end the run · 0 = free" ),
				Str( "Prompt", () => e.EndingHint, v => e.EndingHint = v,
					"what the use prompt says · blank = 'End game'" ),
				Int( "Starting round", () => e.EndingStartRound,
					v => e.EndingStartRound = v, 1, 0, 200,
					"earliest usable round · 0 = from the start" ),

				new( "Placed", () => ActiveConfig.Current.Endings.Count.ToString(),
					_ => { }, "LMB on the floor · RMB removes", null, RowKind.Info ),
			},

			NZTools.PlayerSpawn => new()
			{
				// Player spawns carry no settings of their own — position and
				// facing ARE the setting. Say so rather than showing an empty
				// panel, which reads as broken.
				new( "No settings", () => "place and face", _ => { },
					"the spawn's position and angle are all there is", null, RowKind.Info ),
			},

			NZTools.Debris => e is null ? Empty : new()
			{
				Int( "Price", () => e.DebrisPrice, v => e.DebrisPrice = v, 50, -10000, 100000,
					"0 with power = opens itself · negative = pays you" ),
				Str( "Flag", () => e.DebrisLink, v => e.DebrisLink = v,
					"any name · same flag opens together" ),
				// ⚠️ THE FLAG'S NAME, NOT THIS BARRIER'S — every barrier on a flag leads into the same room (`RoomNames`). The same
				// names, a row per flag, are Settings → Map → Room names.
				Str( "Room name", () => RoomNames.NameOf( e.DebrisLink ), v => RoomNames.SetName( e.DebrisLink, v ),
					"this flag's · top left on walking through · flag 0 = the start" ),
				Bool( "Buyable", () => e.DebrisBuyable, v => e.DebrisBuyable = v,
					"off = no prompt · opens only with its flag" ),
				Bool( "Needs power", () => e.DebrisRequiresPower,
					v => e.DebrisRequiresPower = v, "buyable only once powered" ),
				Bool( "Block mode", () => e.DebrisBlockMode, v => e.DebrisBlockMode = v,
					"corners then a height click, vs copying a prop" ),
				// ⚠️ ABOVE Material on purpose: it decides whether Material matters at
				// all, and a setting that silences the row under it belongs before it.
				Bool( "Visible", () => e.DebrisVisible, v => e.DebrisVisible = v,
					"off = solid but not drawn · still buyable, still opens" ),
				Int( "Corners", () => e.DebrisCorners, v => e.DebrisCorners = v, 1, 3, 8,
					"clicked on the floor · one more click sets the height" ),
				Num( "Height if missed", () => e.DebrisHeight, v => e.DebrisHeight = v,
					16f, 16f, 1024f, "only used when the height click lands too low" ),
				Pick( "Material", Materials, () => e.DebrisMaterial ?? "",
					v => e.DebrisMaterial = v, "surface for block mode" ),
			},

			// ⚠️ FOUR ROWS, AND THAT IS THE FEATURE. A wall has no price, no
			// flag and no power gate — showing those greyed out would suggest it
			// might one day be buyable, which is the one thing it must never be.
			NZTools.InvisibleWall => e is null ? Empty : new()
			{
				Bool( "Visible", () => e.WallVisible, v => e.WallVisible = v,
					"off = solid but not drawn · collision either way" ),
				// ⚠️ THE ROW HAS TO EXIST OR THE SETTING DOES NOT. The config field, the
				// manager and `nz_wall_zombies` were all in place and the tool still built
				// player-only walls, because this panel is where a wall is actually authored
				// — a console command nobody opens is not a feature.
				Bool( "Blocks zombies", () => e.WallBlocksZombies,
					v => e.WallBlocksZombies = v,
					"off = horde walks through · on = baked into the navmesh, they route around" ),
				Int( "Corners", () => e.DebrisCorners, v => e.DebrisCorners = v, 1, 3, 8,
					"clicked on the floor · one more click sets the height" ),
				Pick( "Material", Materials, () => e.WallMaterial ?? "",
					v => e.WallMaterial = v, "only used when visible" ),
			},

			// ⚠️ THE SHADOW ROW SAYS WHAT IT COSTS. Every other toggle here is a look or a rule;
			// this one is the difference between a cheap light and the expensive kind, and a mapper
			// who turns it on for twenty lights has no other warning until the framerate goes.
			NZTools.MapLight => e is null ? Empty : new()
			{
				Colour( "Colour", () => e.LightColor, v => e.LightColor = v,
					"the light's hue · brightness is its own row" ),
				Num( "Brightness", () => e.LightBrightness, v => e.LightBrightness = v,
					0.25f, 0f, 50f, "multiplier on the colour · 1 is the swatch as-is" ),
				Num( "Radius", () => e.LightRadius, v => e.LightRadius = v,
					50f, 16f, 8192f, "how far it reaches, in units" ),
				Bool( "Shadows", () => e.LightShadows, v => e.LightShadows = v,
					"⚠ EXPENSIVE · dynamic every frame · off unless it earns it" ),
				Bool( "Needs power", () => e.LightRequiresPower, v => e.LightRequiresPower = v,
					"off = always lit · on = dark until the switch" ),

				new( "Placed", () => (ActiveConfig.Current?.Lights.Count ?? 0).ToString(),
					_ => { }, "LMB place · RMB removes the nearest", null, RowKind.Info ),
			},

			NZTools.SoundSpot => e is null ? Empty : new()
			{
				// ⚠️ RESOLVED WHEN DRAWN, not captured once — a sound imported while the panel is
				// open should appear in the list without reopening the tool.
				Pick( "Sound", SoundEvents, () => e.SoundEventPath ?? "",
					v => e.SoundEventPath = v, "a .sound EVENT, not an audio file" ),
				Num( "Volume", () => e.SoundSpotVolume, v => e.SoundSpotVolume = v,
					0.1f, 0f, 4f, "before distance falloff" ),
				Num( "Distance", () => e.SoundSpotDistance, v => e.SoundSpotDistance = v,
					100f, 64f, 8192f, "how far it carries, in units" ),
				// ⚠️ THE ROW HAS TO EXIST OR THE SETTING DOES NOT — the same lesson the invisible
				// wall's "Blocks zombies" row records. Without it a spot always used the default 20s
				// and a clip of any other length either gapped or overlapped, with no way to say so.
				Num( "Repeat every", () => e.SoundSpotRepeat, v => e.SoundSpotRepeat = v,
					0.5f, 0f, 600f, "SET TO THE CLIP'S LENGTH · 0 = play once" ),

				new( "Placed", () => (ActiveConfig.Current?.Sounds.Count ?? 0).ToString(),
					_ => { }, "LMB place · RMB removes the nearest", null, RowKind.Info ),
			},

			// ⚠️ THE NAME IS STAMPED ON THE NEXT ZONE DRAWN. One already drawn is renamed in Settings → Map → Room names, or
			// with nz_room_zone_name. No flag and no power row, as the walls have none: a zone gates nothing, it names a space.
			NZTools.RoomZone => e is null ? Empty : new()
			{
				Str( "Name", () => e.RoomZoneName, v => e.RoomZoneName = v,
					"what the top left says on walking in · blank = names nothing" ),
				Int( "Corners", () => e.DebrisCorners, v => e.DebrisCorners = v, 1, 3, 8,
					"clicked on the floor · one more click sets the height" ),
				new( "Drawn", () => (ActiveConfig.Current?.RoomZones.Count ?? 0).ToString(),
					_ => { }, "LMB corners, then click the height · R restarts · RMB removes", null, RowKind.Info ),
			},

			// ⚠️ DENSITY IS THE ONLY ROW THAT CAN RUIN IT, so it steps in 0.02. Fog reads as
			// atmosphere far below where the number looks meaningful — a 0.1 step would jump straight
			// past the usable range, which runs out well before 0.5.
			NZTools.FogArea => e is null ? Empty : new()
			{
				Colour( "Colour", () => e.FogColor, v => e.FogColor = v,
					"soot is a warm near-black · neutral grey reads as mist" ),
				Num( "Density", () => e.FogDensity, v => e.FogDensity = v,
					0.02f, 0f, 1f, "keep it low · 0.18 already reads across a room" ),
				// ⚠️ NO RADIUS AND NO HEIGHT ROW ANY MORE — both come from the shape you draw. The
				// footprint sets the extent and the second click sets the height, so a row for either
				// would be a number that silently disagrees with the volume on screen.
				Num( "Feather", () => e.FogFeather, v => e.FogFeather = v,
					25f, 0f, 2048f, "units INSIDE the edge before it reaches full strength" ),

				new( "Drawn", () => (ActiveConfig.Current?.Fog.Count ?? 0).ToString(),
					_ => { }, "LMB corners, then click the height · R restarts · RMB removes",
					null, RowKind.Info ),
			},

			NZTools.DamageWall => e is null ? Empty : new()
			{
				Num( "Damage", () => e.DamageWallDamage, v => e.DamageWallDamage = v, 5f, 0f, 10000f,
					"dealt to each player inside, per tick" ),
				Num( "Interval", () => e.DamageWallInterval, v => e.DamageWallInterval = v,
					0.1f, 0.05f, 60f, "seconds between ticks · 0.05 is the floor" ),
				Bool( "Visible", () => e.DamageWallVisible, v => e.DamageWallVisible = v,
					"drawn while AUTHORING · on by default" ),
				// ⚠️ THE ROW HAS TO EXIST OR THE SETTING DOES NOT — the same lesson the invisible
				// wall's "Blocks zombies" row above records. The config field, the manager gate and
				// `nz_dmgwall_show` can all be in place and the tool still builds invisible killboxes,
				// because THIS panel is where a wall is actually authored.
				Bool( "Shown in game", () => e.DamageWallShown, v => e.DamageWallShown = v,
					"off = invisible killbox · on = a hazard the player can see" ),
				Pick( "Material", DamageWallMaterials, () => e.DamageWallMaterial ?? "",
					v => e.DamageWallMaterial = v, "lava is animated · needs no UVs" ),
				Int( "Corners", () => e.DebrisCorners, v => e.DebrisCorners = v, 1, 3, 8,
					"clicked on the floor · one more click sets the height" ),

				new( "Placed", () => (ActiveConfig.Current?.DamageWalls.Count ?? 0).ToString(),
					_ => { }, "drawn like an invisible wall · RMB removes", null, RowKind.Info ),
			},

			// ⚠️ NO "Corners" row — a barricade is always two points. Offering a
			// count would imply a footprint flow it does not have.
			NZTools.Barricade => e is null ? Empty : new()
			{
				Int( "Height", () => e.BarricadeHeight, v => e.BarricadeHeight = v, 4, 8, 128,
					"44 = vaultable · taller stops being a barricade" ),
				Int( "Boards", () => e.BarricadeBoards, v => e.BarricadeBoards = v, 1, 0, 6,
					"zombies tear these off one at a time" ),
			},

			// ⚠️ A ROW PER PACK, not one "Pack" picker. The box rolls from SEVERAL
			// packs at once, and Pick is single-select — offering it here would
			// quietly turn a multi-choice setting into a single choice.
			NZTools.MysteryBox => e is null ? Empty : BoxRows( e ),

			NZTools.WallBuy => new()
			{
				Pick( "Pack", () => Packs, () => CurrentPack, v =>
				{
					WallBuyPack = v;
					WallBuyCategory = "All";      // stale category from another pack
				}, "which game these came from" ),

				Pick( "Category", () => Categories, () => WallBuyCategory,
					v => WallBuyCategory = v, "weapon class within the pack" ),

				Pick( "Weapon", () => WeaponNames, () => CurrentWeaponName, v =>
				{
					var hit = WeaponLibrary.In( CurrentPack, WallBuyCategory )
						.FirstOrDefault( e => e.Name == v );
					if ( hit is not null ) WallBuyWeapon = hit.Prefab;
				}, "what this wall sells" ),
				Pick( "Rarity", RarityNames, () => Rarity.NameFor( WallBuyRarity ),
					v =>
					{
						// ⚠️ INDEX, NOT A PARSE. RarityNames is built in tier order from
						// Rarity.NameFor, so the position IS the tier — and a name that somehow
						// does not match leaves the setting alone rather than resetting it to
						// Common behind the mapper's back.
						var t = System.Array.IndexOf( RarityNames, v );
						if ( t >= 0 ) WallBuyRarity = t;
					},
					"chalk colour, and the tier the gun is sold at" ),
				Int( "Price", () => WallBuyPrice, v => WallBuyPrice = v, 50, 50, 20000,
					"ammo refill costs half, floored to 10" ),
				new( "Ammo", () => $"{(WallBuyPrice - WallBuyPrice % 10) / 2}", _ => { },
					"derived — never set directly", null, RowKind.Info ),
			},

			NZTools.PowerSwitch => new()
			{
				// The switch is placed, not configured — its only job is to be
				// somewhere findable. Kept as a row so the panel is never blank.
				new( "No settings", () => "place it", _ => { },
					"one switch powers the whole map", null, RowKind.Info ),
			},

			Players => new()
			{
				// ⚠️ First in the list because it is the one a mapper actually
				// tunes — the speeds and health below are set once and forgotten.
				Num( "Slide speed", () => Cfg.Player.SlideSpeed,
					v => Cfg.Player.SlideSpeed = v, 0.05f, 1f, 3f ),
				Num( "Slide duration", () => Cfg.Player.SlideDuration,
					v => Cfg.Player.SlideDuration = v, 0.05f, 0.1f, 3f ),
				Num( "Slide cooldown", () => Cfg.Player.SlideCooldown,
					v => Cfg.Player.SlideCooldown = v, 0.05f, 0f, 5f ),

				Int( "Perk slots", () => Cfg.Player.PerkSlots,
					v => Cfg.Player.PerkSlots = v, 1, 1, 18,
					"most perks held at once · buy more at the Wunderfizz" ),

				Int( "Perks kept when downed", () => Cfg.Player.PerksKeptOnDown,
					v => Cfg.Player.PerksKeptOnDown = v, 1, 0, 20,
					"0 = lose them all · keeps the ones bought first" ),

				Num( "Max health", () => Cfg.Player.MaxHealth, v => Cfg.Player.MaxHealth = v, 10f, 1f, 1000f ),
				Num( "Walk speed", () => Cfg.Player.WalkSpeed, v => Cfg.Player.WalkSpeed = v, 5f, 10f, 500f ),
				Num( "Sprint speed", () => Cfg.Player.SprintSpeed, v => Cfg.Player.SprintSpeed = v, 10f, 10f, 900f ),
				Num( "Jump power", () => Cfg.Player.JumpPower, v => Cfg.Player.JumpPower = v, 10f, 0f, 800f ),
				Num( "Stamina max", () => Cfg.Player.StaminaMax, v => Cfg.Player.StaminaMax = v, 5f, 0f, 500f ),
				Num( "Stamina drain", () => Cfg.Player.StaminaDrainPerTick, v => Cfg.Player.StaminaDrainPerTick = v, 0.1f, 0f, 20f ),
				Num( "Stamina regen", () => Cfg.Player.StaminaRegenPerTick, v => Cfg.Player.StaminaRegenPerTick = v, 0.5f, 0f, 50f ),
				Num( "Stamina delay", () => Cfg.Player.StaminaRegenDelay, v => Cfg.Player.StaminaRegenDelay = v, 0.1f, 0f, 10f ),
				Num( "Regen delay", () => Cfg.Player.HealthRegenDelay, v => Cfg.Player.HealthRegenDelay = v, 0.5f, 0f, 60f, "seconds unhurt before healing" ),
				Num( "Regen %", () => Cfg.Player.HealthRegenPercent, v => Cfg.Player.HealthRegenPercent = v, 1f, 0f, 100f, "% of max per step" ),
				Num( "Regen rate", () => Cfg.Player.HealthRegenRate, v => Cfg.Player.HealthRegenRate = v, 0.01f, 0.01f, 5f, "seconds per step" ),
			},

			Zombies => new()
			{
				Int( "Base health", () => Cfg.Zombies.BaseHealth, v => Cfg.Zombies.BaseHealth = v, 5, 1, 100000, "round 1" ),
				Int( "Health / round", () => Cfg.Zombies.HealthIncrement, v => Cfg.Zombies.HealthIncrement = v, 5, 0, 10000, "flat, rounds 2-9" ),
				Num( "Health multiplier", () => Cfg.Zombies.HealthMultiplier, v => Cfg.Zombies.HealthMultiplier = v, 0.01f, 0f, 2f, "compounding, round 10+" ),
				// ⚠️ MAX RAISED TO 5M BECAUSE THE DEFAULT IS NOW 1M. A slider whose maximum
				// equals the default can only be dragged DOWN — the control looks live and half
				// of it does not exist. Step raised with it: 1,000 across a 5M range is five
				// thousand clicks.
				// ⚠️ AND TO 30M WITH THE 6M DEFAULT (2026-10-04), for the same reason: a 5M top
				// would have clamped the default itself on the first click. Step 100,000.
				// ⚠️ NOT A CAP SINCE 2026-10-06: health passes "Health target" on "Target round" and keeps climbing at the same
				// rate, so the labels say target. The fields keep their HealthCap names because every config persists them.
				Int( "Health target", () => Cfg.Zombies.HealthCap, v => Cfg.Zombies.HealthCap = v, 100000, 100, 30000000, "hp on the target round · keeps climbing after" ),
				Int( "Linear from", () => Cfg.Zombies.HealthLinearFrom, v => Cfg.Zombies.HealthLinearFrom = v, 1, 1, 500, "last compounding round" ),
				Int( "Target round", () => Cfg.Zombies.HealthCapRound, v => Cfg.Zombies.HealthCapRound = v, 1, 1, 500, "straight line through the target · no cap" ),
				Int( "Top speed round", () => Cfg.Zombies.SpeedCapRound, v => Cfg.Zombies.SpeedCapRound = v, 1, 0, 500, "every zombie at full speed from here · 0 = Speed / round" ),
				Int( "Speed / round", () => Cfg.Zombies.SpeedPerRound, v => Cfg.Zombies.SpeedPerRound = v, 1, 0, 100, "only when Top speed round is 0" ),
				Int( "Speed cap", () => Cfg.Zombies.SpeedCap, v => Cfg.Zombies.SpeedCap = v, 10, 0, 2000 ),
				Int( "Wave base", () => Cfg.Zombies.WaveBase, v => Cfg.Zombies.WaveBase = v, 1, 1, 500, "per player" ),
				Int( "Wave cap", () => Cfg.Zombies.WaveCap, v => Cfg.Zombies.WaveCap = v, 10, 1, 5000 ),
				Int( "Max alive", () => Cfg.Zombies.MaxAlive, v => Cfg.Zombies.MaxAlive = v, 1, 1, 200, "population target, not a rate limit" ),
				Num( "Spawn delay", () => Cfg.Zombies.SpawnDelayBase, v => Cfg.Zombies.SpawnDelayBase = v, 0.1f, 0.05f, 30f, "seconds, round 1" ),
				Num( "Spawn delay min", () => Cfg.Zombies.SpawnDelayMin, v => Cfg.Zombies.SpawnDelayMin = v, 0.01f, 0.01f, 10f ),
			},

			Gameplay => new()
			{
				Int( "Starting points", () => Cfg.Gameplay.StartingPoints, v => Cfg.Gameplay.StartingPoints = v, 50, 0, 100000 ),
				// ⚠️ THE LOADING SCREEN AND THE NAME IT SHOWS (`MapLoading`, 2026-09-28)
				Str( "Title", () => Cfg.Title ?? "", v => Cfg.Title = v,
					"the map's name on its loading screen and in the config list · blank = the map's own" ),
				// ⚠️ THE OPENING CARD'S LINE OF PLACE (`IntroCard`, 2026-09-28)
				Str( "Location", () => Cfg.Location ?? "", v => Cfg.Location = v,
					"a line under the name on the opening card · blank = none · basalt: Beneath the Basalt · nz_intro" ),
				// ⚠️ THE PUBLISHED LOBBY'S NAME FOR THIS CONFIG (`Gamemodes`, 2026-10-05) — each config a map ships is a gamemode
				Str( "Gamemode", () => Cfg.Gamemode ?? "", v => Cfg.Gamemode = v,
					"what the published lobby's Gamemode list calls this config · blank = its file name · nz_gamemode" ),
				Str( "Gamemode info", () => Cfg.GamemodeDescription ?? "", v => Cfg.GamemodeDescription = v,
					"one line under that name in the list · blank = none" ),
				Str( "Ambience", () => Cfg.Gameplay.AmbientBed ?? "", v => Cfg.Gameplay.AmbientBed = v,
					"one quiet bed under the whole game · blank = none · basalt: nz.basalt.ambience · nz_ambience 0 mutes it" ),
				Num( "Loading screen", () => Cfg.Gameplay.LoadingSeconds, v => Cfg.Gameplay.LoadingSeconds = v,
					0.5f, 0f, 60f, "seconds of the map's image, name and a bar between ready and the spawn · 0 = none" ),
				Num( "Round 1 delay", () => Cfg.Gameplay.FirstRoundDelay, v => Cfg.Gameplay.FirstRoundDelay = v,
					0.5f, 0f, 30f, "seconds from round 1's start to its first zombie · 0 = at once" ),
				// ⚠️ THE MAP'S TREMORS, EACH OFF UNLESS THE MAP ASKS (2026-10-01) — basalt's volcano had shaken every map
				Bool( "Spawn-in tremor", () => Cfg.Gameplay.SpawnInTremor, v => Cfg.Gameplay.SpawnInTremor = v,
					"the view shakes and the rock rumbles as the game fades up, dust falling · off = none · basalt: on · nz_tremor" ),
				Bool( "Power tremor", () => Cfg.Gameplay.PowerOnTremor, v => Cfg.Gameplay.PowerOnTremor = v,
					"the same as the power comes on · off = none · basalt: on · nz_power_tremor" ),
				Bool( "Tremors", () => Cfg.Gameplay.Tremors, v => Cfg.Gameplay.Tremors = v,
					"the ground shakes softly now and then, dust falling · the wait rolled again each time · off = none · basalt: on" ),
				Num( "Tremor wait, from", () => Cfg.Gameplay.TremorMinMinutes, v => Cfg.Gameplay.TremorMinMinutes = v,
					1f, 0.5f, 120f, "minutes — the shortest wait between two" ),
				Num( "Tremor wait, to", () => Cfg.Gameplay.TremorMaxMinutes, v => Cfg.Gameplay.TremorMaxMinutes = v,
					1f, 0.5f, 120f, "minutes — the longest wait between two" ),

				// ⚠️ THE MAP'S OWN SOUNDS (`NZSound.MapCue`) — a cue under sounds/nz/, blank for the game's own
				Str( "Round start sound", () => Cfg.Gameplay.RoundStartSound ?? "", v => Cfg.Gameplay.RoundStartSound = v,
					"a cue under sounds/nz/ · blank = the game's own · basalt: nz.basalt.round.start" ),
				Str( "Round end sound", () => Cfg.Gameplay.RoundEndSound ?? "", v => Cfg.Gameplay.RoundEndSound = v,
					"keep it under 10 s, the gap before the next round · blank = the game's own" ),
				Str( "Game over sound", () => Cfg.Gameplay.GameOverSound ?? "", v => Cfg.Gameplay.GameOverSound = v,
					"blank = the game's own" ),
				Str( "Debris sound", () => Cfg.Gameplay.DebrisSound ?? "", v => Cfg.Gameplay.DebrisSound = v,
					"a barrier coming down · blank = the game's own" ),
				// ⚠️ THE POWER'S MOTIF AND THE WALL BUY'S FLAME (2026-09-28)
				Str( "Power sound", () => Cfg.Gameplay.PowerSound ?? "", v => Cfg.Gameplay.PowerSound = v,
					"the map's motif as the power comes on, over the game's own · blank = none · basalt: nz.basalt.power · nz_power_motif" ),
				Str( "Wall buy sound", () => Cfg.Gameplay.WallBuySound ?? "", v => Cfg.Gameplay.WallBuySound = v,
					"a wall weapon bought, at the wall, in place of the cha-ching · blank = the game's own · basalt: nz.wallbuy.flame" ),
				// ⚠️ THE EASTER EGG'S MUSIC (`EggMusic`, 2026-09-28)
				Str( "Defense music", () => Cfg.Gameplay.DefenseMusic ?? "", v => Cfg.Gameplay.DefenseMusic = v,
					"the altar defense's challenge track · blank = none · basalt: nz.basalt.music.defend · nz_egg_music defend" ),
				Str( "Boss music", () => Cfg.Gameplay.BossMusic ?? "", v => Cfg.Gameplay.BossMusic = v,
					"the boss fight's track, low through each phase change · blank = none · basalt: nz.basalt.music.boss · nz_egg_music boss" ),
				Str( "Boss win sound", () => Cfg.Gameplay.BossWinSound ?? "", v => Cfg.Gameplay.BossWinSound = v,
					"a sting as the beast dies · blank = none · basalt: none, its fanfare's victory motif plays (nz_egg_fanfare 15)" ),
				Str( "Lobby music", () => Cfg.Gameplay.LobbyMusic ?? "", v => Cfg.Gameplay.LobbyMusic = v,
					"what the lobby plays with this map loaded · blank = the game's own tracks · basalt: nz.basalt.lobby" ),
				Str( "Box spin sound", () => Cfg.Gameplay.BoxSpinSound ?? "", v => Cfg.Gameplay.BoxSpinSound = v,
					"the mystery box spinning, played to its end · blank = the game's own · basalt: nz.basalt.box.spin" ),
				// ⚠️ THE BOX'S LOOK (`MysteryBoxSkins`, 2026-09-28) — rebuilt at once, so the choice shows
				Pick( "Box skin", MysteryBoxSkins.Names, () => MysteryBoxSkins.Current.Name, v =>
				{
					Cfg.Gameplay.BoxSkin = v == MysteryBoxSkins.OriginalName ? "" : v;
					var boxes = MysteryBoxManager.Instance;
					if ( boxes.IsValid() ) boxes.Rebuild();
				}, "the mystery box's look · origins: the stone chest from Origins, on its base · nz_box_skin" ),
				// ⚠️ EMBER CHALK AND FOG EMBERS (2026-09-28)
				Colour( "Chalk colour", () => Cfg.Gameplay.ChalkColour ?? "", v => Cfg.Gameplay.ChalkColour = v,
					"the common tier's wall-buy chalk · blank = white · basalt: ember #ff8a3d" ),
				Num( "Chalk glow", () => Cfg.Gameplay.ChalkGlow, v => Cfg.Gameplay.ChalkGlow = v, 0.05f, 0f, 1f,
					"how much the chalk breathes, like embers · 0 = steady" ),
				// ⚠️ THE BURN-IN (`WallBuyBurnIn`, 2026-09-28)
				Num( "Chalk burn-in", () => Cfg.Gameplay.ChalkBurnIn, v => Cfg.Gameplay.ChalkBurnIn = v, 0.1f, 0f, 5f,
					"seconds a wall weapon takes to burn into its chalk, side to side · 0 = at once · nz_wallbuy_burn" ),
				Num( "Fog embers", () => Cfg.Gameplay.FogEmbers, v => Cfg.Gameplay.FogEmbers = v, 0.25f, 0f, 4f,
					"embers rising in the fog areas beside the ash · 0 = none · nz_embers" ),
				// ⚠️ THE LIGHTS, THE POWER AND THE SHAKING (`StripLights`, `MapLightManager`, 2026-09-28)
				Bool( "Strips need power", () => Cfg.Gameplay.StripsNeedPower, v => Cfg.Gameplay.StripsNeedPower = v,
					"the map's light strips dark until the power is on, then lit in a wave from the switch · nz_strips" ),
				Str( "Strip material", () => Cfg.Gameplay.StripMaterial ?? "", v => Cfg.Gameplay.StripMaterial = v,
					"a part of the strips' material path · blank = lights/white001 · nz_strips rebuild after a change" ),
				Num( "Light flicker", () => Cfg.Gameplay.LightFlicker, v => Cfg.Gameplay.LightFlicker = v, 0.25f, 0f, 3f,
					"how much the lights flicker when the ground shakes, each on its own · 0 = none · nz_flicker" ),
				Num( "Lava embers", () => Cfg.Gameplay.LavaEmbers, v => Cfg.Gameplay.LavaEmbers = v, 0.25f, 0f, 4f,
					"embers rising off the lava near the player · 0 = none · nz_lava_embers" ),
				Num( "Lava haze", () => Cfg.Gameplay.LavaHaze, v => { Cfg.Gameplay.LavaHaze = v; LavaHaze.Ensure()?.Rebuild(); }, 1f, 0f, 1f,
					"heat haze over the lava · 0 = none · nz_lava_haze · nz_lava_look living|classic for the lava itself" ),
				Bool( "Engravings", () => Cfg.Gameplay.Engravings, v => { Cfg.Gameplay.Engravings = v; BasaltEngravings.Ensure()?.Rebuild(); },
					"basalt's carvings on its pillars (basalt only) · nz_engravings" ),
				Str( "Room sound", () => Cfg.Gameplay.RoomSound ?? "", v => Cfg.Gameplay.RoomSound = v,
					"a player's first visit to a named room, heard by them alone · blank = none" ),
				Int( "Points / hit", () => Cfg.Gameplay.PointsHit, v => Cfg.Gameplay.PointsHit = v, 5, 0, 10000 ),
				Int( "Points / kill", () => Cfg.Gameplay.PointsKill, v => Cfg.Gameplay.PointsKill = v, 10, 0, 10000 ),
				Int( "Points / headshot", () => Cfg.Gameplay.PointsKillHeadshot, v => Cfg.Gameplay.PointsKillHeadshot = v, 10, 0, 10000 ),
				Int( "Points / knife", () => Cfg.Gameplay.PointsKillKnife, v => Cfg.Gameplay.PointsKillKnife = v, 10, 0, 10000 ),
			},

			Specials => new()
			{
				Bool( "Special rounds", () => Cfg.Specials.Enabled,
					v => Cfg.Specials.Enabled = v, "off = every round is a normal wave" ),
				Int( "First round", () => Cfg.Specials.FirstRound,
					v => Cfg.Specials.FirstRound = v, 1, 1, 200,
					"5 is the original's first dog round" ),
				Int( "Every N rounds", () => Cfg.Specials.RoundInterval,
					v => Cfg.Specials.RoundInterval = v, 1, 1, 100,
					"5 = rounds 5, 10, 15..." ),
				Int( "Count per round", () => Cfg.Specials.CountPerRound,
					v => Cfg.Specials.CountPerRound = v, 1, 1, 200,
					"how many arrive in total" ),
				Int( "Max alive", () => Cfg.Specials.MaxAlive,
					v => Cfg.Specials.MaxAlive = v, 1, 1, 100,
					"how many at once — the rest queue" ),
				Num( "Spawn delay", () => Cfg.Specials.SpawnDelay,
					v => Cfg.Specials.SpawnDelay = v, 0.05f, 0.05f, 10f,
					"seconds between arrivals · 1.25 = the original solo" ),
				Num( "Health x", () => Cfg.Specials.HealthMultiplier,
					v => Cfg.Specials.HealthMultiplier = v, 0.1f, 0.1f, 20f,
					"on top of the round curve and the variant's own" ),
				Num( "Speed x", () => Cfg.Specials.SpeedMultiplier,
					v => Cfg.Specials.SpeedMultiplier = v, 0.1f, 0.1f, 5f,
					"on top of the variant's own" ),
				Bool( "Fog", () => Cfg.Specials.Fog, v => Cfg.Specials.Fog = v,
					"the hellhound round's haze · off = the round looks like any other" ),
				Bool( "Announcer", () => Cfg.Specials.Announce, v => Cfg.Specials.Announce = v,
					"the call three seconds in · off = the round starts in silence" ),
				Str( "Loop sound", () => Cfg.Specials.Loop ?? "", v => Cfg.Specials.Loop = v,
					"a cue under sounds/nz/, looped while the round lasts · blank = none" ),
				Bool( "Round sounds", () => Cfg.Specials.RoundSounds, v => Cfg.Specials.RoundSounds = v,
					"the round's start and end sounds · off = the special round has only its own" ),

				new( "Spawners placed", () => ActiveConfig.Current.SpecialSpawns.Count.ToString(),
					_ => { }, "zero = special rounds never trigger", null, RowKind.Info ),
			},

			Pap => PapRows(),

			Ammo => AmmoRows(),

			Rooms => RoomRows(),

			Bosses => new()
			{
				Bool( "Boss rounds", () => Cfg.Bosses.Enabled,
					v => Cfg.Bosses.Enabled = v, "off = bosses never scheduled" ),
				Int( "First round", () => Cfg.Bosses.FirstRound,
					v => Cfg.Bosses.FirstRound = v, 1, 1, 200,
					"10 is ours — nZombies has no boss schedule to copy" ),
				Int( "Every N rounds", () => Cfg.Bosses.RoundInterval,
					v => Cfg.Bosses.RoundInterval = v, 1, 1, 100,
					"10 = rounds 10, 20, 30..." ),
				Int( "Count per round", () => Cfg.Bosses.CountPerRound,
					v => Cfg.Bosses.CountPerRound = v, 1, 1, 20,
					"before the ramp below" ),

				// ⚠️ THE RAMP IS THREE FIELDS RATHER THAN A CURVE, on purpose. "From round 30,
				// two instead of one" is something a mapper can state and this panel can show;
				// `round / 15` is a formula whose behaviour has to be worked out by hand first.
				Int( "Ramp from round", () => Cfg.Bosses.RampFromRound,
					v => Cfg.Bosses.RampFromRound = v, 1, 0, 200,
					"0 = never ramp" ),
				Int( "Ramp adds", () => Cfg.Bosses.CountStep,
					v => Cfg.Bosses.CountStep = v, 1, 0, 10,
					"extra bosses per step" ),
				Int( "Ramp every N", () => Cfg.Bosses.RampInterval,
					v => Cfg.Bosses.RampInterval = v, 1, 0, 100,
					"0 = step once and stop" ),

				Int( "Max alive", () => Cfg.Bosses.MaxAlive,
					v => Cfg.Bosses.MaxAlive = v, 1, 1, 20,
					"caps the ramp too" ),
				Num( "Spawn delay", () => Cfg.Bosses.SpawnDelay,
					v => Cfg.Bosses.SpawnDelay = v, 0.25f, 0.25f, 30f,
					"seconds between arrivals when more than one is due" ),

				// ⛔ THE PREVIEW IS THE POINT OF THE PAGE. Seven numbers interact here, and a
				// mapper cannot tell from the fields alone which rounds they produce or when two
				// start arriving. `Preview` runs the real `IsBossRound` and `CountForRound`, so what
				// is shown cannot drift from what the schedule does.
				new( "Arrives on", () => Cfg.BossPreview(),
					_ => { }, "what these numbers actually produce — special rounds are skipped", null, RowKind.Info ),

				new( "Spawners placed", () => ActiveConfig.Current.BossSpawns.Count.ToString(),
					_ => { }, "zero = bosses never appear, whatever the schedule says", null, RowKind.Info ),

				// ⛔ THE COLLISION WARNING IS THE ONE ROW THAT CATCHES A SILENT MISCONFIGURATION.
				// Bosses every 5 from round 10 against specials every 5 from round 5 means every due
				// round is a dog round and no boss ever arrives — with every field looking sensible.
				new( "Schedule clash", () => ActiveConfig.Current.BossNeverArrives()
						? "YES — every due round is a special round"
						: "none",
					_ => { }, "a boss never shares a round with a dog wave", null, RowKind.Info ),
			},

			WeaponStats => WeaponRows(),

			_ => Empty,
		};
	}

	/// <summary>
	/// Stats for the held weapon, edited through <see cref="WeaponTuning"/>.
	///
	/// ⛔ THE WEAPON IS RESOLVED INSIDE EACH GETTER, NEVER CAPTURED. Capturing it
	/// once when the list is built means every row keeps editing the gun you were
	/// holding at the time — and since the rows survive a weapon switch, the panel
	/// would show one weapon's name while writing another's damage. Silent, and
	/// exactly the class of bug that has cost this project a free Pack-a-Punch level
	/// and an unbuyable wall gun.
	///
	/// ⛔ SIXTEEN ROWS, NOT NINETY. `ShootInfo` exposes ~90 numeric properties and
	/// listing them all makes the panel a scrolling wall in which nothing is findable.
	/// These are the ones that decide how a gun FEELS; `nz_wep_fields` reaches every
	/// one of the rest, which is the right place for the long tail.
	///
	/// ⚠️ THE PANEL SAVES ON EVERY EDIT, unlike the console, which needs an explicit
	/// `nz_wep_save`. Dragging a stepper is a deliberate act with the value visible
	/// in front of you; a scripted console sweep is not, and wants to try numbers
	/// without committing them. `nz_wep_reset` undoes either.
	/// </summary>
	static List<Row> WeaponRows()
	{
		var held = WeaponTuningCommands.Held();

		if ( !held.IsValid() )
			return new() { Str( "No weapon", () => "hold one to edit it", _ => { } ) };

		float Get( string f )
		{
			var w = WeaponTuningCommands.Held();
			return w.IsValid() ? WeaponTuning.Get( w, f ) ?? 0f : 0f;
		}

		void Put( string f, float v )
		{
			var w = WeaponTuningCommands.Held();
			if ( !w.IsValid() ) return;

			WeaponTuning.Set( w, f, v );
			WeaponTuning.Current.Save();
		}

		// ⚠️ `min` IS A PARAMETER BECAUSE -1 IS MEANINGFUL. `ReloadEmptyTime` uses -1
		// for "same as a normal reload", and a floor of 0 would silently turn that
		// sentinel into a zero-second reload the first time the row was nudged.
		Row Stat( string label, string field, float step, float max, string hint = "",
			float min = 0f )
			=> Num( label, () => Get( field ), v => Put( field, v ), step, min, max, hint );

		return new()
		{
			Stat( "Damage", "Damage", 1f, 10000f, "per bullet, before multipliers" ),
			Stat( "RPM", "RPM", 10f, 3000f, "rounds per minute" ),
			Stat( "Clip size", "ClipSize", 1f, 500f ),
			Stat( "Bullets / shot", "Bullets", 1f, 64f, "pellets — 1 for everything but shotguns" ),
			Stat( "Ammo / shot", "AmmoPerShot", 1f, 20f ),
			Stat( "Spread", "Spread", 0.005f, 5f, "base inaccuracy" ),
			Stat( "Recoil", "Recoil", 0.05f, 50f ),
			Stat( "Recoil up", "RecoilUp", 0.05f, 50f ),
			Stat( "Recoil side", "RecoilSide", 0.05f, 50f ),
			Stat( "Head mult", "HeadMultiplier", 0.1f, 20f, "headshot damage" ),
			Stat( "Limb mult", "LimbMultiplier", 0.1f, 20f ),
			Stat( "Falloff start", "FalloffStart", 10f, 10000f, "units before damage drops" ),
			Stat( "Falloff end", "FalloffEnd", 10f, 20000f ),
			Stat( "Falloff mult", "FalloffMultiplier", 0.05f, 5f, "damage left at max range" ),
			Stat( "Force", "Force", 1f, 1000f, "ragdoll push" ),

			// ⚠️ THREE SEPARATE RELOAD NUMBERS, because they do different jobs and
			// tuning one without the others gives an animation and a gameplay timing
			// that disagree. `ReloadTime` is when you can shoot again, `ReloadSpeed`
			// scales the ANIMATION, and `ReloadEmptyTime` covers the longer
			// from-empty reload.
			Stat( "Reload time", "ReloadTime", 0.05f, 15f, "seconds until you can fire" ),
			Stat( "Reload empty", "ReloadEmptyTime", 0.05f, 15f,
				"-1 = same as reload", -1f ),
			Stat( "Reload speed", "ReloadSpeed", 0.05f, 5f, "animation rate ×" ),

			Stat( "Draw time", "DrawTime", 0.05f, 5f, "seconds to bring up" ),
		};
	}

	static readonly List<Row> Empty = new();

	// ══ the buildable's pieces ═════════════════════════════════════
	//
	// ⚠️ THE LABEL CARRIES BOTH THE NUMBER AND THE NAME. `Pick` stores a STRING, and a chip
	// reading only "core" would leave the console command — which takes 1..3 — with nothing to
	// match; one reading only "2" would put the mapper back to guessing which piece that is.

	static string[] BuildPieces
		=> Enumerable.Range( 1, BuildParts.Count ).Select( BuildPiece ).ToArray();

	static string BuildPiece( int n ) => $"{n} · {BuildParts.Name( n )}";

	/// <summary>Read the piece number back off a chip. Falls back to 1 rather than throwing.</summary>
	static int BuildPieceOf( string label )
	{
		var head = (label ?? "").Split( ' ' )[0];
		return int.TryParse( head, out var n ) && BuildParts.Valid( n ) ? n : 1;
	}

	/// <summary>"3 placed" or "3 placed — MISSING claws", which is an unwinnable map.</summary>
	static string BuildPlacedSummary()
	{
		var list = ActiveConfig.Current?.BuildParts;
		var n = list?.Count ?? 0;

		var absent = Enumerable.Range( 1, BuildParts.Count )
			.Where( p => list is null || !list.Any( s => s.Part == p ) )
			.Select( BuildParts.Name )
			.ToList();

		return absent.Count == 0
			? $"{n} placed"
			: $"{n} placed — MISSING {string.Join( ", ", absent )}";
	}

	/// <summary>Heading for the panel.</summary>
	public static string TitleFor( string context ) => context switch
	{
		Players => "Player settings",
		Zombies => "Zombie settings",
		Gameplay => "Gameplay settings",
		Pap => "Pack-a-Punch settings",
		Ammo => "Ammo box settings",
		Rooms => "Room names",

		// ⚠️ Names the WEAPON, not the panel. "Weapon stats" on a panel editing the
		// M1911 leaves you checking which gun you are holding; the title answering
		// that is the whole point of a per-weapon editor.
		WeaponStats => WeaponTuningCommands.Held() is { IsValid: true } w
			? $"{w.DisplayName} — stats"
			: "Weapon stats",
		_ => NZTools.NameFor( context ),
	};

	/// <summary>⚠️ Only block-mode barriers are drawn with a material, so this
	/// list is short on purpose — a long picker for something four barriers use
	/// is worse than typing nz_debris_mat.</summary>
	/// <summary>
	/// The mystery box's rows: price, whether the box may start here, and one
	/// toggle per weapon pack.
	///
	/// ⚠️ Price and start-here are per-SPOT (authored into the next placement);
	/// the pack list is per-MAP, because a box that rolled different weapons
	/// depending on which spot it currently sat in would be indistinguishable
	/// from a bug.
	/// </summary>
	/// <summary>The two answers to "how is a failed attempt locked out". Order matters —
	/// `MapEditor.EggRetryMode` compares against these by index.</summary>
	public static readonly string[] RetryModes = { "Seconds", "Next round" };

	/// <summary>
	/// The rows every easter-egg interactable shares, down to and including its trigger count.
	///
	/// ⛔ ONE SET OF FIELDS FOR EVERY INTERACTABLE, which is what makes a mixed step
	/// practical: type a step number and its flags once, place a button, switch to the
	/// shootable, place that — both land in the same step. Parallel `Press*` and `Shoot*` fields
	/// would have made building one step out of two kinds of part a retyping exercise, and the
	/// spec's own example (four statues) is a single-kind step only by coincidence.
	///
	/// ⚠️ The count's LABEL differs because "press count" and "hit count" are the same
	/// setting described in the words of the thing in front of you.
	/// </summary>
	static List<Row> EggHeadRows( MapEditor e, string countLabel, string countHint )
		=> new()
		{
			// ⛔ THE SAME "Flag" ROW EVERY OTHER PLACEABLE HAS. It is the DOOR pool: the
			// thing is inert until that flag opens, the same way a perk machine or a spawner
			// behind the same door is. The rows under it are the EGG pool — a separate
			// namespace, where a flag named "2" is an unrelated value to a door flag named "2".
			Str( "Flag", () => e.SpawnLink, v => e.SpawnLink = v,
				"door flag · blank or 0 = always there" ),

			// ⛔ THE EGG'S OWN STEP NUMBER, and the first thing on the egg side of this panel.
			// It says WHICH step of the easter egg this is; everything else carrying the same
			// number is part of that same step and lands, resets, and vanishes with it.
			Int( "Easter egg step", () => e.EggStepNumber, v => e.EggStepNumber = v, 1, 0, 99,
				"the egg's step · everything sharing it completes as ONE unit · 0 = on its own" ),

			Str( "Required flags", () => e.EggRequired, v => e.EggRequired = v,
				"comma-separated · ALL must be set · blank = open from the start" ),
			Str( "Reward flags", () => e.EggReward, v => e.EggReward = v,
				"comma-separated · set when it completes" ),
			Str( "Excluded flags", () => e.EggExcluded, v => e.EggExcluded = v,
				"comma-separated · ANY set locks this out" ),
			Str( "Opens door flag", () => e.EggGeneralReward, v => e.EggGeneralReward = v,
				"general flag opened on completion · blank = none" ),

			Int( countLabel, () => e.EggRepeat, v => e.EggRepeat = v, 1, 0, 50, countHint ),
		};

	/// <summary>The rows every easter-egg interactable shares below its own settings: the step
	/// clock, the retry rule, the unlock delay, the round reset and the weapon/perk gate.</summary>
	static void AddEggTailRows( MapEditor e, List<Row> rows )
	{
		// ⛔ THE STEP'S CLOCK, NOT THE PART'S. It starts on the first trigger ANYWHERE in the
		// step and every member is failed when it runs out — so on a four-button step this is
		// "get all four within N seconds", which is the spec's Time window.
		rows.Add( Num( "Step time limit", () => e.EggWindow, v => e.EggWindow = v, 1f, 0f, 300f,
			"seconds to finish the WHOLE step once anyone starts it · 0 = no limit" ) );

		rows.Add( Pick( "Retry after failing", RetryModes, () => e.EggRetryMode,
			v => e.EggRetryMode = v, "a clock, or the round turn · not both" ) );

		// ⚠️ ONLY UNDER "Seconds". Under "Next round" there is no number to set: the wait is
		// however long the round lasts, which is why it is a choice and not a value.
		if ( e.EggRetryMode != RetryModes[1] )
			rows.Add( Num( "Retry delay", () => e.EggCooldown, v => e.EggCooldown = v,
				0.5f, 0f, 120f, "seconds after a FAILED attempt · 0 = retry at once" ) );

		rows.Add( Num( "Unlock delay", () => e.EggTimed, v => e.EggTimed = v, 0.5f, 0f, 300f,
			"seconds after its Required flags are met" ) );

		// ⚠️ A DIFFERENT QUESTION TO "Retry after failing", and the hint has to say so. This
		// one zeroes PROGRESS at the round turn whether or not anything failed.
		rows.Add( Bool( "Reset each round", () => e.EggResetOnRound,
			v => e.EggResetOnRound = v, "progress zeroes on the round turn, failed or not" ) );

		// ⚠️ THE SPEC'S STATE TOGGLE, AND IT HAD NO ROW UNTIL THE SHOOTABLE NEEDED ONE. Both
		// fields existed in config and could only be set by hand — the same gap the step number
		// had.
		rows.Add( Str( "Needs weapon", () => e.EggWeapon, v => e.EggWeapon = v,
			"part of the class name, e.g. awm · blank = any" ) );

		rows.Add( Str( "Needs perk", () => e.EggPerk, v => e.EggPerk = v,
			"perk id · blank = none needed" ) );
	}

	static List<Row> PressRows( MapEditor e )
	{
		var rows = EggHeadRows( e, "Press count",
			"EXACT · overshooting FAILS the attempt · 0 or 1 = one press" );

		rows.Add( Num( "Hold time", () => e.PressHold, v => e.PressHold = v, 0.25f, 0f, 60f,
			"0 = a tap · cancels on move or damage" ) );

		AddEggTailRows( e, rows );

		rows.Add( new( "Placed", () => (ActiveConfig.Current?.Pressables.Count ?? 0).ToString(),
			_ => { }, "LMB on a wall or floor · RMB removes", null, RowKind.Info ) );

		return rows;
	}

	/// <summary>
	/// The shootable's rows.
	///
	/// ⚠️ NO HOLD ROW. The spec drops it: there is no hold-the-shot-key equivalent for a
	/// ranged trigger, and a setting that could never do anything is worse than a missing one.
	/// </summary>
	static List<Row> ShootRows( MapEditor e )
	{
		var rows = EggHeadRows( e, "Hit count",
			"EXACT · overshooting FAILS the attempt · 0 or 1 = one hit" );

		// ⚠️ ANY TIER. "A specific packed gun" is this plus the Needs weapon row below, which
		// is why there is no tier number here to disagree with it.
		rows.Add( Bool( "Needs Pack-a-Punch", () => e.ShootRequiresPaP,
			v => e.ShootRequiresPaP = v, "any tier · name a gun in Needs weapon as well if you want one" ) );

		AddEggTailRows( e, rows );

		rows.Add( new( "Placed", () => (ActiveConfig.Current?.Shootables.Count ?? 0).ToString(),
			_ => { }, "LMB on a wall or floor · RMB removes · shoot it to trigger", null, RowKind.Info ) );

		return rows;
	}

	static List<Row> BoxRows( MapEditor e )
	{
		var rows = new List<Row>
		{
			Int( "Price", () => e.BoxPrice, v => e.BoxPrice = v, 50, 0, 10000,
				"the original charges 950" ),
			Bool( "Start here", () => e.BoxCanStart, v => e.BoxCanStart = v,
				"off = the box can move here, but never begins here" ),
		};

		// ⚠️ EMPTY MEANS ALL, so an untouched map rolls everything — see
		// MapConfig.BoxPacks. Turning one pack ON therefore has to switch the
		// list from "implicitly all" to "explicitly these", which is why the
		// getter reports empty as all-on.
		foreach ( var pack in WeaponLibrary.Packs )
		{
			var name = pack;
			rows.Add( Bool( $"Pack: {name}",
				() =>
				{
					var list = ActiveConfig.Current.BoxPacks;
					return list.Count == 0 || list.Contains( name );
				},
				v =>
				{
					var list = ActiveConfig.Current.BoxPacks;

					// First touch on an all-on map: materialise the implicit list
					// so turning one OFF does not read as turning all off.
					if ( list.Count == 0 )
						list.AddRange( WeaponLibrary.Packs );

					if ( v ) { if ( !list.Contains( name ) ) list.Add( name ); }
					else list.Remove( name );

					Log.Info( $"[nz] box packs: {(list.Count == 0 ? "(none — falls back to all)" : string.Join( ", ", list ))}"
						+ $" — {MysteryBox.Pool().Count} weapon(s) in the pool" );
				},
				"which packs the box can roll" ) );
		}

		return rows;
	}

	/// <summary>
	/// Pad models the teleporter tool offers.
	///
	/// ⚠️ THE EMPTY STRING IS A REAL CHOICE HERE, not a "none" placeholder — it selects the
	/// built box, which is the only pad whose size the Pad size row can actually change.
	/// </summary>
	/// <summary>
	/// Box models the soul box tool offers.
	///
	/// ⚠️ THE EMPTY STRING IS A REAL CHOICE, not a "none" placeholder — it selects the built box,
	/// which is the only one whose Box size and height rows do anything.
	/// </summary>
	static readonly string[] SoulBoxModels =
	{
		"models/zmb/bo2/tomb/zm_tm_soul_box.vmdl",
		"",
	};

	static readonly string[] TeleporterModels =
	{
		"models/moo/_codz_ports_props/t5/zm/zombie_teleporter_pad/moo_codz_zm_teleporter_pad.vmdl",
		"",
	};

	/// <summary>Where a mapper drops clue art. Scanned for the picker.</summary>
	public const string ClueImageFolder = "materials/clues";

	/// <summary>
	/// The images the clue picker offers: every PNG under <see cref="ClueImageFolder"/>.
	///
	/// ⛔ A DEDICATED FOLDER, NOT THE WHOLE PROJECT, AND THE NUMBERS ARE WHY. Scanning
	/// `materials/` for compiled textures returned 3,899 entries — every one of them a
	/// `*_png_<hash>.generated.vtex` produced by material compilation, because this project
	/// contains ZERO authored .vtex files. Scanning the SOURCE images instead returns 6,515
	/// PNGs, nearly all of them wall and weapon textures. Neither is a picker; both are a
	/// haystack.
	///
	/// ⚠️ SO THE CONVENTION IS THE FEATURE. Art dropped in `materials/clues/` appears in the
	/// list; anything else in the project is still reachable by typing its path, and
	/// `nz_clue_images &lt;filter&gt;` searches the whole tree to find one.
	///
	/// ⚠️ CACHED, because it walks directories. `nz_clue_images` drops the cache, so art
	/// added while the game is running shows up without a restart.
	/// </summary>
	static string[] _clueImages;

	public static void ForgetClueImages() => _clueImages = null;

	public static string[] ClueImages()
	{
		if ( _clueImages is not null ) return _clueImages;

		var found = new List<string> { "" };

		try
		{
			if ( FileSystem.Mounted.DirectoryExists( ClueImageFolder ) )
			{
				foreach ( var f in FileSystem.Mounted.FindFile( ClueImageFolder, "*.png", true ) )
					found.Add( $"{ClueImageFolder}/{f}" );
			}
		}
		catch ( System.Exception e )
		{
			// ⚠️ REPORTED, NOT SWALLOWED. An empty picker and a picker that threw look
			// identical from the tool panel.
			Log.Warning( $"[nz-clue] could not scan '{ClueImageFolder}': {e.Message}" );
		}

		_clueImages = found.Distinct().OrderBy( x => x ).ToArray();
		return _clueImages;
	}

	/// <summary>
	/// Every `.sound` event in the project, for the sound tool's picker.
	///
	/// ⛔ SCANNED, NOT HAND-LISTED. The project ships over 2,000 sound events and gains more every
	/// time somebody imports one — a literal array here would be stale the first time it was used.
	///
	/// ⚠️ CACHED, BECAUSE `Pick` RESOLVES ITS OPTIONS EVERY TIME THE ROW IS DRAWN. Walking the
	/// whole resource library once per frame while a panel is open is real work for a list that
	/// changes when somebody imports a file, which is not often.
	/// </summary>
	static string[] _soundEvents;

	/// <summary>
	/// Where a map-placeable sound has to live to be offered.
	///
	/// ⛔ A FOLDER, BECAUSE THE UNFILTERED LIST IS 2,189 ENTRIES AND THEREFORE NOT A PICKER. Every
	/// weapon discharge, every announcer line and every footstep in the project is a SoundEvent, and
	/// none of them is something you place in a map. Scrolling past all of them to reach the one
	/// ambience loop is the same objection `RowKind.Options` already records about stepping through
	/// 135 weapons: that is not selection, it is scrolling.
	///
	/// ⚠️ AND A FOLDER RATHER THAN A HARD-CODED LIST, so importing another one makes it appear.
	/// `Tools/import_sound.py` writes here by default for exactly that reason — a literal array
	/// would have to be edited by hand every time, which is how a picker silently goes stale.
	/// </summary>
	public const string AmbienceFolder = "sounds/nz/ambience/";

	static string[] SoundEvents()
	{
		if ( _soundEvents is not null ) return _soundEvents;

		var found = ResourceLibrary.GetAll<SoundEvent>()
			.Select( x => x.ResourcePath )
			.Where( x => !string.IsNullOrWhiteSpace( x )
				&& x.StartsWith( AmbienceFolder, System.StringComparison.OrdinalIgnoreCase ) )
			.Distinct()
			.OrderBy( x => x )
			.ToList();

		// ⚠️ AN EMPTY ENTRY FIRST, so "no sound" is pickable. A picker that cannot express the
		// default leaves a placed spot permanently stuck with whatever was chosen once.
		found.Insert( 0, "" );

		_soundEvents = found.ToArray();
		return _soundEvents;
	}

	/// <summary>`nz_sound_refresh` — rescan after importing a file. See SoundEvents.</summary>
	[ConCmd( "nz_sound_refresh" )]
	public static void RefreshSounds()
	{
		_soundEvents = null;
		Log.Info( $"[nz-sound] rescanned — {SoundEvents().Length - 1} placeable sound(s) in "
			+ AmbienceFolder
			// ⚠️ NAMES THE FOLDER, because an empty picker after importing a file is otherwise
			// indistinguishable from a broken scan — and the answer is nearly always that the file
			// landed somewhere else.
			+ "   (only events in that folder are offered)" );
	}

	static readonly string[] Materials =
	{
		"materials/concrete/concretewall052a.vmat",
		"materials/wood/woodwall009a.vmat",
		"materials/metal/metalwall048a.vmat",
		"",
	};

	/// <summary>
	/// Surfaces offered for a damage wall, lava first.
	///
	/// ⛔ A SEPARATE LIST FROM `Materials`, WHICH IS BRICK AND CONCRETE. Those are the surfaces
	/// a BOUNDARY wants — something that reads as part of the building. A damage wall wants the
	/// opposite: a surface that says "do not stand here" before anyone has taken a tick of damage.
	///
	/// ⚠️ `materials/nz/lava.vmat` IS THE ONE THIS FEATURE WAS BUILT FOR. It is procedural and
	/// world-space — see `Assets/shaders/lava.shader` — which is what a wall extruded from an
	/// arbitrary clicked footprint can actually wear, because such a mesh has no UVs worth using.
	/// It also animates off `g_flTime`, so it keeps flowing in the editor with the game stopped.
	/// </summary>
	static readonly string[] DamageWallMaterials =
	{
		"materials/nz/lava.vmat",
		"materials/dev/reflectivity_50.vmat",
		"materials/concrete/concretewall052a.vmat",
		"materials/metal/metalwall048a.vmat",
		"",
	};

	/// <summary>
	/// Pack-a-Punch: how many tiers, and per tier a price and a damage multiplier.
	///
	/// ⛔ BUILT IN A LOOP, NOT WRITTEN OUT FIVE TIMES. Ten hand-written rows is ten places to paste
	/// the wrong index into, and the off-by-one would show as "MK4 costs what MK5 should" — a bug
	/// that looks like a pricing decision rather than a typo.
	///
	/// ⚠️ ALL SIX TIERS ARE ALWAYS SHOWN, even with Tiers set to 2 — MK6, the Easter egg's, too. Hiding the rows would mean
	/// lowering the cap silently discarded the numbers above it, and raising it again handed back
	/// whatever was there before with no way to have checked first. The label marks which are live.
	///
	/// ⚠️ THE MULTIPLIER IS THE TOTAL, matching `PapSettings.Multipliers` and the `nz_pap_status`
	/// readout — MK3 is 15.63, not the 2.5 that got it there.
	/// </summary>
	static List<Row> PapRows()
	{
		var rows = new List<Row>
		{
			Int( "Tiers", () => Cfg.Pap.Tiers, v => Cfg.Pap.Tiers = v, 1, 1, PapSettings.MaxMapTiers,
				"how many MK levels a weapon can buy" ),

			// ⚠️ THE CAMO THIS MAP'S PACKED WEAPONS WEAR (2026-09-28, basalt's hexagons). "default" is blank in the config,
			// which is Crazy Place; `nz_camo_set` still overrides it for a session.
			Pick( "Camo", () => new[] { CamoDefault }.Concat( PapCamo.Camos.Select( c => c.Id ) ).ToArray(),
				() => string.IsNullOrWhiteSpace( Cfg.Pap.Camo ) ? CamoDefault : Cfg.Pap.Camo,
				v => Cfg.Pap.Camo = v == CamoDefault ? "" : v,
				"what a packed weapon wears on this map" ),
		};

		// ⚠️ `i` COPIED INTO A LOCAL. The lambdas below outlive this iteration, and closing over the
		// loop variable directly would give every row the final index — all five editing MK5.
		for ( int tier = 1; tier <= PapSettings.MaxTiers; tier++ )
		{
			int i = tier - 1;

			rows.Add( Int( $"MK{tier} cost", () => Cost( i ), v => SetCost( i, v ),
				500, 0, 1000000, TierNote( tier, "points" ) ) );

			rows.Add( Num( $"MK{tier} damage x", () => Mult( i ), v => SetMult( i, v ),
				0.25f, 0.1f, 1000f, TierNote( tier, "total, vs unpacked" ) ) );
		}

		return rows;
	}

	/// <summary>The "Camo" row's choice for a config that names none: the game's own, Crazy Place.</summary>
	const string CamoDefault = "default";

	/// <summary>A tier row's note: live; above the map's cap; or MK6, which no map sells of its own — basalt's Easter egg opens it.</summary>
	static string TierNote( int tier, string live )
		=> tier > PapSettings.MaxMapTiers ? "basalt's Easter egg opens it"
			: tier > Cfg.Pap.Tiers ? "above the tier cap — unused"
			: live;

	// ⛔ THE ARRAYS ARE GROWN ON WRITE, NOT ASSUMED TO BE FULL LENGTH. A config deserialized from an
	// older save carries a three-entry Costs, and `Costs[4] = v` on it throws inside the panel's
	// click handler — which surfaces as a row that silently does nothing.
	//
	// ⚠️ AND THE ROWS READ WHAT THE MACHINE WILL USE, `CostAt` and `MultiplierAt`: a saved array's missing tiers as they
	// ship — MK6 on basalt's save, which the raw array would have shown as 0.
	static int Cost( int i )
		=> Cfg.Pap.CostAt( i );

	static float Mult( int i )
		=> Cfg.Pap.MultiplierAt( i + 1 );

	static void SetCost( int i, int v )
	{
		Cfg.Pap.Costs = Grow( Cfg.Pap.Costs, PapSettings.DefaultCosts );
		Cfg.Pap.Costs[i] = v;
	}

	static void SetMult( int i, float v )
	{
		Cfg.Pap.Multipliers = Grow( Cfg.Pap.Multipliers, PapSettings.DefaultMultipliers );
		Cfg.Pap.Multipliers[i] = v;
	}

	/// <summary>
	/// Pad an array to the shipped one's length, keeping what is already there and filling the rest AS IT SHIPS.
	///
	/// ⛔ NOT WITH ZEROS. Basalt's save holds five prices and five multipliers; padded with a 0 for MK6 on an edit to any
	/// other tier, the Easter egg's MK6 would read as nothing to upgrade — and a 0 ammo price is a free refill.
	/// </summary>
	static T[] Grow<T>( T[] src, T[] shipped )
	{
		if ( src is not null && src.Length >= shipped.Length ) return src;

		var next = new T[shipped.Length];
		for ( int i = 0; i < next.Length; i++ )
			next[i] = src is not null && i < src.Length ? src[i] : shipped[i];
		return next;
	}

	/// <summary>
	/// Ammo box: a base price per Pack-a-Punch tier, plus the per-round escalation.
	///
	/// ⚠️ SEVEN PRICE ROWS FOR SIX TIERS. "Unpacked" is a real and common case — most of a game is
	/// spent holding something that has never been packed — so the array is indexed by LEVEL, not by
	/// level-1, and the extra row is the point rather than an off-by-one.
	/// </summary>
	static List<Row> AmmoRows()
	{
		var rows = new List<Row>();

		// ⚠️ `i` COPIED INTO A LOCAL, as in PapRows. The lambdas outlive the iteration and closing
		// over the loop variable gives every row the final index.
		for ( int level = 0; level <= PapSettings.MaxTiers; level++ )
		{
			int i = level;
			var label = i == 0 ? "Unpacked cost" : $"MK{i} cost";

			rows.Add( Int( label, () => AmmoBase( i ), v => SetAmmoBase( i, v ), 100, 0, 1000000,
				i == 0 ? "no Pack-a-Punch" : "points, first use of a round" ) );
		}

		rows.Add( Num( "Repeat x", () => Cfg.AmmoBox.RepeatMultiplier,
			v => Cfg.AmmoBox.RepeatMultiplier = v, 0.05f, 1f, 10f,
			"compounds per use in the same round · 1 = flat" ) );

		rows.Add( Int( "Price cap", () => Cfg.AmmoBox.MaxPrice, v => Cfg.AmmoBox.MaxPrice = v,
			500, 0, 1000000, "0 = uncapped" ) );

		rows.Add( new( "Boxes placed", () => (ActiveConfig.Current?.AmmoBoxes.Count ?? 0).ToString(),
			_ => { }, "nz_ammobox to drop one", null, RowKind.Info ) );

		return rows;
	}

	/// <summary>
	/// The rooms' names: flag 0, where a game starts, then a row per door flag the map's barriers carry. Each is what the top
	/// left of the HUD says once a player walks through where one of that flag's barriers stood (`RoomNames`); a blank one
	/// changes nothing. The debris tool's "Room name" row edits the same names, one flag at a time.
	/// </summary>
	static List<Row> RoomRows()
	{
		var rows = new List<Row>
		{
			Str( "Start (flag 0)", () => RoomNames.NameOf( DoorLinks.Unlinked ),
				v => RoomNames.SetName( DoorLinks.Unlinked, v ), "on screen as a game starts · blank = none until a door" ),
			Str( "Start subtitle", () => RoomNames.SubtitleFor( DoorLinks.Unlinked ),
				v => RoomNames.SetSubtitle( DoorLinks.Unlinked, v ), "under the name, on each player's first visit · the start must be named" ),
		};

		// ⚠️ `link` IS EACH PASS'S OWN — a foreach variable is fresh every time round, so each row keeps its own flag
		foreach ( var (link, barriers) in RoomNames.MapFlags() )
		{
			rows.Add( Str( $"Flag {link}", () => RoomNames.NameOf( link ), v => RoomNames.SetName( link, v ),
				$"{barriers} barrier{(barriers == 1 ? "" : "s")} · blank = walking through changes nothing" ) );

			// ⚠️ AND ITS SUBTITLE (2026-09-27): the line under the name on each player's first visit to the room, this game
			rows.Add( Str( $"Flag {link} subtitle", () => RoomNames.SubtitleFor( link ), v => RoomNames.SetSubtitle( link, v ),
				"under the name, on each player's first visit · the flag must be named" ) );

			// ⚠️ AND WHAT WAITS INSIDE (2026-09-28): an enemy at each of the flag's special spawns, the first time it opens (`FlagAmbush`)
			rows.Add( Str( $"Flag {link} ambush", () => RoomNames.AmbushFor( link ), v => RoomNames.SetAmbush( link, v ),
				$"{FlagAmbush.SpawnsOf( link ).Count} special spawn(s) · {string.Join( ", ", SpecialEnemies.Names )} · blank = none"
				+ " · the flag must be named" ) );
		}

		// ⚠️ AND EACH TELEPORTER. A teleport crosses no doorway, so a pad says for itself which room its riders arrive in.
		// ⚠️ NOT A `Str` ROW: that turns a typed 0 into blank, and here 0 — the start — and blank — the name stays — differ.
		var pads = Cfg.Teleporters ?? new List<TeleporterSpot>();
		for ( int n = 0; n < pads.Count; n++ )
		{
			var pad = pads[n];
			rows.Add( new( $"Pad {n} arrives in", () => RoomNames.ArrivalText( pad ), _ => { },
				$"teleporter at {pad.A.x:0}, {pad.A.y:0} · a flag, 0 = the start · blank = the name stays",
				v => { RoomNames.SetArrival( pad, v ); Changed(); }, RowKind.Text ) );
		}

		// ⚠️ AND EACH ROOM ZONE, drawn with the Room zone tool — walking into one names the room, wherever it is
		var zones = Cfg.RoomZones ?? new List<RoomZone>();
		for ( int n = 0; n < zones.Count; n++ )
		{
			var i = n;
			var zone = zones[n];
			if ( zone is null ) continue;

			rows.Add( Str( $"Zone {n}", () => zone.Name ?? "", v => RoomZones.Rename( i, v ),
				$"drawn at {zone.Position.x:0}, {zone.Position.y:0} · walking in shows it · blank = names nothing" ) );

			// ⚠️ AND ITS SUBTITLE (2026-09-27), for a zone whose name no flag's room has — a flag's of the same name speaks first
			rows.Add( Str( $"Zone {n} subtitle", () => zone.Subtitle ?? "", v => { zone.Subtitle = v?.Trim() ?? ""; RoomZones.Sync(); },
				"under the name on each player's first visit · a flag's room of the same name speaks first" ) );
		}

		rows.Add( new( "Now in", () => RoomNames.Describe, _ => { },
			"this machine's player · nz_rooms lists every flag", null, RowKind.Info ) );

		return rows;
	}

	// ⚠️ WHAT THE BOX WILL CHARGE — a saved array's missing levels as they ship, MK6 on basalt's save.
	static int AmmoBase( int i )
		=> Cfg.AmmoBox.BaseFor( i );

	static void SetAmmoBase( int i, int v )
	{
		// ⚠️ GROWN TO MaxTiers + 1, not MaxTiers — index 0 is the unpacked price; the shipped array is that long.
		Cfg.AmmoBox.BasePrices = Grow( Cfg.AmmoBox.BasePrices, AmmoBoxSettings.DefaultBasePrices );
		Cfg.AmmoBox.BasePrices[i] = v;
	}
}