Manager component for wall-buy entities. It finds/creates the singleton, tracks all WallBuy instances, handles aiming/range tests, spawning visual representations (chalk outline, weapon model or marker), rebuilding from config, placement, and per-frame reveal/outline breathing logic.
using System.Collections.Generic;
using System.Linq;
using System;
using Sandbox;
namespace NZombies;
/// <summary>
/// Finds and operates wallbuys. Deliberately the same shape as DebrisManager
/// and PowerManager — singleton, `Aimed()`, `Reach` — so the use key, the
/// prompt and the console all talk to every buyable the same way.
///
/// ⚠️ THE SHAPE IS THE POINT. A third interaction that invented its own
/// aiming/range/prompt convention would mean the prompt and the key could
/// disagree for wallbuys only, which is exactly the class of bug the shared
/// order in TickUse/UsePrompt exists to prevent.
/// </summary>
public sealed class WallBuyManager : Component
{
public static WallBuyManager Instance { get; private set; }
/// <summary>How far the aiming ray is cast.</summary>
[Property] public float BuyRange { get; set; } = 200f;
/// <summary>How close you must actually stand. Matches the debris reach.</summary>
[Property] public float Reach { get; set; } = 80f;
/// <summary>
/// Find the manager, creating it if the scene has none.
///
/// ⚠️ Created at runtime rather than saved into the scene — the same as every
/// other manager here, and for the same reason: a .scene is never rewritten
/// from a script. Without this the whole feature silently no-ops, because
/// every entry point starts with `Instance?.`
/// </summary>
public static WallBuyManager Ensure()
{
if ( Instance.IsValid() ) return Instance;
var scene = Game.ActiveScene;
if ( scene is null ) return null;
var found = scene.GetAllComponents<WallBuyManager>().FirstOrDefault( m => m.IsValid() );
if ( found.IsValid() ) return Instance = found;
var go = scene.CreateObject();
go.Name = "WallBuy Manager";
go.Flags |= GameObjectFlags.NotSaved;
Log.Info( "[wallbuy] created manager (none in scene)" );
return Instance = go.Components.Create<WallBuyManager>();
}
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public List<WallBuy> All => Scene?
.GetAllComponents<WallBuy>()
.Where( w => w.IsValid() )
.ToList() ?? new();
/// <summary>
/// The wallbuy the player is looking at, or null.
///
/// ⛔ IGNORE THE PLAYER'S OWN HIERARCHY. The camera sits at eye height INSIDE
/// the player's collider, so an un-ignored trace stops at zero distance on
/// the body it started in and never reaches anything — the same trap
/// DebrisManager documents.
/// </summary>
public WallBuy Aimed( NZPlayer player )
{
if ( !player.IsValid() ) return null;
var cam = Scene.Camera;
if ( !cam.IsValid() ) return null;
var from = cam.WorldPosition;
var dir = cam.WorldRotation.Forward;
// ⚠️ Nearest wins — two wallbuys on facing walls would otherwise pick by
// list order rather than by what is actually under the crosshair.
WallBuy best = null;
var bestDist = float.MaxValue;
foreach ( var b in All )
{
if ( !Hits( b, from, dir, out var dist ) ) continue;
if ( dist > BuyRange || dist >= bestDist ) continue;
if ( (from + dir * dist).Distance( player.WorldPosition ) > Reach ) continue;
best = b;
bestDist = dist;
}
return best;
}
static readonly Dictionary<string, Vector3> _centreCache = new();
/// <summary>
/// Drop cached visual centres so the next rebuild recomputes them.
///
/// ⛔ WITHOUT THIS, TUNING LOOKS LIKE IT DOES NOTHING. The cache is static and
/// survives both a wallbuy rebuild and a code hotload, so changing how the
/// centre is computed had no visible effect and no log line — indistinguishable
/// from the change not working.
/// </summary>
public static void ClearCentreCache() => _centreCache.Clear();
/// <summary>
/// Where a weapon LOOKS centred, rather than the middle of its bounding box.
///
/// ⛔ Bounds.Center IS THE WRONG ANCHOR FOR A GUN. An AABB is decided by the
/// two extremes, so a long thin barrel drags the centre far forward of where
/// the weapon's mass actually is — a revolver centres near its cylinder, not
/// halfway down the barrel. The drawing then sits visibly off.
///
/// ⚠️ The MEAN VERTEX POSITION is a good proxy for "densest part": detail
/// clusters on the receiver, cylinder and grip, while a barrel is a few rings
/// of geometry spanning a long distance. So averaging vertices weights the
/// result toward the bulk without needing real mass properties.
///
/// ⚠️ Cached per model — this walks every vertex, and a wallbuy rebuild would
/// otherwise redo it for each placement.
/// </summary>
static Vector3 VisualCentre( Model model )
{
if ( _centreCache.TryGetValue( model.Name, out var hit ) ) return hit;
var centre = model.Bounds.Center;
try
{
var verts = model.GetVertices();
if ( verts is not null && verts.Length > 0 )
{
var sum = Vector3.Zero;
foreach ( var v in verts ) sum += v.Position;
centre = sum / verts.Length;
}
}
catch ( System.Exception e )
{
// ⚠️ Falls back to the bounding centre rather than failing — a wallbuy
// slightly off is better than no wallbuy.
Log.Info( $"[wallbuy] no vertex data for {model.Name} ({e.Message}) — using bounds" );
}
// ⚠️ Report BOTH so "I see no difference" is answerable: either the vertex
// centroid really is near the bounds centre for this mesh, or GetVertices
// returned nothing and we silently fell back.
Log.Info( $"[wallbuy/centre] {System.IO.Path.GetFileName( model.Name )} " +
$"bounds {model.Bounds.Center} vertices {centre} " +
$"delta {(centre - model.Bounds.Center).Length:0.##}" );
_centreCache[model.Name] = centre;
return centre;
}
/// <summary>Half-extents of a wallbuy's aimable box, in its own space.</summary>
static readonly Vector3 AimBox = new( 2f, 15f, 7f );
/// <summary>
/// Ray vs the wallbuy's box, done in the wallbuy's LOCAL space so the box can
/// stay axis-aligned — the standard slab test.
///
/// ⚠️ Replaces a physics trace so the entity needs no collider and therefore
/// blocks nothing. Local X is the wall normal, matching every other convention
/// in this file.
/// </summary>
static bool Hits( WallBuy buy, Vector3 from, Vector3 dir, out float dist )
{
dist = 0f;
if ( !buy.IsValid() ) return false;
var inv = buy.WorldRotation.Inverse;
var o = inv * (from - buy.WorldPosition);
var d = inv * dir;
float near = float.MinValue, far = float.MaxValue;
for ( var i = 0; i < 3; i++ )
{
// ⚠️ A ray parallel to a slab either misses entirely or is unconstrained
// by it; dividing by ~0 would give infinities that poison the interval.
if ( MathF.Abs( d[i] ) < 0.0001f )
{
if ( MathF.Abs( o[i] ) > AimBox[i] ) return false;
continue;
}
var t1 = (-AimBox[i] - o[i]) / d[i];
var t2 = (AimBox[i] - o[i]) / d[i];
if ( t1 > t2 ) (t1, t2) = (t2, t1);
near = MathF.Max( near, t1 );
far = MathF.Min( far, t2 );
if ( near > far ) return false;
}
if ( far < 0f ) return false;
dist = near < 0f ? 0f : near;
return true;
}
/// <summary>Place one. Used by the editor tool and the console command.</summary>
/// <summary>
/// Destroy what is standing and build the CONFIG again.
///
/// ⚠️ Same shape as every other manager, and it did not exist before — wall
/// buys were only ever created by clicking, never rebuilt, so loading a config
/// produced none of them.
/// </summary>
public void Rebuild()
{
foreach ( var b in All.ToList() )
b?.GameObject?.Destroy();
_built.Clear();
var list = ActiveConfig.Current?.WallBuys;
if ( list is null || list.Count == 0 ) return;
for ( var i = 0; i < list.Count; i++ )
{
var spot = list[i];
var buy = Spawn( spot.Position, spot.Angles.ToRotation(),
spot.WeaponPrefab, spot.Price, spot.Link, spot.Rarity );
buy.Index = i;
_built.Add( buy );
}
Log.Info( $"[nz] {list.Count} weapon buy(s) built" );
}
/// <summary>
/// Every wall buy the config built, each carrying its index there — so a purchase that arrives by message (`NZNet.WallBought`)
/// finds its wall. ⚠️ NOT `All`: a rebuild's outgoing walls are still in the scene until the frame ends, under the same indices.
/// </summary>
readonly List<WallBuy> _built = new();
public IReadOnlyList<WallBuy> Built => _built;
/// <summary>The wall buy at this config index, or null.</summary>
public WallBuy ByIndex( int index ) => _built.FirstOrDefault( b => b.IsValid() && b.Index == index );
/// <summary>Place one AND record it in the config, so it survives a save.</summary>
public WallBuy Place( Vector3 pos, Rotation rot, string weaponPrefab, int price,
int rarity = 0 )
{
// ⛔ THE CONFIG ENTRY IS THE POINT OF THIS METHOD NOW. Creating only the
// scene object is what lost every wallbuy on reload.
ActiveConfig.Current.WallBuys.Add( new WallBuySpot
{
Position = pos,
Angles = rot.Angles(),
WeaponPrefab = weaponPrefab,
Price = price,
Rarity = rarity,
} );
var buy = Spawn( pos, rot, weaponPrefab, price, DoorLinks.Unlinked, rarity );
buy.Index = ActiveConfig.Current.WallBuys.Count - 1;
_built.Add( buy );
return buy;
}
/// <summary>Build the world object only. Rebuild and Place share it.</summary>
WallBuy Spawn( Vector3 pos, Rotation rot, string weaponPrefab, int price, string link,
int rarity = 0 )
{
var go = Scene.CreateObject();
go.Name = $"WallBuy ({System.IO.Path.GetFileNameWithoutExtension( weaponPrefab )})";
go.WorldPosition = pos;
go.WorldRotation = rot;
// ⚠️ NotSaved, like every other config-built object. Without it a play
// session bakes them into the scene file and they come back permanently,
// on top of the ones the config builds.
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
var buy = go.Components.Create<WallBuy>();
buy.WeaponPrefab = weaponPrefab;
buy.Price = price;
buy.Rarity = rarity;
SpawnVisual( buy );
return buy;
}
/// <summary>
/// Build what the wallbuy looks like: THE CHALK DRAWING, AND NOTHING ELSE.
///
/// ⛔ NO 3D WEAPON MODEL. This is not a simplification — it is what nZombies
/// does. In `entities/entities/wall_buys/shared.lua` the only live
/// `self:DrawModel()` sits behind `if self:GetNoChalk()`; inside
/// `DrawLegacyOutline` the same call is commented out. The entity carries the
/// weapon's model **for bounds, collision and use-range** and never renders
/// it. A lit gun stuck to a wall is not what a wallbuy looks like.
///
/// ⚠️ That makes the missing-chalk fallback LOAD-BEARING. With the model gone,
/// a weapon with no decal would be an *invisible* wallbuy — unfindable and
/// unplaceable — so it falls back to the marker box.
/// </summary>
public static void SpawnVisual( WallBuy buy )
{
foreach ( var old in buy.GameObject.Children
.Where( c => c.Name is "display" or "visual" or "chalk" or "weapon" ).ToList() )
{
// ⛔ UNPARENT AND RENAME BEFORE DESTROYING. Destroy is deferred to the
// end of the frame, so the object is still a child — and still matches
// a name lookup — for the rest of this one. `nz_wallbuy_depth` rebuilt
// the model and then revealed the OUTGOING child, leaving the new one
// hidden and the wallbuy looking empty. Same trap as nz_give.
old.Name = "~dead";
old.Enabled = false;
old.SetParent( null );
old.Destroy();
}
// ⚠️ Drop the reveal too — the object it pointed at is being destroyed, and
// a stale reference would leave the next aim thinking it is already shown.
_revealed = null;
// ⚠️ Read the prefab only to VALIDATE it. Nothing here renders it, but a
// wallbuy selling a weapon that does not exist should say so at placement
// rather than at the moment a player spends 1250 points on it.
if ( ResourceLibrary.Get<PrefabFile>( buy.WeaponPrefab ) is null )
Log.Warning( $"[wallbuy] no prefab at {buy.WeaponPrefab} — it will sell nothing" );
if ( SpawnChalk( buy ) )
SpawnWeaponModel( buy );
else
SpawnMarker( buy );
// ⛔ WITHOUT A COLLIDER NOTHING CAN AIM AT IT. Scene.Trace is a PHYSICS
// query and a ModelRenderer is not physics — the ray passed straight
// through, so there was no prompt and the use key did nothing, which
// reads as "the entity is broken" rather than "it has no shape".
//
// ⚠️ This is now the ONLY thing giving the wallbuy a physical presence,
// which is exactly the job GMod's unrendered weapon model does.
//
// ⚠️ GetOrCreate, NOT Create. SpawnVisual is re-run to re-tune the chalk
// (nz_wallbuy_chalk), and Create would stack a second collider on every
// call — the trace would still work, so the leak would go unnoticed.
// ⛔ NO COLLIDER AT ALL. A wallbuy is a chalk drawing — paint, not geometry —
// so it must not stop bullets or shove the player. It previously carried a
// BoxCollider purely so the use-trace could find it, and that shape blocked
// everything else too.
//
// ⚠️ Aimed() now does the ray/box test in code instead (see Hits). With a
// handful of wallbuys that is cheaper than a physics query anyway, and it
// cannot collide with anything by construction.
foreach ( var old in buy.GameObject.Components.GetAll<BoxCollider>().ToList() )
old.Destroy();
}
/// <summary>
/// The solid weapon model, hidden, revealed while the player aims at the wallbuy.
///
/// ⚠️ Placed by the SAME Orient() the chalk uses, just un-flattened, so the gun
/// materialises into its own outline by construction rather than by tuning.
/// </summary>
static void SpawnWeaponModel( WallBuy buy )
{
var model = WeaponModel( buy );
if ( model is null ) return;
var go = buy.Scene.CreateObject();
go.Name = "weapon";
go.SetParent( buy.GameObject );
go.Enabled = false; // revealed by OnUpdate
go.Components.Create<ModelRenderer>().Model = model;
// ⚠️ AND WHAT BURNS IT IN, where the map asks for that (`WallBuyBurnIn`, `Gameplay.ChalkBurnIn`); at once otherwise
go.Components.Create<WallBuyBurnIn>();
// ⛔ FLATTENED, LIKE THE CHALK. A solid 3D gun on a wall needed a depth
// offset, a centre anchor and a bounds solve, and every one of those was a
// source of misalignment. Flattening it into the SAME plane makes it a 2D
// render of the weapon sitting inside its own outline — and since chalk and
// model are then the same mesh, same scale, same plane, they line up by
// construction rather than by tuning.
Orient( go, model, true );
}
/// <summary>
/// How far the revealed model floats off the wall, in front of the chalk.
///
/// ⚠️ SMALL ON PURPOSE. This is the ONLY thing that should separate the model
/// from its drawing — just enough to stop them z-fighting. Anything larger and
/// the gun visibly hovers off the wall.
/// </summary>
public static float ModelDepth { get; set; } = 0.1f;
/// <summary>Hold every weapon model visible, for inspection. `nz_wallbuy_reveal`.</summary>
public static bool ForceReveal { get; set; }
/// <summary>
/// Extra rotation applied to the weapon model only, in its own frame.
///
/// ⚠️ Exists because the model's authored axes are not knowable from the asset
/// — how a `c_` viewmodel is oriented varies per pack — and every wrong guess
/// costs a compile plus a screenshot. Dial it with `nz_wallbuy_model` and bake
/// the winner in as the default here.
/// </summary>
// ⚠️ Roll 180 is the MEASURED default, not a guess: without it the child came
// out at roll −179° — the gun upside down in its own outline, magazine up.
public static Rotation ModelTweak { get; set; } = Rotation.FromRoll( 180f );
/// <summary>
/// Per-pack PRE-flatten rotation, keyed by a substring of the model's path.
///
/// ⛔ THIS TURNS THE MESH *BEFORE* IT IS FLATTENED — the distinction is the whole
/// point. The chalk is the flattened SILHOUETTE of the mesh (SpawnChalk): the
/// flatten squashes the model's +Y, so the drawing traces its X–Z face. ModelTweak
/// rotates the wafer AFTER the flatten — it can spin or flip the outline but never
/// change WHICH face it traces. A pack authored on other axes (SIMER's) presents the
/// wrong face and comes out end-on ("forwards"); only turning the mesh first, so a
/// different face is the one flattened, fixes it. Orient() folds this in and
/// redirects the flatten axis to match.
///
/// ⚠️ STATIC — SURVIVES HOTLOAD (INSTRUCTIONS §1). Dialing via nz_wallbuy_premodel
/// mutates it live; a baked default needs a FULL RESTART to re-run this initialiser.
/// </summary>
public static Dictionary<string, Rotation> PreTweaks { get; } = new()
{
// ⚠️ MEASURED on the STG-44, 2026-09-02, via `nz_wallbuy_premodel 90 0 0 simers`:
// a 90° yaw swings the barrel out of the +Y (flattened) axis so the side profile
// faces the wall. Rotation.From is (pitch, yaw, roll), so yaw 90 → From( 0, 90, 0 ).
["simers"] = Rotation.From( 0f, 90f, 0f ),
// ⚠️ Same "forwards"/end-on symptom reported on three more packs — given the SAME
// yaw 90 as SIMER's, since they share the authored-axis convention. VERIFY each in
// game; if one differs, dial `nz_wallbuy_premodel <y> <p> <r> <tag>` and rebake.
["_lpg"] = Rotation.From( 0f, 90f, 0f ), // Golub low-poly guns (weapons/*_lpg/)
["uplp"] = Rotation.From( 0f, 90f, 0f ), // poly-arms pack (weapons/uplp_*/)
["destiny"] = Rotation.From( 0f, 90f, 0f ), // no shared tag — matched via DestinyRoster below
// ✅ MEASURED AND CONFIRMED IN GAME, 2026-09-14, via `nz_wallbuy_premodel 90 0 0 historical`
// — the user checked the chalk and confirmed the profile before this was written down. Same
// yaw 90 as SIMER's, which is evidence the TFA packs share an authored-axis convention
// rather than a coincidence to be relied on for the NEXT pack.
//
// ⚠️ MATCHED BY MANIFEST PACK NAME, NOT BY PATH — see `ManifestPacks`. 76 weapons across
// `coldwar_`, `dibis_`, `ins2_`, `isonzo_` and more, with no substring in common.
["historical"] = Rotation.From( 0f, 90f, 0f ),
};
/// <summary>
/// Packs identified by their MANIFEST pack name instead of a path substring or a hand-typed
/// roster.
///
/// ⛔ "TFA Historical" IS 76 WEAPONS WITH NO SHARED PATH FRAGMENT — `coldwar_`, `dibis_`,
/// `ins2_`, `isonzo_` and more. The substring tag cannot match it and a Destiny-style hand
/// roster would be 76 names that go stale the moment the pack gains a weapon.
///
/// ⚠️ THE MANIFEST ALREADY KNOWS. `WeaponLibrary.Entry` carries `Pack` straight from
/// `weapons/manifest.json`, so the roster is DERIVED rather than duplicated — add a gun to the
/// pack and its chalk is oriented with no code change.
/// </summary>
static readonly Dictionary<string, string> ManifestPacks = new()
{
["historical"] = "TFA Historical",
};
static Dictionary<string, string[]> _manifestRosters;
/// <summary>Drop the derived rosters — after a manifest reload or a live dial.</summary>
public static void InvalidateRosters() => _manifestRosters = null;
/// <summary>
/// The model-name fragments belonging to a manifest-identified pack.
///
/// ⚠️ `nz_coldwar_m12.prefab` -> `coldwar_m12`, which IS the fragment the model path carries
/// (`weapons/coldwar_m12/v_coldwar_m12.vmdl`). Sliced with plain string ops rather than
/// `System.IO.Path`, which s&box's whitelist refuses — see INSTRUCTIONS.md, error SB1000.
/// </summary>
static string[] ManifestRoster( string tag )
{
if ( _manifestRosters is null )
{
_manifestRosters = new Dictionary<string, string[]>();
foreach ( var kv in ManifestPacks )
{
var names = new List<string>();
foreach ( var e in WeaponLibrary.All )
{
if ( e.Pack != kv.Value ) continue;
var n = e.Prefab ?? "";
int slash = n.LastIndexOf( '/' );
if ( slash >= 0 ) n = n[(slash + 1)..];
int dot = n.LastIndexOf( '.' );
if ( dot >= 0 ) n = n[..dot];
if ( n.StartsWith( "nz_", System.StringComparison.OrdinalIgnoreCase ) ) n = n[3..];
if ( n.Length > 2 ) names.Add( n );
}
_manifestRosters[kv.Key] = names.ToArray();
}
}
return _manifestRosters.TryGetValue( tag, out var r ) ? r : System.Array.Empty<string>();
}
/// <summary>
/// Packs whose weapons share NO path substring, so the tag alone cannot match them.
/// The Destiny pack names every gun individually (ace_of_spades, eyasluna…), so its
/// roster is listed here and ResolvePre treats a hit as the "destiny" tag. Taken from
/// the Destiny section of PapNames — the same weapons, folder = prefab id minus `nz_`.
/// </summary>
static readonly Dictionary<string, string[]> PackRosters = new()
{
["destiny"] = new[]
{
"farewell", "forerunner", "7th_sidearm", "trespasser", "graviton_lance",
"gridskipper", "hard_light", "ikelos_smg", "7th_smg", "sweet_sorrow",
"unforgiven", "bxr_battler", "chroma_rush", "disparity", "khvostov_7g0x",
"midhas_reckoning", "new_purpose", "outbreak_perfected", "piece_of_mind",
"revision_zero", "7th_carbine", "smite_of_merain", "the_eremite",
"touch_of_malice", "vex_mythoclast2", "bryas_love", "doom_of_chelchis",
"trustee", "commemoration", "eleatic_principle", "forgotten_plague",
"hammerhead", "nemesis_star", "qullims_terminus", "recurrent_impact",
"retrofit_escapade", "7th_saw", "shattered_cipher", "thunderlord",
"tommys_matchbook", "xenophage", "ace_of_spades", "eyasluna", "hawkmoon",
"ikelos_hc", "kept_confidence", "posterity", "7th_revolver", "sunshot",
"thorn", "zaoulis_bane", "heritage", "ikelos_shotgun", "matador64",
"7th_shotgun", "soujourners_tale", "wastelander", "1000_yard_stare",
"cloudstrike", "darci", "defiance_of_yasmin", "ikelos_sniper", "ice_breaker_2",
"izanagis", "locus_locutus", "lorentz_driver", "no_land_beyond",
"polaris_lance", "stormchaser", "succession", "thoughtless", "whisper",
"zen_meteor",
},
};
/// <summary>
/// The PRE-flatten rotation for this model — a per-pack override when its path
/// matches one, otherwise identity (the ARC9 default: no pre-rotation).
///
/// ⚠️ ONE resolver, used for BOTH the chalk and the revealed gun (Orient runs for
/// each), so the two cannot pick different orientations and drift apart.
/// </summary>
/// <summary>
/// How many weapons a pack tag actually matches.
///
/// ⛔ THE ONE THING A DIAL CANNOT TELL YOU BY LOOKING. A tag that matches nothing produces a
/// rotation that is stored, reported and applied to zero chalk — indistinguishable in game
/// from a rotation that is simply wrong. "TFA Historical" has no shared path fragment, so
/// this is exactly the pack where a typo would look like a bad angle.
/// </summary>
public static int MatchCount( string tag )
{
if ( string.IsNullOrWhiteSpace( tag ) ) return 0;
int n = 0;
foreach ( var e in WeaponLibrary.All )
{
var p = e.Prefab ?? "";
if ( p.Contains( tag, System.StringComparison.OrdinalIgnoreCase ) ) { n++; continue; }
if ( PackRosters.TryGetValue( tag, out var roster ) )
foreach ( var name in roster )
if ( p.Contains( name, System.StringComparison.OrdinalIgnoreCase ) ) { n++; goto next; }
foreach ( var name in ManifestRoster( tag ) )
if ( p.Contains( name, System.StringComparison.OrdinalIgnoreCase ) ) { n++; goto next; }
next: ;
}
return n;
}
static Rotation ResolvePre( Model model )
{
var path = model?.Name ?? "";
foreach ( var kv in PreTweaks )
{
// A self-identifying tag is a substring of the model path ("simers", "_lpg", "uplp").
if ( path.Contains( kv.Key, System.StringComparison.OrdinalIgnoreCase ) )
return kv.Value;
// A pack with no shared substring (Destiny) matches via its listed roster instead.
if ( PackRosters.TryGetValue( kv.Key, out var roster ) )
foreach ( var name in roster )
if ( path.Contains( name, System.StringComparison.OrdinalIgnoreCase ) )
return kv.Value;
// ⚠️ AND A PACK THE MANIFEST NAMES, whose roster is derived rather than typed out.
foreach ( var name in ManifestRoster( kv.Key ) )
if ( path.Contains( name, System.StringComparison.OrdinalIgnoreCase ) )
return kv.Value;
}
return Rotation.Identity;
}
/// <summary>
/// Per-axis scale that squashes to a wafer whichever of `dir`'s axes is dominant,
/// leaving the other two at full `scale`. `dir` is the model axis the pre-rotation
/// turns into the wall normal (+Y). For a cardinal pre-rotation exactly one
/// component is ±1; near-cardinal values pick the largest, so the flatten degrades
/// gracefully rather than squashing two axes at once.
/// </summary>
static Vector3 FlattenScale( float scale, Vector3 dir )
{
var thin = scale * 0.02f;
var ax = MathF.Abs( dir.x );
var ay = MathF.Abs( dir.y );
var az = MathF.Abs( dir.z );
if ( ax >= ay && ax >= az ) return new Vector3( thin, scale, scale );
if ( ay >= ax && ay >= az ) return new Vector3( scale, thin, scale );
return new Vector3( scale, scale, thin );
}
/// <summary>
/// Nudge the model within the outline — Y along the wall, Z up it.
///
/// ⚠️ Separate from the bbox fit, which centres the model's BOUNDS on the
/// drawing's. That is geometrically right and still reads slightly high,
/// because the icon's render and our model do not share a pivot.
/// </summary>
/// ⛔ ZERO BY DEFAULT. The old -3 was tuned when the chalk STRETCHED every
/// weapon to one length; with a fixed scale it is stale, and it was the reason
/// the model sat off its own outline. Chalk and model must share a position —
/// they are the same mesh, so anything that moves one has to move both.
public static Vector3 ModelNudge { get; set; } = Vector3.Zero;
/// <summary>Re-place chalk and model on every wallbuy after a tweak.</summary>
public static void RefitModels()
{
foreach ( var buy in Instance?.All ?? new List<WallBuy>() )
{
foreach ( var name in new[] { "chalk", "weapon" } )
{
var go = buy.GameObject.Children.FirstOrDefault( c => c.Name == name );
var r = go.IsValid() ? go.Components.Get<ModelRenderer>() : null;
if ( r?.Model is null ) continue;
// ⚠️ Refit in place rather than respawning — respawning goes through the
// deferred-Destroy path, and the reveal would then point at the outgoing
// child for the rest of the frame.
Orient( go, r.Model, name == "chalk" );
}
}
}
/// <summary>
/// Show the weapon on whichever wallbuy the player is aiming at.
///
/// ⛔ ONE TRACE PER FRAME, NOT ONE PER WALLBUY. Asking each WallBuy whether it
/// is aimed at would fire a scene trace per entity per frame; a map with
/// twenty wallbuys would pay twenty traces to answer one question.
/// </summary>
protected override void OnUpdate()
{
// ⚠️ THE CHALK BREATHES FIRST, above every return below (`TickChalk`)
TickChalk();
// ⚠️ `nz_wallbuy_reveal` holds every model on, so the aim tracking must not
// immediately switch them back off.
if ( ForceReveal ) return;
var player = NZPlayer.Local;
var aimed = player.IsValid() ? Aimed( player ) : null;
if ( aimed == _revealed ) return;
SetRevealed( _revealed, false ); // no-ops on a bought wallbuy
SetRevealed( aimed, true );
_revealed = aimed;
}
// ⚠️ Static because SpawnVisual is, and it has to clear this when it destroys
// the object being pointed at. Safe here only because the manager is a
// singleton — see Ensure().
static WallBuy _revealed;
/// <summary>
/// Show or hide a wallbuy's weapon model.
///
/// ⚠️ Public because WallBuy.TryBuy reveals on purchase — the model then stays
/// up permanently, so the reveal is not solely aim-driven any more.
/// </summary>
/// <param name="instant">At once, not burning — a joiner catching up on a wall bought before they came (`NZNet.WallBought`).</param>
public static void SetRevealed( WallBuy buy, bool on, bool instant = false )
{
if ( !buy.IsValid() ) return;
// ⛔ A BOUGHT WALLBUY NEVER HIDES. Aiming away must not undo the purchase
// reveal, and OnUpdate calls this every time the aim target changes.
if ( !on && buy.Bought ) return;
var go = buy.GameObject.Children.FirstOrDefault( c => c.Name == "weapon" );
if ( !go.IsValid() ) return;
// ⚠️ BURNING IN AND OUT, where the map burns its chalk (`WallBuyBurnIn`, 2026-09-28) — at once where it does not, as it always was
var burn = go.Components.Get<WallBuyBurnIn>( FindMode.EverythingInSelf );
if ( burn.IsValid() ) burn.Show( on, instant );
else go.Enabled = on;
}
/// <summary>
/// Stand-in for a weapon with no chalk, so the wallbuy is still findable.
///
/// ⛔ TWO OBJECTS, SAME AS PowerManager AND DebrisManager. A collider
/// multiplies by its object's scale, so putting both on one scaled object
/// squares the collider. The parent stays unscaled and states its collider in
/// plain world units; only the child is scaled.
/// </summary>
static void SpawnMarker( WallBuy buy )
{
Log.Info( $"[wallbuy] {buy.WeaponName} has no chalk — placing a marker box" );
var vis = buy.Scene.CreateObject();
vis.Name = "visual";
vis.SetParent( buy.GameObject );
vis.LocalPosition = Vector3.Zero;
vis.LocalRotation = Rotation.Identity;
// ⛔ FLAT TO THE WALL, NOT STICKING OUT OF IT. The object's FORWARD is the
// surface normal (Rotation.LookAt( normal )), so local X points out of the
// wall — the THIN axis has to be X. With the long axis there the
// placeholder stood perpendicular, like a shelf.
vis.LocalScale = new Vector3( 0.04f, 0.30f, 0.14f );
var r = vis.Components.Create<ModelRenderer>();
r.Model = Model.Load( "models/dev/box.vmdl" );
r.Tint = new Color( 0.31f, 0.79f, 0.66f, 0.75f );
}
/// <summary>
/// The chalk drawing — THE WEAPON'S OWN MODEL, flattened against the wall and
/// outlined.
///
/// ⛔ NOT TRACED FROM A PACK ICON. Icons are optional in a weapon pack and, when
/// present, need not match that pack's own model — the ARC9 BO1 Galil icon shows
/// a full-length barrel and skeletal folder while its vmdl has a short barrel and
/// a solid stock. Every reconciliation knob this file used to carry (fit.json
/// bboxes, scale factors, nudge offsets) existed only to paper over that gap. An
/// outline taken from the geometry cannot disagree with the geometry and needs
/// nothing authored per weapon — which is what lets ANY pack drop into this base.
///
/// This is GMod's own fallback path (`UseLegacyOutline` in wall_buys/shared.lua):
/// it flattens copies of the world model to 1% thickness and outlines them through
/// the stencil buffer. HighlightOutline is the s&box equivalent.
/// </summary>
public static bool SpawnChalk( WallBuy buy )
{
foreach ( var old in buy.GameObject.Children.Where( c => c.Name == "chalk" ).ToList() )
{
old.Name = "~dead"; old.Enabled = false; old.SetParent( null ); old.Destroy();
}
var model = WeaponModel( buy );
if ( model is null ) return false;
// ⛔ NOTHING RENDERS WITHOUT A Highlight ON THE CAMERA. HighlightOutline only
// marks a renderer as a target; the camera component does the drawing.
EnsureHighlight( buy.Scene );
var go = buy.Scene.CreateObject();
go.Name = "chalk";
go.SetParent( buy.GameObject );
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
// ⚠️ Transparent, so only the OUTLINE reads. GMod does the same with
// render.SetBlend(0) — its flattened copies are never seen, they only define
// the silhouette.
r.Tint = new Color( 1f, 1f, 1f, 0f );
var o = go.Components.Create<HighlightOutline>();
// ⛔ TIER 0 KEEPS ChalkColor, IT DOES NOT USE Rarity.ColorFor( 0 ). That returns grey
// (175,175,175) and this returns white, so routing Common through the rarity palette would
// silently repaint every wall buy in every existing map. Only a tier the mapper actually
// chose changes colour.
// ⚠️ THE MAP'S OWN CHALK, IF IT HAS ONE (`Gameplay.ChalkColour`, 2026-09-28) — basalt's ember
o.Color = buy.Rarity > 0 ? Rarity.ColorFor( buy.Rarity ) : ChalkBase;
o.InsideColor = Color.Transparent;
o.ObscuredColor = Color.Transparent;
o.InsideObscuredColor = Color.Transparent;
o.Width = ChalkWidth;
// ⚠️ THE COMMON TIER BREATHES, each on its own beat (`TickChalk`); a rarity's colour is information and stays still
if ( buy.Rarity <= 0 ) Chalks.Add( (o, Game.Random.Float( 0f, 6.2832f )) );
Orient( go, model, true );
return true;
}
/// <summary>Chalk line colour. White, as the original.</summary>
public static Color ChalkColor { get; set; } = Color.White;
/// <summary>Chalk line weight.</summary>
public static float ChalkWidth { get; set; } = 0.4f;
/// <summary>The common tier's chalk on this map: `Gameplay.ChalkColour` (basalt's ember), else <see cref="ChalkColor"/>, the original's white.</summary>
public static Color ChalkBase => Color.Parse( ActiveConfig.Current?.Gameplay?.ChalkColour ?? "" ) ?? ChalkColor;
/// <summary>Every common tier's chalk outline, with its own phase, for the glow. A dead one falls out as it is met.</summary>
static readonly List<(HighlightOutline Outline, float Phase)> Chalks = new();
/// <summary>
/// EMBER CHALK — *"I would love this"* (2026-09-28): the common tier's chalk in the map's colour, breathing like embers by
/// `Gameplay.ChalkGlow` (0 steady; basalt's 0.35), each wall buy on its own beat so a wall of them never pulses in step.
///
/// ⚠️ A COLOUR ON AN OUTLINE AND NOTHING MORE — no light, no material, no draw of its own. The outline is drawn every frame
/// regardless (`HighlightOutline`); this only changes the number it is drawn with.
/// </summary>
static void TickChalk()
{
if ( Chalks.Count == 0 ) return;
var glow = Math.Clamp( ActiveConfig.Current?.Gameplay?.ChalkGlow ?? 0f, 0f, 1f );
var colour = ChalkBase;
var now = Time.Now;
for ( var i = Chalks.Count - 1; i >= 0; i-- )
{
var (outline, phase) = Chalks[i];
if ( !outline.IsValid() ) { Chalks.RemoveAt( i ); continue; }
// ⚠️ A SLOW BREATH, about 3.4 s, never dimmer than (1 - glow) of full
var k = glow <= 0f ? 1f : 1f - glow * 0.5f * (1f - MathF.Sin( now * 1.85f + phase ));
outline.Color = new Color( colour.r * k, colour.g * k, colour.b * k, colour.a );
}
}
/// <summary>
/// The camera needs a Highlight component or no outline draws anywhere.
///
/// ⚠️ Added at runtime like every other manager here — a .scene is never
/// rewritten from a script.
/// </summary>
/// ⚠️ PUBLIC because Pickup needs the same wiring for its loot outlines.
/// Two copies of "has the camera got a Highlight yet" is exactly the duplicated
/// lookup INSTRUCTIONS.md §3 warns diverges — one of them gets the fix.
public static void EnsureHighlight( Scene scene )
{
var cam = scene?.Camera;
if ( !cam.IsValid() ) { Log.Warning( "[wallbuy] no camera — chalk will not draw" ); return; }
if ( cam.Components.Get<Highlight>() is null )
{
cam.Components.Create<Highlight>();
Log.Info( "[wallbuy] added Highlight to the camera (needed for chalk)" );
}
}
/// <summary>The weapon's model, from its prefab. Null when it cannot be loaded.</summary>
static Model WeaponModel( WallBuy buy )
{
var prefab = ResourceLibrary.Get<PrefabFile>( buy.WeaponPrefab );
if ( prefab is null ) { Log.Warning( $"[wallbuy] no prefab at {buy.WeaponPrefab}" ); return null; }
var weapon = prefab.RootObject?["Components"]?.AsArray()
.FirstOrDefault( c => c?["__type"]?.ToString()?.EndsWith( "Weapon" ) == true );
// ⛔ THE VIEWMODEL IS THE WORLD MODEL HERE. ARC9 points WorldModelMirror at
// the same `c_` model it uses in first person.
var path = weapon?["WorldModel"]?.ToString();
if ( string.IsNullOrWhiteSpace( path ) ) path = weapon?["ViewModel"]?.ToString();
if ( string.IsNullOrWhiteSpace( path ) ) return null;
// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`, 2026-10-01): an MW viewmodel's bind pose came apart on
// the wall, *"the chalk for the weapon models its all broken, even though its perfectly good in my hand"*.
var m = WeaponDisplay.Load( path );
// ⚠️ Model.Load returns the ERROR model, not null, for a bad path.
return m is null || m.IsError ? null : m;
}
/// <summary>
/// Lay a weapon model on the wall — same rotation, scale and centring for the
/// chalk and for the revealed gun, so the two cannot drift apart.
///
/// ⚠️ ONE METHOD FOR BOTH ON PURPOSE. The drawing and the gun are the same mesh
/// now; separate placement code is exactly how they would stop lining up again.
/// </summary>
static void Orient( GameObject go, Model model, bool flatten )
{
// Orientation is TWO stages that must not be confused:
// 1. ResolvePre turns the MESH *before* the flatten — it decides WHICH face
// becomes the drawing. Identity for ARC9; a per-pack turn for SIMER's.
// 2. LookAt + ModelTweak orient the finished wafer on the wall (ModelTweak is
// the post-flatten roll, the measured ARC9 up/down fix).
// The pre-rotation is FOLDED into LocalRotation: a cardinal turn commutes with
// the axis-aligned flatten (S·pre = pre·S′, S′ the permuted scale), so pre stays
// here and the flatten axis below follows it. For pre = identity this is exactly
// the old line — model +X (muzzle) → local −Z, +Z (up) → local +Y, +Y (side) →
// local −X, the weapon lying along the wall in profile.
var pre = ResolvePre( model );
go.LocalRotation = Spin * Rotation.LookAt( Vector3.Down, Vector3.Left ) * ModelTweak * pre;
// ⛔ A FIXED SCALE, NOT STRETCH-TO-FIT. This used to normalise every weapon
// to the same drawn LENGTH (ChalkScale * 0.5 / size.x), which made a pistol
// exactly as long as a rifle — so pistols came out gigantic. Weapons differ
// in size and the chalk should say so.
//
// ⚠️ ChalkScale is therefore a MULTIPLIER now, not a target length. 64 is
// life-size, matching what the old fit produced for a rifle, so rifles look
// unchanged and only the small weapons shrink.
var scale = ChalkScale / 64f;
// ⚠️ FLATTEN THE MODEL AXIS THE PRE-ROTATION TURNS INTO THE WALL NORMAL — not
// always +Y. The squashed axis must be the one that ends up facing out of the
// wall. With pre = identity that is the model's own +Y (the ARC9 side); a
// pre-rotated pack presents a different face, so the flatten has to follow or the
// drawing comes out an end-on sliver. GMod flattens to 1% for the same reason: a
// flat cutout outlines cleanly, a 3D gun outlines a shape that changes as you pass.
var flatAxis = pre.Inverse * new Vector3( 0f, 1f, 0f );
var flatScale = FlattenScale( scale, flatAxis );
go.LocalScale = flatten ? flatScale : new Vector3( scale, scale, scale );
// ⚠️ Cancel the model's own origin — a `c_` model is authored around the
// grip, not the middle.
// ⚠️ ModelNudge IS FOR THE SOLID MODEL ONLY. It was being applied to the
// chalk too, which is why some drawings sat a few units off the point they
// were placed at — the nudge is a fitting tweak for the revealed weapon,
// not part of where the wallbuy is.
// ⚠️ IDENTICAL FOR BOTH except a hair of depth on the wall normal. The chalk
// and the revealed weapon are the same mesh at the same scale, so any offset
// applied to one and not the other shows up immediately as the gun sitting
// off its own outline.
// ⛔ CENTRE USING THE PER-AXIS SCALE, NOT A UNIFORM ONE. The chalk is
// FLATTENED — LocalScale.y is scale * 0.02 — but this centring multiplied
// bounds.Center by the uniform `scale`, over-compensating along the squashed
// axis by 50x. That is what held the drawing off the wall, and why it varied
// per weapon: a `c_` model is authored around the GRIP, so how far its
// centre sits from the origin is arbitrary.
//
// ⚠️ Component-wise. Putting the model's CENTRE on the wallbuy origin is the
// whole intent — the flattened mesh then straddles the wall plane instead of
// hanging off it.
// ⛔ ALWAYS CENTRE WITH THE FLATTENED SCALE, FOR BOTH. The chalk is verified
// correct against the wall; computing the solid model's centre from its own
// UNIFORM scale gave a different answer along the wall normal, so the gun
// landed off its own outline. Deriving both from the same vector means they
// can differ ONLY by ModelDepth, by construction.
//
// ⚠️ Flattening squashes the mesh about its own centre, so using the flat
// scale here does not shrink the solid model — it only decides where the
// centre sits. Uses the SAME axis-aware flatScale as the squash above, so the
// centre is cancelled along whichever axis was actually flattened.
var c = VisualCentre( model );
var centre = go.LocalRotation * new Vector3(
-c.x * flatScale.x, -c.y * flatScale.y, -c.z * flatScale.z );
go.LocalPosition = centre
+ new Vector3( flatten ? 0f : ModelDepth, 0f, 0f ) + ModelNudge;
}
/// <summary>
/// Chalk size in world units. GMod's `ChalkScale` default is 64.
/// </summary>
public static float ChalkScale { get; set; } = 64f;
/// <summary>
/// Spin about the wall normal.
///
/// ⛔ APPLIED IN THE PARENT FRAME, NOT EACH OBJECT'S OWN. Deriving a per-object
/// spin axis — the quad presents +Z to the wall normal, the model presents +Y —
/// is correct on paper and turned out fragile in practice: the quad rotated and
/// the model did not. Left-multiplying a ROLL rotates both about the wallbuy's
/// local X, which IS the wall normal by construction (Rotation.LookAt( normal )),
/// so neither object's internal axes matter.
///
/// ⚠️ 270, not 90. The dev plane maps image-right to local Z, so unspun every
/// weapon hangs VERTICALLY on a wall — but 90 lands it upside down (magazine
/// up). 270 puts the muzzle left and the magazine down, matching the icon the
/// chalk was traced from. Measured by screenshot; not derivable from the path.
/// </summary>
public static float ChalkSpin { get; set; } = 270f;
/// <summary>Roll about the wallbuy's local X — the wall normal. Left-multiply it.</summary>
static Rotation Spin => Rotation.FromRoll( ChalkSpin );
}