Weapons/PapCamo.cs

Component that applies Pack-a-Punch camo materials to weapons. It tracks active camo selection, loads VMat materials by pack level, overrides view/world renderers, and provides console commands to inspect and change camo, map config, and pack exclusions.

File Access
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using SWB.Base;

using System;

namespace NZombies;

/// <summary>
/// THE PACK-A-PUNCH CAMO — a packed weapon wears a camo, and which one depends on its MK level.
///
/// Ported from `gamemode/papcamos/`. Upstream keeps 64 camos (`nzCamos:NewCamo`), each a `camotable`
/// of VMTs indexed BY PACK-A-PUNCH TIER, and paints the gun by looping its submaterials with
/// `SetSubMaterial( k, gun.nzPaPCamo )`. `MaterialOverride` replaces every surface in one assignment,
/// so it is the same operation with no loop.
///
/// ⚠️ THE TABLE IS INDEXED BY LEVEL, WHICH IS THE WHOLE POINT OF THE SHAPE. Silver Etching ships one
/// VMT and wears it at every tier; Crazy Place ships five and changes colour as you pack. A single
/// material per camo could express the first and not the second.
///
/// ⚠️ MK*n* IS THE ORIGINAL'S TIER *n*, ONE FOR ONE. `NZPlayer.PapMaxLevel` was raised from 3 to 5
/// precisely so Crazy Place's five-entry camotable maps straight across — the alternative was
/// spreading tiers 1/3/5 over three levels, which would have made MK2 mean "the original's MK3".
///
/// ⛔ A COMPONENT THAT POLLS, NOT A ONE-SHOT CALL AT PURCHASE. The camo has to survive things that
/// happen long after Pack-a-Punch: `Weapon.RebuildViewModel` destroys and recreates the viewmodel on
/// a character swap, and a fresh renderer comes back with no override. Comparing against the renderer
/// INSTANCES rather than a bool means any rebuild repaints itself on the next frame, which is also
/// what makes this survive a hotload.
///
/// ⚠️ THE HANDS ARE NOT PAINTED. `ViewModelHandsRenderer` is a separate renderer precisely so it can
/// hold the character's arms — overriding it puts camo on Dempsey's gloves.
///
/// ⚠️ NEITHER ARE ATTACHMENTS, and that is a judgement call rather than an oversight.
/// `MaterialOverride` replaces EVERY surface on a model, so painting a scope paints its LENS too — a
/// mirror where the glass should be. Upstream does not hit this because its attachments are
/// bodygroups on the one weapon model; ours are separate models with their own materials. Add
/// `wep.Attachments` to <see cref="Repaint"/> if camo'd optics are wanted more than working glass.
/// </summary>
public sealed class PapCamo : Component
{
	/// <summary>
	/// One camo: an id, a display name, and a material per Pack-a-Punch level.
	///
	/// ⚠️ `Levels` IS 1-BASED WITH A NULL AT [0]. Level 0 is an unpacked weapon, which wears no camo
	/// at all — keeping the index aligned with the MK number means no arithmetic at the call site,
	/// which is where an off-by-one would silently paint MK1 with MK2's material.
	/// </summary>
	public sealed record Camo( string Id, string Name, string[] Levels );

	/// <summary>
	/// Every imported camo. Ids match upstream's `nzCamos:NewCamo` ids exactly.
	///
	/// ⚠️ 60 OF THE 64 ARE EXTRACTABLE but only these are imported — see
	/// `Sbox nzombies/Tools/CAMO_IMPORT.md` for the process and `camo_coverage.py` for what is
	/// available. Importing is per-camo work because Source 1 stored each one's look differently:
	/// Silver Etching's lives in a phong-exponent map, Crazy Place's in self-illum plus a scroll
	/// proxy. There is no general converter and pretending otherwise produced a matte gun once.
	/// </summary>
	// ⛔ A PROPERTY THAT BUILDS THE TABLE, NOT A `static readonly` ARRAY (INSTRUCTIONS.md §1). A hotload copies a static's
	// VALUE across by name and does not re-run its initialiser: the old four-entry array once survived an edit and MK4/MK5
	// reported as missing while the source plainly listed them, and renaming the field was the only cure. Built on each call,
	// it cannot go stale; it is asked only when a gun is repainted and by the commands, never every frame.
	public static Camo[] Camos => new Camo[]
	{
		// ⚠️ ONE VMT REPEATED FIVE TIMES, and that is what upstream's camotable holds — Silver
		// Etching genuinely has a single material for every tier. Listing it per level rather than
		// special-casing a length-1 table keeps `MaterialFor` free of a "does this camo vary?"
		// branch, and the clamp there would cover a short table anyway.
		new( "waw_etching", "Silver Etching (W@W)", new[]
		{
			null,
			"materials/camos/waw/pap/silver_etching.vmat",
			"materials/camos/waw/pap/silver_etching.vmat",
			"materials/camos/waw/pap/silver_etching.vmat",
			"materials/camos/waw/pap/silver_etching.vmat",
			"materials/camos/waw/pap/silver_etching.vmat",
		} ),

		// All five of the original's tiers: green, orange, magenta, blue, red.
		new( "mello", "Crazy Place (Owlie)", new[]
		{
			null,
			"materials/camos/mello/mello_pap.vmat",
			"materials/camos/mello/mello_pap2.vmat",
			"materials/camos/mello/mello_pap3.vmat",
			"materials/camos/mello/mello_pap4.vmat",
			"materials/camos/mello/mello_pap5.vmat",
		} ),

		// Basalt's own (2026-09-28): basalt-black hexagonal plates with light running in the seams between them, one
		// colour per tier: the map's strip white, then the Easter egg's four tile colours, and the egg's purple flame at MK6.
		// Made by `Tools/basalt_hex_camo.py`; basalt's config names it (`Pap.Camo`). ⚠️ ITS SHOTS MATCH IT: the flash, the
		// tracers and the burn decals take its tier colours from `PapMuzzleFlash.NewCamoPalettes` — change one, change both.
		new( "basalt_hex", "Basalt Hex", new[]
		{
			null,
			"materials/camos/basalt/basalt_hex_mk1.vmat",
			"materials/camos/basalt/basalt_hex_mk2.vmat",
			"materials/camos/basalt/basalt_hex_mk3.vmat",
			"materials/camos/basalt/basalt_hex_mk4.vmat",
			"materials/camos/basalt/basalt_hex_mk5.vmat",
			"materials/camos/basalt/basalt_hex_mk6.vmat",
		} ),
	};

	/// <summary>The camo a map wears when its config names none: Crazy Place.</summary>
	public const string DefaultId = "mello";

	/// <summary>A camo by id, case-insensitive; null if the table has none by that name.</summary>
	public static Camo Find( string id )
		=> string.IsNullOrWhiteSpace( id ) ? null
			: Camos.FirstOrDefault( c => c.Id.Equals( id.Trim(), StringComparison.OrdinalIgnoreCase ) );

	/// <summary>
	/// The camo this map's config names (`Pap.Camo`), or null for none.
	///
	/// ⚠️ THE STRING, NOT A LOOKUP: this is read every frame by every packed gun's comparison, and the table is built on each
	/// call. A name the table does not hold is caught where it is used (<see cref="ActiveCamo"/>) and by `nz_camo_status`.
	/// </summary>
	static string MapCamoId => ActiveConfig.Pap?.Camo is { } id && !string.IsNullOrWhiteSpace( id ) ? id.Trim() : null;

	// ⛔ NULLABLE-BACKED — a static's VALUE survives a hotload but its initialiser does not re-run.
	// INSTRUCTIONS.md §1.
	static string _activeId;

	/// <summary>
	/// Which camo packed weapons wear: `nz_camo_set &lt;id&gt;`'s for this session if one was set, else the one this map's
	/// config names (`Pap.Camo`, basalt's `basalt_hex`), else Crazy Place.
	///
	/// ⚠️ CRAZY PLACE BY DEFAULT. It varies per tier, so a packed weapon's MK level reads off the gun itself — green, orange,
	/// magenta, blue, red — which Silver Etching cannot do with its single material. Silver Etching stays in the table and
	/// is one `nz_camo_set waw_etching` away.
	///
	/// ⛔ THE CONSOLE'S CHOICE WINS AND OUTLIVES A MAP CHANGE: `_activeId` is a static, so it survives a hotload too. `nz_camo_set
	/// auto` hands the choice back to the map.
	/// </summary>
	public static string ActiveId
	{
		get => _activeId ?? MapCamoId ?? DefaultId;
		set => _activeId = value;
	}

	/// <summary>The active camo: <see cref="ActiveId"/>'s, or Crazy Place if that names none in the table.
	///
	/// ⛔ NOT `Active` — that name hides `Component.Active`, the same collision `CamoEnabled` was
	/// renamed for. A static shadowing a base instance member compiles and then means something
	/// different depending on where it is read from.</summary>
	public static Camo ActiveCamo
		=> Find( ActiveId ) ?? Find( DefaultId ) ?? Camos.FirstOrDefault();

	/// <summary>Where <see cref="ActiveId"/> came from, for the console.</summary>
	static string ActiveSource
		=> _activeId is not null ? "set from the console (nz_camo_set auto hands it back to the map)"
			: MapCamoId is not null ? "this map's config (Pap.Camo)"
			: "the game's default";

	// ⛔ CACHES MISSES TOO, keyed by path. `Material.Load` returning null means the asset did not
	// compile, and retrying it every frame on every weapon turns one bad path into a per-frame cost.
	// A dictionary that stores the null separates "not looked up" from "looked up, absent".
	static Dictionary<string, Material> _cache;

	/// <summary>The material for a Pack-a-Punch level, or null when there is nothing to wear.</summary>
	public static Material MaterialFor( int level )
	{
		var camo = ActiveCamo;
		if ( camo is null || level <= 0 ) return null;

		// ⚠️ CLAMPED, NOT BOUNDS-CHECKED AWAY. A camo table shorter than PapMaxLevel should wear its
		// top tier rather than turn the gun plain at the highest upgrade, which would read as the
		// camo breaking exactly when you finished paying for it.
		var path = camo.Levels[System.Math.Min( level, camo.Levels.Length - 1 )];
		if ( string.IsNullOrEmpty( path ) ) return null;

		_cache ??= new Dictionary<string, Material>();
		if ( _cache.TryGetValue( path, out var cached ) ) return cached;

		var mat = Material.Load( path );
		_cache[path] = mat;

		if ( mat is null )
			Log.Warning( $"[nz-camo] material not found: {path} — packed weapons stay plain" );

		return mat;
	}

	static bool? _camoEnabled;
	/// <summary>Master switch — `nz_camo 0` to compare a packed gun against its plain finish.</summary>
	public static bool CamoEnabled { get => _camoEnabled ?? true; set => _camoEnabled = value; }

	/// <summary>
	/// Packs whose weapons never wear a Pack-a-Punch camo. Destiny.
	///
	/// ⛔️ A PACK-LEVEL RULE, NOT 73 PER-WEAPON FLAGS. Destiny's guns carry their own finishes and
	/// a camo painted over them reads as the model breaking rather than as an upgrade -- and it is
	/// the whole pack, so the pack is the thing to name. Adding another is one string.
	///
	/// ⚠️ THE CAMO IS A `MaterialOverride` ON THE WHOLE RENDERER, which is why this has to be an
	/// opt-out rather than something the material could handle. Repaint replaces every material on
	/// the gun at once, so there is no way for one part -- a sight, a scope lens -- to keep its own.
	/// That is also why Destiny's optics looked wrong packed.
	///
	/// ⚠️ Case-insensitive against `WeaponLibrary.Entry.Pack`, matching AdsCenterDot.ForPack, which
	/// is the working precedent for selecting a pack at runtime.
	/// </summary>
	/// ⚠️ CODOL-XGG ADDED 2026-09-23, AND FOR A SHARPER VERSION OF DESTINY'S REASON. Those 44
	/// weapons ARE skins — five AACs, four AK117s and six M1887s that differ from their siblings in
	/// nothing but their finish, which is also the only thing their names describe. A camo painted
	/// over the whole renderer does not just look wrong on one gun; it makes Steampunk, Graffiti
	/// and QQ Browser the same object, and packing one would quietly undo the work that told them
	/// apart.
	public static HashSet<string> NoCamoPacks { get; set; }
		= new( StringComparer.OrdinalIgnoreCase ) { "Destiny", "CODOL-XGG" };

	/// <summary>
	/// Does this weapon's pack allow a camo at all?
	///
	/// ⚠️ NULL PREFAB MEANS "NOT STAMPED YET", AND THAT MUST NOT MEAN "EXCLUDED". WeaponSource is
	/// written a frame or two after the clone exists, so treating an unknown prefab as excluded
	/// would leave a gun plain until something else forced a repaint. Unknown wears the camo --
	/// the same call AdsCenterDot makes, one line the other way.
	/// </summary>
	public static bool PackWearsCamo( Weapon wep )
	{
		if ( NoCamoPacks is not { Count: > 0 } ) return true;

		var prefab = Rarity.PrefabOf( wep );
		if ( string.IsNullOrEmpty( prefab ) ) return true;

		var pack = WeaponLibrary.Find( prefab )?.Pack;
		return string.IsNullOrEmpty( pack ) || !NoCamoPacks.Contains( pack );
	}

	// ── what is currently painted ────────────────────────────────────────────────────────────

	Weapon _weapon;
	int _level = -1;
	SkinnedModelRenderer _view;
	SkinnedModelRenderer _world;
	bool _paintedWhileEnabled;
	string _paintedCamo;
	bool _paintedPackWears = true;

	protected override void OnUpdate()
	{
		_weapon ??= Components.Get<Weapon>( FindMode.EverythingInSelf );
		if ( !_weapon.IsValid() ) return;

		int level = LevelFor( _weapon );
		var view = _weapon.ViewModelRenderer;
		var world = _weapon.WorldModelRenderer;

		// ⚠️ THE RENDERERS AND THE ACTIVE CAMO ARE BOTH IN THE COMPARISON, not just the level. A
		// rebuilt viewmodel has the same level and a different instance; `nz_camo_set` changes
		// neither. Either one alone leaves the gun wearing the wrong thing.
		// ⚠️ IN THE COMPARISON TOO, not only in the paint below. It is resolved from WeaponSource,
		// which is stamped AFTER the clone exists, so its answer can change from "wears one" to
		// "does not" a frame or two in — and a cached comparison that ignored it would leave the
		// camo painted on for the rest of the weapon's life.
		var wearsCamo = PackWearsCamo( _weapon );

		if ( level == _level && view == _view && world == _world
			&& CamoEnabled == _paintedWhileEnabled && ActiveId == _paintedCamo
			&& wearsCamo == _paintedPackWears )
			return;

		_level = level;
		_view = view;
		_world = world;
		_paintedWhileEnabled = CamoEnabled;
		_paintedCamo = ActiveId;
		_paintedPackWears = wearsCamo;

		Repaint( CamoEnabled && wearsCamo ? MaterialFor( level ) : null );
	}

	/// <summary>Paint, or strip back to the model's own materials when handed null.</summary>
	void Repaint( Material mat )
	{
		if ( _view.IsValid() ) _view.MaterialOverride = mat;
		if ( _world.IsValid() ) _world.MaterialOverride = mat;
	}

	/// <summary>
	/// This weapon's Pack-a-Punch level.
	///
	/// ⚠️ FROM THE WEAPON'S OWN STAMP, falling back to StartingWeapon — the same resolution
	/// `ApplyTechPassives` uses (NZPlayer.cs:2086). PaP levels are per PREFAB and a player can carry
	/// two guns, so reading the held weapon's prefab here would paint the holstered one with the
	/// other's upgrade.
	/// </summary>
	static int LevelFor( Weapon wep )
	{
		var player = wep.Components.GetInAncestors<NZPlayer>( true );
		if ( !player.IsValid() ) return 0;

		var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
		var prefab = string.IsNullOrEmpty( src ) ? player.StartingWeapon : src;
		return player.PapLevelFor( prefab );
	}

	// ── console ──────────────────────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_camo [0/1]` — turn the camo off to see the plain gun, or back on.
	///
	/// ⚠️ NO ARGUMENT TOGGLES, matching `nz_score_pin`. The repaint happens on the next frame because
	/// <see cref="CamoEnabled"/> is part of the comparison above — nothing needs poking.
	/// </summary>
	[ConCmd( "nz_camo" )]
	public static void CamoCmd( int on = -1 )
	{
		CamoEnabled = on < 0 ? !CamoEnabled : on != 0;
		Log.Info( $"[nz-camo] {(CamoEnabled ? "ON" : "off")} — {ActiveCamo?.Name ?? "no camo"}" );
	}

	/// <summary>
	/// `nz_camo_set &lt;id|auto&gt;` — switch which camo packed weapons wear, for this session; `auto` goes back to the map's own.
	/// No argument lists them.
	///
	/// ⚠️ REFUSES AN UNKNOWN ID RATHER THAN STORING IT. `ActiveCamo` falls back to Crazy Place when
	/// the id does not resolve, so a typo would silently paint the wrong camo while the command
	/// reported success.
	/// </summary>
	[ConCmd( "nz_camo_set" )]
	public static void CamoSetCmd( string id = "" )
	{
		if ( string.IsNullOrWhiteSpace( id ) )
		{
			Log.Info( $"[nz-camo] active: {ActiveId} — {ActiveSource} · this map's config names {MapCamoId ?? "none"}" );
			foreach ( var c in Camos )
			{
				var tiers = string.Join( ", ", c.Levels.Skip( 1 )
					.Select( ( p, i ) => $"MK{i + 1} {(p is null ? "-" : p[(p.LastIndexOf( '/' ) + 1)..])}" ) );
				Log.Info( $"[nz-camo]   {c.Id,-14} {c.Name,-24} {tiers}" );
			}
			return;
		}

		if ( id.Trim().Equals( "auto", StringComparison.OrdinalIgnoreCase ) )
		{
			_activeId = null;
			Log.Info( $"[nz-camo] back to the map's choice -> {ActiveId} ({ActiveSource})" );
			return;
		}

		var found = Find( id );
		if ( found is null )
		{
			Log.Warning( $"[nz-camo] no camo '{id}' — have: "
				+ string.Join( ", ", Camos.Select( c => c.Id ) ) );
			return;
		}

		ActiveId = found.Id;
		Log.Info( $"[nz-camo] active camo -> {found.Id} ({found.Name}) for this session — nz_camo_set auto for the map's own" );
	}

	/// <summary>
	/// `nz_camo_map &lt;id|none&gt;` — the camo THIS MAP'S CONFIG names (`Pap.Camo`), in memory like every other tool edit;
	/// `nz_save` keeps it. `none` clears it back to the game's default. Also the "Camo" row in the Pack-a-Punch settings.
	/// </summary>
	[ConCmd( "nz_camo_map" )]
	public static void CamoMapCmd( string id = "" )
	{
		var pap = ActiveConfig.Pap;
		if ( pap is null ) { Log.Warning( "[nz-camo] no config loaded" ); return; }

		if ( string.IsNullOrWhiteSpace( id ) )
		{
			Log.Info( $"[nz-camo] this map's config names {(string.IsNullOrWhiteSpace( pap.Camo ) ? "none (the game's default)" : pap.Camo)}"
				+ $" · wearing {ActiveId} ({ActiveSource})" );
			return;
		}

		if ( id.Trim().Equals( "none", StringComparison.OrdinalIgnoreCase ) )
			pap.Camo = "";
		else if ( Find( id ) is { } found )
			pap.Camo = found.Id;
		else
		{
			Log.Warning( $"[nz-camo] no camo '{id}' — have: " + string.Join( ", ", Camos.Select( c => c.Id ) ) );
			return;
		}

		ActiveConfig.NotifyChanged();
		Log.Info( $"[nz-camo] this map's config -> {(pap.Camo.Length == 0 ? "none" : pap.Camo)} (nz_save keeps it)"
			+ $" · wearing {ActiveId} ({ActiveSource})" );
	}

	/// <summary>
	/// `nz_camo_status` — what every carried weapon is wearing and why.
	///
	/// ⚠️ IT PRINTS THE RENDERERS, not just the level. "Level 2, override null" and "level 0,
	/// override set" are different bugs, and a level-only report cannot tell them apart.
	/// </summary>
	/// <summary>
	/// `nz_camo_packs [pack] [0|1]` — which packs wear a camo. No argument lists them.
	///
	/// ⛔️ EVERY RULE GETS A COMMAND. This one especially: it is invisible from in-game — a plain
	/// packed gun and a broken camo look identical — so without a way to ask, "Destiny has no
	/// camo" and "the camo failed to load" are the same picture.
	/// </summary>
	[ConCmd( "nz_camo_packs" )]
	public static void CamoPacksCmd( string pack = "", int wears = -1 )
	{
		NoCamoPacks ??= new( StringComparer.OrdinalIgnoreCase );

		if ( !string.IsNullOrWhiteSpace( pack ) )
		{
			// ⚠️ NO ARGUMENT AFTER THE NAME TOGGLES, matching nz_camo. Naming a pack is almost
			// always a request to flip it.
			var on = wears >= 0 ? wears != 0 : NoCamoPacks.Contains( pack );

			if ( on ) NoCamoPacks.Remove( pack );
			else NoCamoPacks.Add( pack );
		}

		var packs = WeaponLibrary.Packs;

		Log.Info( $"[nz-camo] camo {(CamoEnabled ? "on" : "OFF")}"
			+ $" · {NoCamoPacks.Count} pack(s) excluded" );

		foreach ( var name in packs )
			Log.Info( $"[nz-camo]   {name,-24}"
				+ $" {(NoCamoPacks.Contains( name ) ? "PLAIN — no camo" : "wears the camo")}"
				+ $"   ({WeaponLibrary.All.Count( e => e.Pack == name )} weapon(s))" );

		// ⚠️ AN EXCLUDED PACK THAT IS NOT IN THE LIBRARY IS ALMOST ALWAYS A TYPO, and it fails
		// silently — the pack simply never matches and every gun keeps its camo.
		foreach ( var name in NoCamoPacks )
			if ( !packs.Contains( name, StringComparer.OrdinalIgnoreCase ) )
				Log.Warning( $"[nz-camo]   \"{name}\" is excluded but matches NO pack in the "
					+ "library — check the spelling against the list above" );
	}

	[ConCmd( "nz_camo_status" )]
	public static void CamoStatusCmd()
	{
		var player = PlayerCharacters.Local();
		if ( !player.IsValid() ) { Log.Warning( "[nz-camo] no player" ); return; }

		Log.Info( $"[nz-camo] enabled {CamoEnabled}, active {ActiveId} ({ActiveCamo?.Name}) — {ActiveSource}" );

		// ⚠️ A NAME THE TABLE DOES NOT HOLD PAINTS CRAZY PLACE, SILENTLY — said here, where someone is looking
		if ( Find( ActiveId ) is null )
			Log.Warning( $"[nz-camo]   '{ActiveId}' is not a camo in the table — wearing {ActiveCamo?.Id} instead. nz_camo_set lists them" );

		for ( int lvl = 1; lvl < (ActiveCamo?.Levels.Length ?? 1); lvl++ )
			Log.Info( $"[nz-camo]   MK{lvl} {(MaterialFor( lvl ) is null ? "MISSING" : "loaded")}"
				+ $"  {ActiveCamo.Levels[lvl]}" );

		int n = 0;
		foreach ( var wep in player.Components
			.GetAll<Weapon>( FindMode.EverythingInSelfAndDescendants ) )
		{
			n++;
			var camo = wep.Components.Get<PapCamo>( FindMode.EverythingInSelf );
			var view = wep.ViewModelRenderer;
			var world = wep.WorldModelRenderer;

			Log.Info( $"[nz-camo]   {wep.DisplayName,-22} MK{LevelFor( wep )}"
				+ $"  watcher {(camo.IsValid() ? "yes" : "NO")}"
				+ $"  view {(view.IsValid() ? (view.MaterialOverride is null ? "plain" : "camo") : "-")}"
				+ $"  world {(world.IsValid() ? (world.MaterialOverride is null ? "plain" : "camo") : "-")}" );
		}

		if ( n == 0 ) Log.Info( "[nz-camo]   no weapons carried" );
	}
}