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.
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 <id>`'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 <id|auto>` — 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 <id|none>` — 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" );
}
}