A Razor UI component for the in-game Weapon Stats panel. It gathers live data from the currently held Weapon, its components and game systems (rarity, tech/perk effects, zombie multipliers, etc.), computes display strings and fractional bar values for many weapon stats (damage, fire mode, reload, spread, recoil, aim-in, draw, income, etc.), and renders a card with labeled rows and progress tracks.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using NZombies;
@using SWB.Base;
@inherits PanelComponent
@*
WEAPON STATS — the C-key card.
Every number is READ LIVE off the held weapon's own components. Nothing here
is authored per-weapon and nothing is stored in a side file: a weapon pack
that never heard of this panel still gets correct numbers, which is the same
rule the wallbuy chalk follows.
⚠️ Some stats are BETTER WHEN LOWER (reload time, spread, aim-in time). Their
bars are inverted so a full bar always means "good" — a card where a long bar
sometimes means fast and sometimes means slow is worse than no bar at all.
*@
@* ⚠️ Gated with @if rather than an early `return` in the markup body — the same
pattern SurvivalHud uses. A bare return inside a render body is not reliably
legal once razor has wrapped it. *@
<root class="@HudTheme.Class">
@if ( Open && Held is not null )
{
<div class="card">
<div class="head">
<div class="name">@WeaponName</div>
@* RARITY, between the name and the class.
⚠️ COLOURED INLINE FROM Rarity.HexFor, not by a per-tier CSS class.
The palette has to be one list — the Arsenal's rarity tab needs the
same five colours, and a copy in this stylesheet is the copy that
goes stale (§3).
⚠️ SHOWN EVEN AT COMMON, unlike the original's HUD. There,
`nzRarity.NameColor` deliberately returns the caller's fallback at
tier 0 so an ordinary gun's name is not tinted on every frame of
play. This panel is the opposite situation: it is a screen you open
ON PURPOSE to ask what a weapon is, and omitting the tier would
leave you unable to tell "Common" from "this panel does not show
rarity". The original's own Arsenal always shows it too. *@
<div class="rarity" style="color: @Rarity.HexFor( RarityTier )">
@Rarity.NameFor( RarityTier ).ToUpper()
</div>
@* The class sits opposite the name, because the bars are only
meaningful relative to it — a full Damage bar means "best in
class", and the reader needs to know which class. *@
@* ⛔ AND CHIMERA IS NAMED HERE, ON THE CLASS, BECAUSE THE CLASS IS WHAT IT
INVALIDATES. Every bar on this card is a class comparison (see the note at
the top of Stats), and `t5_chimera` replaces eight of the fields those bars
read with values drawn from the whole 31-weapon roster — so a Python can
legitimately show a 100-round magazine and an AWM's damage. Without a
marker that reads as the card being broken, and the roll is UNREPEATABLE,
so "is this gun a Chimera" has no other answer available in play.
`nz_tech_live` prints the drawn numbers; this only says which gun to ask
about.
⚠️ A QUALIFIER ON THE CLASS AND NOT A ROW, because it is not a magnitude —
it is the same KIND of fact as the class and the rarity, both of which
already live in this header. It also costs no new style: it rides the
`.wclass` element, whose own note explains why a fourth child would have
to fight for the header's slack. *@
<div class="wclass">@ClassLabel</div>
<div class="hint">C to close</div>
@* THE PACK THE GUN CAME FROM, ON A LINE OF ITS OWN UNDER THE NAME (`PackLabel`, 2026-09-29).
⚠️ NOT A FIFTH ITEM IN THE ROW ABOVE, whose slack the notes on `.rarity` and `.wclass` already share out:
`.head` wraps, and `.pack` is the whole width, so it starts a line of its own and that row stays as it was. *@
@if ( PackLabel.Length > 0 )
{
<div class="pack">PACK · @PackLabel</div>
}
</div>
<div class="rows">
@foreach ( var s in Stats() )
{
<div class="row">
<div class="label">@s.Label</div>
@* ⛔ ONE ROW HAS NO BAR AND IT IS NOT AN OMISSION. `Fire mode` is the
only CATEGORICAL row on this card — semi, auto, burst are not
positions on a scale, and ranking them would be inventing balance:
Bolt Gun buys semi as a COST while Overclocked buys auto as a
BENEFIT, so any ordering makes one of the two nodes read backwards.
A track that can never fill is worse than none, because this card's
rule is that a full bar means good and WeaponClassStats' own note
records that an empty track reads as "no data" or "broken".
⚠️ NO SPACER AND NO NEW STYLE. `.value` is `flex-grow: 1` and
right-aligned, so dropping the track simply widens the value column
and the figure stays flush with every other row's. *@
@if ( s.Bar )
{
<div class="track"><div class="fill" style="width: @(s.Frac * 100)%"></div></div>
}
<div class="value">@s.Value</div>
</div>
}
</div>
</div>
}
</root>
@code
{
/// <summary>Visible? Toggled by C, or by `nz_stats`.</summary>
public static bool Open { get; set; }
/// <summary>
/// Bar ceilings. A bar needs a scale and these weapons have no natural one,
/// so they are picked to sit above the pack's real maxima — the point is
/// comparing two weapons to each other, not reading an absolute.
///
/// ⚠️ WHAT IS LEFT HERE IS ONLY THE ROWS WeaponClassStats CANNOT SCALE, because
/// their fields are not in its index. Six more consts stood here — MaxDamage,
/// MaxRPM, MaxReload, MaxSpread, MaxHeadshot and MaxPenetration — and every one
/// had been dead since the bars moved to class ranges (see the note at the top of
/// Stats). A dead ceiling sitting beside live ones reads like the authority for a
/// bar it no longer scales, which is the same objection the stylesheet records
/// against keeping a second copy of the rarity palette.
/// </summary>
const float MaxAimIn = 0.6f;
// ⚠️ TWICE THE AUTHORED 0.25 — which is the value on every shipped prefab
// (enumerated: `RecoilRecoveryTime` appears 31 times and is 0.25 in all of them).
// So a stock weapon sits at half a bar and Stabilizer visibly fills it. A DISPLAY
// SCALE, like Reserve's 480, not a roster comparison: the field is not in
// WeaponClassStats' index, and with one value across the whole pack there is no
// spread to compare against even in principle.
const float MaxRecovery = 0.5f;
// ⚠️ JUST ABOVE THE LONGEST AUTHORED DRAW (enumerated across the 31 prefabs:
// `DrawTime` runs 0.5 to 1.9). Also a display scale — DrawTime is not in the class
// index either.
const float MaxDraw = 2f;
// ⚠️ THE LARGEST AUTHORED MAGAZINE ON THE ROSTER (enumerated: `ClipSize` runs 2 to
// 100), and Fabricator pays exactly one magazine a minute, so a full bar means "the
// most this node can deliver off a stock weapon". Display scale.
//
// ⚠️ AND A MAGAZINE NODE CAN NOW EXCEED IT, which is why that sentence says "stock".
// Drum Magazine writes ClipSize x3 at spawn and this row reads the live field on
// purpose — the same field TickFabricator credits — so a 300-round M60 earns 300/min
// and pegs the bar. The value is still exact; the bar is a display scale that has run
// out of headroom, the same way Penetration's class range pegs under Overpenetrator.
const float MaxIncome = 100f;
// ⚠️ `Bar` DEFAULTS TO TRUE so all twenty-three measured rows are unchanged and the one
// categorical row opts out — see the markup. A nullable Frac would have said the same
// thing and forced every reader of the record to unwrap it.
record Stat( string Label, string Value, float Frac, bool Bar = true );
// ⚠️ `Owner is not null` is the "actually held" test — a weapon lying in the
// world or parked on a wallbuy has no owner, and the card must describe the
// gun in the player's hands, not whichever one the scene lists first.
Weapon Held => Scene?.GetAllComponents<Weapon>()
.FirstOrDefault( w => w.IsValid() && w.GameObject.Enabled && w.Owner is not null );
string WeaponName
{
get
{
var w = Held;
if ( w is null ) return "—";
if ( !string.IsNullOrWhiteSpace( w.DisplayName ) ) return w.DisplayName.ToUpper();
// ⚠️ Fall back to the class name, which is always `nz_<something>`.
var n = w.ClassName ?? "";
if ( n.StartsWith( "nz_" ) ) n = n[3..];
return n.Replace( "_", " " ).ToUpper();
}
}
/// <summary>
/// The held weapon's reserve CAPACITY, which is what Deep Pockets raises.
///
/// ⛔ `MaxReserve`, NOT `Reserve`. `Reserve` is how much ammo you are carrying right
/// now, which falls as you shoot — a stats panel showing that would drop while you
/// fired and read as the weapon getting worse. Capacity is the weapon STAT.
///
/// ⛔ AND IT IS `NZAmmo`, NOT `NZWeapon`. NZWeapon is the legacy placeholder gun and
/// appears on ZERO of the 31 weapon prefabs; every real weapon carries NZAmmo. That
/// wrong field was in the tech catalogue and in nz_tech_live before it was caught,
/// and it fails SILENTLY — it compiles and reads null.
///
/// ⚠️ `EverythingInSelf` because a holstered weapon is a DISABLED component and the
/// default Get skips those.
/// </summary>
int ReserveValue
{
get
{
var w = Held;
if ( w is null ) return 0;
var ammo = w.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
return ammo.IsValid() ? ammo.MaxReserve : 0;
}
}
string Reserve => ReserveValue > 0 ? $"{ReserveValue}" : "—";
/// <summary>
/// The held weapon's rarity tier, 0-4.
///
/// ⚠️ THE STORED TIER, for the NAME only — the damage row deliberately reads
/// the live `si.RarityMultiplier` instead. They are two different questions: this
/// is "what does this weapon own", the damage row is "what is the gun doing". If
/// they ever disagree the panel shows a rarity name with unmoved damage, which is
/// the honest picture; `nz_rarity` is the command that names the fault.
///
/// ⚠️ EXCEPT THE PRISMA, which reads Legendary and hits at x1 ON PURPOSE — its damage is the
/// number on its prefab (`Rarity.DamageMult`). That one disagreement is not a fault.
///
/// ⚠️ Goes through Rarity.PrefabOf rather than resolving WeaponSource here —
/// that lookup needs `EverythingInSelf` to see a holstered weapon's disabled
/// component, and it is a trap NZPlayer records having cost a bug three times.
/// </summary>
int RarityTier
{
get
{
var w = Held;
if ( w is null ) return 0;
var nz = NZPlayer.Local;
return Rarity.TierOf( nz, w );
}
}
/// <summary>
/// A live zombie's Health — the source for every figure that lives on the VICTIM
/// rather than on the gun. Null on an empty map.
///
/// ⛔ IT EXCLUDES THE PLAYER, AND THAT IS THE WHOLE REASON THIS EXISTS. Both
/// accessors below took the FIRST `NZombies.Health` in the scene, and the player
/// carries one too — Armor, HealthRegen and three places in PerkEffects all read
/// `Components.Get<Health>()` off the player. So "the zombie's headshot scale" could
/// be the PLAYER's, and it read correctly only because the class's defaults are
/// identical for both. The first person to tune a zombie prefab's head or limb
/// values would have got the untouched defaults here and no hint why.
///
/// ⚠️ Tested by ANCESTRY, not by looking for ZombieAI. Whether Health sits on the
/// same GameObject as the AI is a layout detail of the zombie prefab; "does an
/// NZPlayer own this" is a fact about the thing itself and stays true if the
/// hierarchy is rearranged.
///
/// ⚠️ Fully qualified — `Sandbox` is also imported and a bare `Health` would be at
/// the mercy of whatever the engine adds under that name.
/// </summary>
NZombies.Health ZombieHealth => Scene?.GetAllComponents<NZombies.Health>()
.FirstOrDefault( x => x.IsValid()
&& !x.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ).IsValid() );
/// <summary>
/// The base headshot multiplier every zombie carries.
///
/// ⚠️ Read off a LIVE zombie when there is one, because the value is a
/// per-Health property and a round could conceivably run with it tuned. The
/// literal is only the fallback for an empty map — a hardcoded 2.5 here would
/// quietly disagree with the damage that actually lands.
/// </summary>
float ZombieHeadshot
{
get
{
var h = ZombieHealth;
return h.IsValid() ? h.HeadshotDamageScale : 2.5f;
}
}
/// <summary>
/// The zombie's AUTHORED torso multiplier, before Body Shot and Deadeye.
///
/// ⚠️ Off a LIVE zombie for the reason ZombieHeadshot and ZombieLimbs are: it is a
/// `[Property]` on Health, a boss variant may author it differently, and a literal
/// here would quietly disagree with the damage that lands. 1.0 is the shipped
/// default and the empty-map fallback, nothing more.
/// </summary>
float ZombieTorso
{
get
{
var h = ZombieHealth;
return h.IsValid() ? h.TorsoMultiplier : 1f;
}
}
/// <summary>
/// The zombie's AUTHORED limb and extremity damage multipliers, before Hollow Points.
///
/// ⚠️ Off a LIVE zombie for exactly the reason ZombieHeadshot is: these are
/// `[Property]` fields on Health, a variant may author them differently, and
/// literals here would quietly disagree with the damage that lands. The literals
/// are the empty-map fallback and nothing else.
///
/// ⚠️ `Limb` IS THE MIN OF ARM AND LEG, not one of them. Health.PartMultiplier
/// floors arms and legs separately, so a card showing only ArmMultiplier would be
/// wrong for legs the moment the two diverge — the min is the figure that cannot
/// overstate either. Both are 0.75 on the shipped Health, so today they agree.
/// </summary>
(float Limb, float Extremity) ZombieLimbs
{
get
{
var h = ZombieHealth;
return h.IsValid()
? (MathF.Min( h.ArmMultiplier, h.LegMultiplier ), h.ExtremityMultiplier)
: (0.75f, 0.5f);
}
}
/// <summary>
/// The held weapon's class, from `manifest.json`.
///
/// ⚠️ Keyed on the SOURCE PREFAB, not the display name — Pack-a-Punch renames a
/// gun to "M1911 MK2" and a name lookup would lose its class the moment it was
/// upgraded, taking every bar's comparison basis with it.
/// </summary>
string WeaponClass
{
get
{
var src = Held?.GameObject.Components
.Get<WeaponSource>( FindMode.EverythingInSelf );
return src is null ? "" : WeaponClassStats.ClassOf( src.Prefab ).ToUpper();
}
}
/// <summary>The prefab this weapon came from, for class comparisons.</summary>
string SourcePrefab => Held?.GameObject.Components
.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
/// <summary>
/// The pack the held weapon came from — "BLACK OPS III", "INSURGENCY" — out of the weapon manifest (`WeaponLibrary`), or ""
/// when the manifest does not list its prefab (a hand-made one), and then the line is not drawn. Asked for as *"can you add
/// the weapon pack the weapon is from"* (2026-09-29).
///
/// ⚠️ BY THE SOURCE PREFAB, as the class is (`WeaponClass`): Pack-a-Punch renames the gun, and its pack does not change.
/// </summary>
string PackLabel => (WeaponLibrary.Find( SourcePrefab )?.Pack ?? "").Trim().ToUpperInvariant();
/// <summary>
/// Has Chimera been ROLLED on this weapon's prefab.
///
/// ⛔ THE ROLL, NOT THE NODE. `NZPlayer.RollChimera` stores nothing when the pool
/// returns null (an empty roster, or an axis with no donor), and its own note says the
/// node stays owned while the weapon runs its authored stats — so `TechEffects.Has` would
/// mark a gun as a Chimera whose numbers are entirely its own. `nz_tech_live` prints that
/// state as an error; this card must not claim it as a success.
///
/// ⚠️ THROUGH THE `rolled` MARKER, which is the contract that constant documents: an axis
/// can legitimately draw the value the weapon already had, so key presence alone cannot
/// tell "rolled and got its own numbers back" from "never rolled".
///
/// ⚠️ KEYED ON THE SOURCE PREFAB, like the rarity tier and the class, because the roll
/// lives on the player keyed that way — Pack-a-Punch destroys and respawns the weapon
/// clone and a marker read off the instance would vanish with the first upgrade.
/// </summary>
bool IsChimera
{
get
{
var prefab = SourcePrefab;
if ( string.IsNullOrEmpty( prefab ) ) return false;
var nz = NZPlayer.Local;
return nz.IsValid()
&& nz.ChimeraRolls.TryGetValue( prefab, out var roll )
&& roll.ContainsKey( NZPlayer.ChimeraRolled );
}
}
/// <summary>The class, plus Chimera's caveat on it. See the header markup.</summary>
string ClassLabel => IsChimera ? $"{WeaponClass} · CHIMERA" : WeaponClass;
Stat[] Stats()
{
var w = Held;
var si = w?.Primary;
if ( si is null ) return Array.Empty<Stat>();
// ⛔ THE CLASS IS THE SCALE NOW. Bars used to run against fixed ceilings
// (MaxDamage, MaxRPM…), which made every pistol look feeble and every LMG
// look maxed — true, and useless for the decision the card exists to support,
// which is "is this a good ONE OF THESE". The weakest in a class reads 10%,
// the strongest 100%, everything else linear between.
var cls = WeaponClassStats.ClassOf( SourcePrefab );
// ── damage ──────────────────────────────────────────────────────────
// A shotgun's damage is meaningless per pellet, so multi-projectile
// weapons show the arithmetic rather than a single number the player
// would have to know to multiply.
//
// ⛔ THROUGH THE PACK-A-PUNCH MULTIPLIER. `si.Damage` is the AUTHORED value
// and never changes — the upgrade lives in `DamageMultiplier`, which is what
// `DamageFor` actually reads. Showing the raw field meant a 30,000-point MK3
// displayed exactly the same number as an unpacked gun, which reads as the
// upgrade having done nothing.
// ⛔ AND THROUGH VIGOR RUSH. Same reason as Pack-a-Punch above: the panel
// must show what the gun actually does in your hands, not the authored
// number. `nz` is looked up further down for the reload line — declared
// here so both use it.
//
// ⚠️ NOW THE ONLY PLAYER LOOKUP IN THIS METHOD, and it carries `IsValid()` — the
// filter the second one (`player`, for the aim-walk row) had and this one did not.
// The Move speed row needs the player as well, and three scene sweeps for one
// component that also has to answer `IsValid` differently in each is how two of them
// end up disagreeing about whether there is a player at all.
var nz = NZPlayer.Local;
// ⛔ AND THROUGH RARITY. Read off the LIVE ShootInfo rather than recomputed
// from the stored tier — `si.RarityMultiplier` is what DamageFor actually
// multiplies by, so if the tier ever failed to reach the gun this figure shows
// the truth instead of covering for it. Recomputing `Rarity.Mult(tier)` here
// would make the panel agree with itself and prove nothing, which is exactly
// how Double Tap, Vigor Rush and Deadshot stayed dead for months (§2).
// ⛔ AND THROUGH MICRO-BURST — THE ONLY TIER-4 DAMAGE NODE THIS ROW HAS TO
// COMPUTE. Every other one is SPAWN-TIME, and they are, enumerated rather than
// counted because this comment has already carried a stale count once: Tuned
// Action, All-Rounder, Scattergun, Solid Slug, Overpressure, Body Shot (x1.5) and
// Last Resort (x3). NZPlayer.ApplyShotTech writes
// `si.Damage = TechBase( … ) * dmgFactor * slugBonus` onto both ShootInfos on every
// equip (read there — verified), so `si.Damage` above IS their product and a factor
// here would DOUBLE COUNT them — the fault the Magazine row records for Extra
// Rounds. Micro-Burst is the exception: Weapon.Shoot sets
// `shootInfo.DamageMultiplier = authored * burstFactor` inside the bullet loop and
// restores it in a `finally` (read Weapon.Shoot.cs), so `si.DamageMultiplier` above
// is never the burst value and the node would otherwise be invisible here.
//
// ⚠️ THE FLAT +40% ONLY, NOT THE CONDITIONAL x2. BurstDamageFactor applies
// BurstHitBonus only on round two, and only when round one connected — per-shot
// state, not a property of the loadout, which is the same objection that keeps
// Double Points off Body kill and GetRealSpread's movement terms off Hip spread.
// This figure is what ONE round of the burst deals; the second can deal twice it.
//
// ⛔ AND THE TEN-ROUND RAMP IS NOT HERE EITHER, FOR THE SAME REASON AND WITH A
// SHARPER EDGE. `t4_tenburst`'s -33% IS in this figure — it is spawn-time, stamped
// onto `si.Damage` by ApplyShotTech (NZPlayer.cs, read there) like the spawn-time
// tier-4 damage nodes enumerated above. Its +20% STEP is not: BurstDamageFactor multiplies
// `MathF.Pow( ramp, round )` where `round` is the index of the round in flight (read
// Weapon.Shoot.cs — `BurstDamageRamp` returns the step only when Micro-Burst is owned
// too, and `BurstHitBonus` stands down when it does), so there is no single figure: on
// the pair, round one deals what is printed here and round ten deals 1.20^9 = 5.16x
// it. A row cannot hold a curve, and averaging one would name a number no round ever
// deals. The `Fire mode` row prints the burst LENGTH, which is what makes the curve
// readable at all.
var perShot = si.Damage * si.DamageMultiplier * si.RarityMultiplier
* PerkEffects.BulletDamageMultiplier( nz )
* TechEffects.Factor( w, "t4_microburst" );
// ⛔ MULTIPLIED INTO `perShot`, NOT SHOWN AS ITS OWN FACTOR. It was a separate
// `× 1.5` on the line for about ten minutes and that is worse: the row then carried
// both the unaugmented number and the multiplier, so the figure the player has to
// read — what one pellet actually deals right now — was the one thing not printed.
// A stat row's job is to answer "how hard does this hit", not to show its working
// for one term while hiding it for the six others already inside `perShot`.
perShot *= NZombies.DtapAugments.TriggerDamageFor( w );
// ⛔ AND SPEED COLA'S M3 "ADRENALINE", WHICH WAS MISSING. It rides the same
// `DamageMultiplier` seam as Trigger Discipline — set inside the bullet loop and
// restored in a `finally` — so `si.DamageMultiplier` above is never its value and the
// augment was invisible here. Same class of gap as Micro-Burst, which this row
// already computes explicitly for the same reason.
perShot *= NZombies.SpeedColaAugments.AdrenalineFor( w );
// ⛔ AND VIGOR RUSH'S M4 AND m5, WHICH WERE MISSING. The perk's BASE multiplier was
// already here (via `PerkEffects.BulletDamageMultiplier`, which is where M1 Overkill
// also lands) but these two multiply in `Health.Apply`, downstream of anything this
// row reads.
//
// ⛔ M3 "POINT BLANK" IS DELIBERATELY EXCLUDED, and it is the reason
// `CertainDamageScale` exists as a separate method. This row states what the next shot
// deals; M3 depends on how far away the thing you have not shot yet happens to be, so
// NO number the card could print would be true — its best case overstates every shot
// beyond contact, and ×1 understates every shot at contact. `nz_aug_vigor` prints the
// falloff at three distances, which is where an answer that depends on a distance
// belongs.
//
// ⚠️ THE SAME RULE ALREADY KEEPS THE CHANCE-BASED AUGMENTS OFF THIS CARD — Lucky
// Shot, Lucky Hands, Overflow, Concussion, Conservation. A row is a statement, not a
// distribution.
perShot *= NZombies.VigorAugments.CertainDamageScale( nz );
// ⛔ AND THROUGH DOUBLE TAP'S M3 "TRIGGER DISCIPLINE", LIVE — THE ONE PER-SHOT
// FIGURE THIS ROW CARRIES, BY REQUEST. Every other transient is deliberately kept
// off the card: the burst's conditional x2, Ten-Round's +20% step, Double Points and
// GetRealSpread's movement terms are all absent because per-shot state is not a
// property of the loadout and a row cannot hold a curve.
//
// ⚠️ THE EXCEPTION IS DEFENSIBLE AND THE REASON IS THE PANEL'S OWN MECHANICS, not a
// change of principle. Those other transients are unobservable here — a burst's
// second round and a ten-round ramp both resolve inside one trigger pull, far faster
// than anyone can read a number. A one-SECOND ramp on a held trigger is slow enough
// to watch climb, and the card is a toggled HUD overlay (C) rather than a menu, so
// it can be open while firing. A figure that visibly moves is the augment's
// feedback; a static ceiling would have been the thing that named a number no shot
// deals.
//
// ⚠️ IT REPAINTS BECAUSE `BuildHash` ALREADY HASHES EVERY STAT'S VALUE STRING. No
// hash change was needed — the damage string changes as the ramp climbs, so the card
// rebuilds on its own. Worth knowing before adding a term for it.
//
// ⚠️ AND IT IS SHOWN AS A FACTOR, NOT FOLDED IN. `2 × 8 × 30 × 1.5 = 720` is this
// row's existing philosophy — a multi-projectile figure is meaningless without its
// factors — and it also means the ramp cannot be mistaken for the gun's base damage
// having changed.
//
// ⛔ AND THROUGH DOUBLE TAP'S M1 "DOUBLE FIRE", WHICH IS THE ONE THING ON THIS ROW
// THAT IS NOT A DAMAGE MULTIPLIER AT ALL. The augment adds an outer pass over
// Weapon.Shoot's bullet loop, so it multiplies how many projectiles leave the
// barrel and never touches `si.Bullets` — deliberately, because `GetRealSpread`
// branches on `Bullets == 1` for the ADS bonus. That means the field this row reads
// is honest about the prefab and silent about the augment, and a player with M1
// equipped would see the unaugmented figure while doing twice the damage.
//
// ⚠️ THE SHOT COUNT IS SHOWN, NOT FOLDED INTO ONE NUMBER. `2 × 8 × 30` says a
// doubled shotgun; `480` says nothing about where it came from, and the whole
// purpose of the arithmetic-instead-of-a-total treatment on this row is that a
// multi-projectile figure is meaningless without its factors.
var shots = NZombies.DtapAugments.ShotMultiplierFor( w );
// ⛔ ROUNDED TO WHOLE NUMBERS, AND THE TOTAL IS DERIVED FROM THE ROUNDED PER-SHOT
// RATHER THAN ROUNDED SEPARATELY. Rounding both independently prints equations that
// do not add up — `8 × 45 = 362` — and a card that visibly cannot multiply is worse
// than one that is a damage point out. This way the arithmetic on screen is always
// exact and the total is within a rounding error of the real figure.
//
// ⚠️ Damage on all 31 prefabs is in the tens, and every multiplier here is ≥ 1, so
// there is no weapon this can round to zero. If one is ever authored at under 1
// damage this line needs a floor, not a decimal.
var shownPerShot = MathF.Round( perShot );
var total = shownPerShot * si.Bullets * shots;
// ⛔ BUILT FROM A FACTOR LIST RATHER THAN A NESTED TERNARY, because two optional
// factors is four cases and the arm that got the formatting wrong would be one
// nobody had a weapon to reproduce.
//
// ⚠️ THE ONLY FACTORS ARE COUNTS — projectiles per shot, and shots per trigger
// pull. Every MULTIPLIER is inside `shownPerShot`: Pack-a-Punch, rarity, Vigor Rush,
// Micro-Burst and Trigger Discipline. That is the line: a count tells the player
// something the total cannot (a shotgun's 8 is why its per-pellet figure looks
// small), whereas a multiplier just restates part of a number already on screen.
var factors = new System.Collections.Generic.List<string>();
if ( shots > 1 ) factors.Add( $"{shots}" );
if ( si.Bullets > 1 ) factors.Add( $"{si.Bullets}" );
factors.Add( $"{shownPerShot:0}" );
var damage = factors.Count > 1
? $"{string.Join( " × ", factors )} = {total:0}"
: $"{total:0}";
// ── blast ───────────────────────────────────────────────────────────
// Explosive Rounds' positive half, and the ONLY place it can appear: the -40% is
// spawn-time and already inside `perShot` above, so without this row the node reads
// as a pure downgrade on every screen the player has.
//
// ⛔ THE SAME EXPRESSION `TechBlast.TryBlast` USES, NOT A RESTATEMENT OF IT:
// `shootInfo.DamageFor( 0f, null ) * WeaponTech.BoundOf( "t5_explosive", 0.5f )` —
// distance 0 and no hit tags, because the blast is not a headshot and carries no
// falloff of its own. `DamageFor` is the chokepoint that applies Pack-a-Punch and
// rarity, so this figure composes with both exactly as the blast does.
//
// ⚠️ THE 0.5 IS A FALLBACK AND IT IS THE THIRD COPY OF ONE. `t5_explosive` declares no
// `Bound`, so `BoundOf` returns this literal; TechBlast holds the same fallback in its
// own `DamageShareFallback` const, and Overpressure's 4f and Deadeye's 0.5 on this
// card are the same situation. Declaring `Bound = 0.5f` on the node makes all of them
// unreachable and puts the number in the table `nz_tech` prints. Reported, not hidden.
//
// ⚠️ NO PERK TERM, AND THAT IS DELIBERATE RATHER THAN AN OMISSION: the blast is not a
// bullet. TechBlast stamps its own `blast` tag instead of `TagsHelper.Bullet` — its
// note says borrowing that tag would hand Vigor Rush a second multiply — so
// `PerkEffects.BulletDamageMultiplier`, which IS in `perShot` above, must not be here.
//
// ⚠️ PER BLAST AND PER ZOMBIE IN RANGE, WHICH IS NOT THE SAME AS PER SHOT. TechBlast
// rate-limits to one blast per weapon per 0.15 s, so above ~400 RPM not every round
// produces one — and neither that interval nor the 70-unit radius can be shown here,
// because both are private consts in that file with no catalogue home. The radius is
// the same on all 31 weapons anyway; the rate limit is not, and it is named in the
// report as the reason this row can overstate a fast weapon.
var blast = TechEffects.Has( w, "t5_explosive" )
? si.DamageFor( 0f, null ) * WeaponTech.BoundOf( "t5_explosive", 0.5f )
: 0f;
// ── fire mode ───────────────────────────────────────────────────────
// ⛔ THE CARD HAS NEVER SHOWN A FIRE MODE, AND FIVE TIER-5 NODES WRITE ONE. Bolt Gun,
// Overclocked, Ten-Round Burst and Ricochet Rounds each convert it, Chimera draws one,
// and Micro-Burst forces burst — and before this row NO diagnostic in the project
// could observe any of them except the log line in `nz_tech_live`. Two of those nodes
// have no other visible effect at all on half the roster (Bolt Gun's conversion is a
// no-op on the 15 authored `semi` weapons, Overclocked's on the 15 authored `auto`),
// so on those weapons this row is the difference between a node and a rumour.
//
// ⛔ IT ASKS `EffectiveFiringType`, THE SWITCHBOARD ITSELF, rather than re-deriving the
// precedence. That method is `public virtual`, it owns the documented descending-volume
// tie-break, and it warns once when a creative weapon holds more than one mode node —
// a copy of that ladder here would be a second place for the resolution to drift, and
// this file's Double Tap block is what a second copy costs.
//
// ⚠️ CHIMERA'S MODE AXIS IS ON THIS ROW SINCE 2026-10-03, AND ONLY BECAUSE IT IS IN THE GUN.
// `EffectiveFiringType` substitutes the rolled mode for the authored one (TechEffects.ChimeraMode),
// so this row shows it with "(was …)" like any other conversion. Until then the roll stored a mode
// the weapon never fired, and this note kept the card from printing it.
//
// ⚠️ THE AUTHORED MODE IS SHOWN BESIDE IT WHEN THEY DIFFER, which is how this row
// answers "did the node do anything on THIS weapon". Nothing else on the card can:
// buying Bolt Gun on an AWM changes the rate and the clip but leaves the mode exactly
// as authored, and "Semi" with no qualifier is the honest way to say so.
//
// ⚠️ BURST LENGTH COMES FROM `BurstRoundsFor`, also `public virtual`, so the pairing
// arithmetic stays in one place: 3 authored, 2 under Micro-Burst alone, 10 whenever
// Ten-Round Burst is owned. It is the only figure on the card that makes the Damage
// row's ramp note readable.
var fireMode = w.EffectiveFiringType( si );
// ⚠️ STAMIN-UP'S M4 "RUN & GUN" IS APPENDED AS A NOTE, not folded into the mode. It
// does not change WHAT the weapon fires, it changes WHEN you are allowed to — so a
// mode name cannot carry it, and it would otherwise be the one wired augment on that
// perk with no presence anywhere on this card.
//
// ⚠️ A NOTE RATHER THAN A ROW because it is a boolean about the player, not a stat
// about the weapon, and it is identical on all 31 prefabs. A row whose value never
// varies between weapons is noise on a card built for comparing them.
var runAndGun = NZombies.StaminUpAugments.RunAndGun( nz );
// ⚠️ SELECT FIRE NAMES ITSELF (2026-10-04): "Select fire · Burst ×3", in whichever mode E+R chose; and the per-class
// augments that fire while sprinting (Run 'n' Gun, Walking Fire) get Run & Gun's note.
var fireModeText = (w.HasSelectFire ? "Select fire · " : "") + ModeName( fireMode )
+ (fireMode == FiringType.burst ? $" ×{w.BurstRoundsFor( si )}" : "")
+ (fireMode != si.FiringType ? $" (was {ModeName( si.FiringType )})" : "")
+ (runAndGun || NZombies.TechStats.Flag( w, "f.sprintfire" ) ? " · sprinting" : "");
// ── reload ──────────────────────────────────────────────────────────
// ⚠️ A shell-by-shell weapon has no single reload duration — ReloadTime
// is the per-shell insert. Labelling it "per shell" is the difference
// between an Ithaca looking absurdly fast and looking correct.
var shell = w.ShellReloading;
var reloadSecs = shell && w.ShellReloadInsertTime > 0 ? w.ShellReloadInsertTime : w.ReloadTime;
// ⛔ THE PERK IS APPLIED TO THE DISPLAYED NUMBER. Weapon.Reload divides by
// `reloadSpeed`, and Speed Cola multiplies that — so the stat panel was
// showing the UNPERKED time while the gun reloaded 30% faster. A stats
// screen that disagrees with the weapon in your hands is worse than no
// stats screen.
//
// ⚠️ Divided, because the perk is a SPEED and this is a TIME.
// ⛔ AND THE TECH NODE. Fast Hands multiplies the SAME `reloadSpeed` local inside
// Weapon.StartReload that Speed Cola does, so leaving it out here would have this
// panel understate the reload on any weapon that bought it — the identical
// disagreement the block above records, one system later.
// ⛔ AND SPEED COLA'S AUGMENTS, WHICH WERE MISSING. `Weapon.StartReload` multiplies
// `reloadSpeed` by `SpeedColaAugments.ReloadSpeedMultiplier` on the very next line
// after the two terms above — so leaving it out here reproduced, one system later,
// exactly the disagreement the block above records having already had twice.
//
// ⚠️ `luckyProc: false`. m1 Lucky Hands is a 15% roll made once per reload; a stats
// row cannot show a per-reload coin flip, and showing the PROC'D figure would state a
// speed 85% of reloads never reach. The row shows the reliable number.
// ⛔ ONE ASK FOR SPEED COLA, exactly as Weapon.Reload does. This composed the base
// perk AND the augment multiplier itself, which since 2026-09-13 is wrong twice
// over: M1 Fast Hands REPLACES the base rather than stacking, and only the perk can
// decide that. Reading both here would have printed ×3.86 on a panel while the gun
// reloaded at ×2 — the panel exists to be trusted about exactly this.
var reloadMult = NZombies.SpeedColaAugments.ReloadSpeedFor( nz, luckyProc: false )
* TechEffects.Factor( w, "t1_reload" );
var baseSpeed = w.ReloadSpeed > 0 ? w.ReloadSpeed : 1f;
reloadSecs /= baseSpeed * reloadMult;
// ⚠️ THE EMPTY RELOAD IS NAMED WHEN IT DIFFERS, which is the only way Speed Cola's
// m4 "Even Keel" can appear at all — it does not scale anything, it CHOOSES between
// two authored durations. A row showing one number could never show an augment whose
// whole effect is that the other number stops being used.
//
// ⚠️ SUPPRESSED WHEN THE TWO AGREE, so 31 weapons do not all grow a redundant
// second figure — and so the absence is itself the signal that m4 is worth nothing
// on this gun.
var emptySecs = NZombies.SpeedColaAugments.ReloadTimeFor(
nz, w.ReloadTime, w.ReloadEmptyTime, isEmpty: true ) / (baseSpeed * reloadMult);
var emptyNote = !shell && MathF.Abs( emptySecs - reloadSecs ) > 0.005f
? $" ({emptySecs:0.00}s empty)"
: "";
var reload = shell
? $"{reloadSecs:0.00}s / shell"
: $"{reloadSecs:0.00}s{emptyNote}";
// ── reserve income ──────────────────────────────────────────────────
// Fabricator, expressed as rounds per minute of reserve.
//
// ⚠️ THE INTERVAL COMES FROM THE CATALOGUE AND IS SECONDS, NOT A MULTIPLIER —
// the node's Factor IS its period — so "per minute" is `ClipSize * 60 / interval`
// and retuning the node to 30s doubles this row with no edit here. A 60 typed in
// this file would be a second source for a magnitude WeaponTech owns.
//
// ⚠️ GUARDED THE SAME TWO WAYS NZPlayer.TickFabricator GUARDS, and both guards
// CHANGE THE ANSWER rather than merely avoiding a divide. `ifAbsent: 0f` because
// Factor's default of 1 would read here as a magazine every SECOND on all 31
// weapons that never bought the node; and a non-positive ClipSize is SWB's "no
// magazine" sentinel, which that method skips rather than paying out -1 a minute.
//
// ⚠️ THE LIVE ClipSize, which is the value TickFabricator actually credits — so
// Extended Mag and Extra Rounds feed this row exactly as they feed the node.
var fabInterval = TechEffects.Factor( w, "t3_fabricator", 0f );
var fabIncome = fabInterval > 0f && si.ClipSize > 0
? si.ClipSize * 60f / fabInterval
: 0f;
// ⛔ SPEED COLA'S M2 "AUTO-LOADER" BELONGS ON THIS ROW AND WAS MISSING FROM IT. The
// row is rounds per minute of income and the augment is a full magazine every ten
// seconds — the same quantity in the same unit, arriving by a different route. A
// player owning both should see one number, not one number and a hidden one.
//
// ⚠️ ADDED, NOT MULTIPLIED. The two are independent sources filling the same
// magazine, so their rates sum — and unlike every other composition on this card
// neither one scales the other.
//
// ⚠️ THE AUGMENT'S RATE IS CLIP-RELATIVE, so this expression is deliberately the same
// shape as the node's above: `ClipSize × 60 / seconds`. If they ever disagree it will
// be because one of them changed, which is visible here in a way two different
// formulas would not be.
if ( NZombies.PerkAugments.Has( nz, "speed", "M2" ) && si.ClipSize > 0 )
fabIncome += si.ClipSize * 60f / MathF.Max( 0.1f,
NZombies.SpeedColaAugments.AutoLoaderSeconds );
// ── fire rate ───────────────────────────────────────────────────────
// ⛔ THIS ROW NOW ASKS THE WEAPON INSTEAD OF RECOMPUTING THE CHAIN, AND THAT IS THE
// WHOLE LESSON OF THE Double Tap BLOCK ON THE ROW ITSELF. It used to compose
// `(base + flat) × perk × pct` here, in the order GetRealRPM applies them, with a
// comment explaining why the order mattered — and the reason that failure was
// possible at all is that the panel had its own copy of the arithmetic to be right
// about while the weapon had none.
//
// `GetRealRPM` is that method's own documented "ONE CHOKEPOINT": eight production
// call sites plus `nz_perk_prove` and `nz_tech_live` read it (its header enumerates
// them), so a diagnostic reading it is the established shape. Match Trigger's flat
// +50, Rapid Fire's percentage, Double Tap's perk AND Bolt Gun's x0.2 all arrive
// here for free, in the crossover order WeaponTech tuned, and so does whatever is
// added to that method next. Nothing can be missed from this row again.
//
// ⛔ IT RETURNS AN INTERVAL IN SECONDS, NOT AN RPM — reciprocals, which is the trap
// the node's own wiring records at its call site. Hence `60 / interval`, and the
// guard: GetRealRPM returns a flat 0 for an unauthored RPM (before Match Trigger's
// addend, deliberately, so a node cannot hand a weapon a fire rate the prefab never
// gave it), and dividing by that would print an Infinity.
//
// ⚠️ BOLT GUN'S TERM IS GATED ON `burstCount == 0` IN THERE, which is the inter-burst
// wait. That is the steady-state cadence and the right figure for a stats row — but
// it does mean a Bolt Gun weapon paired with Micro-Burst reads its fast intra-burst
// rate for the frames it is mid-burst. Per-shot state on a loadout row, the same
// objection as the Damage row's ramp; it self-corrects the moment the burst ends.
var interval = w.GetRealRPM( si.RPM );
var perkedRPM = interval > 0f ? 60f / interval : 0f;
// ── hip spread ──────────────────────────────────────────────────────
// ⚠️ Deadshot halves it, same as GetRealSpread does in the weapon — which
// is true as of 2026-08-19 and was NOT true when this line was written. Like
// the Double Tap note above, it asserted a weapon-side change that had never
// been made, so the label halved and the bullets did not. The claim was made
// good rather than removed; see the block in Weapon.Getters.GetRealSpread.
// Kept as TWO values: the perked one for the label, the base one for the bar.
// ⛔ THE TECH FACTOR GOES ON THE HIPFIRE TERM ONLY, mirroring exactly where
// Weapon.GetRealSpread applies it — on `SpreadAddHipFire` at its addition site,
// not on the finished value. Putting it on the total here would overstate the
// node, because base `Spread` is 0.0 on all 31 prefabs and would be scaled too.
//
// ⚠️ THIS RECOMPUTES RATHER THAN CALLING GetRealSpread, deliberately. That method
// also folds in movement, mid-air and sustained-fire terms, so a panel driven by
// it would read differently while you walked — useless as a comparison figure.
// The cost is that the composition lives in two places; the FACTOR does not,
// which is the half that actually goes stale.
//
// ⛔ BULL BARREL JOINS THE HIPFIRE TERM, IN POINT SHOOTING'S PLACE IN THE PRODUCT,
// because that is literally the same line in GetRealSpread: `spread +=
// si.SpreadAddHipFire * Factor( "t1_hipspread" ) * BullBarrelSpread()` (read it). So
// the two compose here exactly as they do there — x3 x 0.8 = x2.4 — and this row
// stays the only screen on which the node's cost is a number.
//
// ⛔ `Has` + `MagOf`, NEVER `Factor`. `Factor( w, "t4_bullbarrel" )` is 2.5, the
// DAMAGE half already stamped onto si.Damage at spawn; x2.5 spread against x3 is
// wrong by 17% and no eye can tell. The weapon side spells this out at
// `Weapon.Getters.BullBarrelSpread`, which is PRIVATE — so this is a second call to
// the same catalogue entry rather than a second copy of the magnitude, which is the
// distinction `MagOf`'s own header draws.
//
// ⚠️ AND IT IS `WeaponTech.MagOf`, NOT `TechEffects.Mag`, TO MATCH THAT SITE EXACTLY.
// The two differ in one respect that matters here: `TechEffects.Mag` applies
// `nz_tech_amp` and `MagOf` does not. Reading the amplified value while the gun fired
// the raw one would make an amplified run move this row and not the bullets — the
// self-referential measurement this file's Double Tap block exists to record.
// ⛔ THE PROJECT-WIDE HIP MULTIPLIER IS PART OF THIS, and it was missing for exactly
// as long as it has existed. `GetRealSpread` applies
// `GlobalHandling.HipSpreadScale` to the hipfire addend, so a card that left it out
// understated every weapon by that factor — the card-disagrees-with-the-gun failure
// this file already carries three warnings about, caused by the very change that
// added the multiplier.
//
// ⚠ FIRST IN THE CHAIN AND ON THE ADDEND, mirroring the weapon exactly. Order does
// not matter to the product, but matching the shot path line for line is what makes
// the two readable against each other.
var spreadBase = si.Spread
+ si.SpreadAddHipFire
* NZombies.GlobalHandling.HipSpreadScale
* TechEffects.Factor( w, "t1_hipspread" )
* (TechEffects.Has( w, "t4_bullbarrel" )
? WeaponTech.MagOf( "t4_bullbarrel", "spread", 1f )
: 1f);
// ⛔ RAILGUN'S ZERO GOES ON THE FINISHED VALUE AND NOT INTO `spreadBase`, WHICH IS
// AGAIN THE SITE'S OWN POSITION: GetRealSpread multiplies its return by
// `RailgunFactor( "spread" )`, last, so that one term annihilates the additive
// penalties, floatMod, the scope and Deadshot together. Folded into the addend
// instead it would leave `si.Spread` — 0.0 on all 31 prefabs, so invisible today and
// wrong on the first pack that authors it.
//
// ⚠️ BUT IT IS STILL ABOVE THE PERK, SO THE BAR SEES IT. This row's documented split
// is tech on the bar (Point Shooting is in `spreadBase`) and perks off it, so the
// railgun's bar pegs at 100% — Fraction clamps a value below the class minimum to a
// full bar for a `lowerIsBetter` field, which is true: it is the most accurate weapon
// on the roster. `handling` stays outside for the reason the note above gives.
//
// ⛔ AND AS OF THIS WRITING `t5_railgun` DECLARES NO `spread` MAGNITUDE — it declares
// `rpm` and nothing else — so this resolves to the neutral 1 and prints one warning,
// EXACTLY AS THE GUN DOES: `Weapon.Getters.RailgunFactor` reads the same undeclared
// name with the same neutral fallback, so the node's "no spread" is currently doing
// nothing at either site. Written this way ON PURPOSE rather than left out: the row
// must not show a zero the bullets do not have, and the day the catalogue declares
// the magnitude both sites move together. Reported as a hand-off, not fixed here —
// WeaponTech.cs is not this file's to edit. The same is true of `recoil` below.
var spreadTech = spreadBase
* (TechEffects.Has( w, "t5_railgun" )
? WeaponTech.MagOf( "t5_railgun", "spread", 1f )
: 1f);
// ⛔ AND DEADSHOT'S m4 "HIP PRECISION", WHICH WAS MISSING. `GetRealSpread` multiplies
// it into the HIPFIRE TERM, so this row — which is the hip-fire row — has to carry it
// or the card understates a 35% accuracy gain the player paid 750 salvage for.
//
// ⚠ ON THE FINISHED HIP FIGURE HERE because `spreadTech` above has already composed
// base `Spread` with `SpreadAddHipFire`, while the weapon applies it to the addend
// alone. The difference is real but negligible: measured, base `Spread` is 0.7% of
// hip spread on the galil.
//
// ⛔ THIS ONCE CLAIMED base `Spread` IS 0.0 ON ALL 31 PREFABS. It is not — every one
// of the 31 authors a non-zero value (galil 0.00204, hs10 0.0548). Third copy of
// that sentence found and corrected; it is tiny, not absent.
var spread = spreadTech
* PerkEffects.HandlingMultiplier( nz )
* NZombies.DeadshotAugments.HipSpreadFor( w );
// ── recoil ─────────────────────────────────────────────
//
// ⛔ THERE WAS NO RECOIL STAT AT ALL, which left Recoil Control the one tier-1
// node with nowhere to show. A stat that does not exist is not a neutral gap when
// a 350-salvage purchase is supposed to move it — the player's only feedback was
// the feel of the gun, which is exactly the evidence this panel exists to replace.
//
// ⚠️ `RecoilUp` IS THE PROXY FOR THE WHOLE KICK. FinishRecoil scales the composed
// Angles — up, side, randomness and first-shot punch together — so no single field
// is the recoil. RecoilUp is the dominant term and the one authored across the
// full 0.35-3 range, so it tracks the others. It is a comparison figure, not a
// measurement.
//
// ⚠️ Both multipliers, in the same order FinishRecoil applies them.
// ⛔ OVERPRESSURE RIDES THE SAME PRODUCT, AND `Has` + `Bound` IS THE ONLY CORRECT
// ACCESSOR. That node's catalogue `Factor` is 2 — its DAMAGE half, already stamped
// onto si.Damage at spawn — so `Factor( w, "t4_overpressure" )` here would show x2
// recoil for a node that applies x4, while looking perfectly idiomatic. The weapon
// side is Weapon.Getters.OverpressureRecoil, which reads
// `BoundOf( "t4_overpressure", 4f )` (read it); the same expression is repeated here
// rather than a bare 4 so that declaring `Bound: 4f` on the node moves both at once.
//
// ⛔ THE 4f FALLBACK IS A SECOND COPY OF THE ONE THE WEAPON SIDE HOLDS, and it is
// reachable only because the node declares no `Bound` yet. Boat Tail avoided exactly
// this by putting its fallback in `WeaponTech.FalloffCeiling`, a public const both
// sites read; this node has no such const, so the duplicate is reported rather than
// hidden. Declaring the bound makes both literals unreachable.
//
// ⚠️ x4 IS FAITHFUL TO THIS ROW'S PROXY AND STILL UNDERSTATES THE GUN.
// GetRecoilAngles scales RecoilRandomUp and RecoilRandomSide by the same factor
// BEFORE FinishRecoil scales the whole kick again, so the random half of a shot ends
// at x16 while RecoilUp — the deterministic term this row uses — ends at x4. That
// RATIO is the node, and one number cannot carry it.
//
// ⚠️ THE BAR EMPTIES, WHICH IS THE HONEST READING. `Inv( recoil, 3f )` clamps at 0
// once the figure passes the highest authored RecoilUp on the roster, so an
// Overpressure gun draws no bar at all: the worst recoil in the game, which it is.
//
// ⛔ RAILGUN IS LAST HERE BECAUSE IT IS LAST IN FinishRecoil — the final write to
// `kick` above the QueueRecoilRecovery call (read it: that ordering is the trap that
// method's header records having cost a bug twice, because a factor applied after the
// queue makes the camera walk back a different amount than it kicked). Zero absorbs
// Recoil Control, Overpressure and Deadshot there, and it does the same here.
//
// ⛔ `Has` + `MagOf`, NEVER `Factor`: this node's `Factor` is 1.5, the DAMAGE half, so
// `Factor` here would print the hardest-kicking gun on the roster for a node sold as
// having none. And `WeaponTech.MagOf` rather than `TechEffects.Mag` to match the site
// on amplification — see the spread block above, which carries the same two notes and
// the same warning that this magnitude is NOT YET DECLARED on the node, so both this
// row and the gun currently kick exactly as authored.
//
// ⚠️ BULL BARREL'S 2.5x RECOIL IS DELIBERATELY ABSENT, and it is a real effect: losing
// `IsAiming` (Weapon.cs, read it) also loses `GetRecoilAngles`' `var aimMult =
// IsAiming ? 0.4f : 1f`. But that multiplier is per-SHOT STATE — it is the difference
// between firing aimed and firing from the hip, not a property of the gun — and this
// row has never modelled it, so it already reads the hip-fire kick for every weapon on
// the card. Adding a x2.5 here would double-count the state this figure is already in.
// ⛔ AND DOUBLE TAP'S m4 "STEADY BARREL", WHICH WAS MISSING. `FinishRecoil`
// multiplies it into the composed kick beside the tier-1 node, so this row has to as
// well — a −35% recoil augment invisible on the one card built to show recoil is the
// §2 failure this file already carries three warnings about.
// ⛔ DEADSHOT IS GONE FROM THIS PRODUCT because it is gone from `FinishRecoil`. Leaving
// it would make the card claim a halving the gun no longer performs — the §2 failure this
// file already carries three warnings about, in the direction that flatters the player.
// ⛔ `si.RecoilUp` WAS THE WRONG FIELD, AND HAS BEEN SINCE 2026-09-14. `GetRecoilAngles`
// reads it ONLY when `UseRecoilBase` is false:
//
// var up = (useBase ? VerticalBase * shootInfo.RecoilVerticalMult
// : shootInfo.RecoilUp) * techRecoil;
//
// Base mode became the default the afternoon the recoil base was baked, so this card has
// been reporting a number the game stopped consuming — the same dead-field bug Chimera's
// recoil axis had, on the one card built to show recoil. Every weapon has a non-zero
// authored `RecoilUp`, which is exactly why it went unnoticed: the row looked plausible.
//
// ⚠️ AND `RecoilScale` WAS MISSING ENTIRELY. `FinishRecoil` opens with `kick *=` it, so a
// project-wide recoil change — ×2, then ×3, now ×2.2 — moved every gun in the game and
// nothing on this card.
//
// ⚠️ `RecoilAutoControl` JOINS THEM, because `GetRecoilAngles` folds it into the kick
// before `FinishRecoil` ever sees it. It is authored on a handful of weapons and is a
// straight reduction of what the player feels.
var baseUp = NZombies.GlobalHandling.UseRecoilBase
? NZombies.GlobalHandling.VerticalBase * si.RecoilVerticalMult
: si.RecoilUp;
var recoil = baseUp
* NZombies.GlobalHandling.RecoilScale
* (1f - si.RecoilAutoControl.Clamp( 0f, 1f ))
* NZombies.DtapAugments.RecoilMultiplierFor( w )
* TechEffects.Factor( w, "t1_recoil" )
* (TechEffects.Has( w, "t4_overpressure" )
? WeaponTech.BoundOf( "t4_overpressure", 4f )
: 1f)
* (TechEffects.Has( w, "t5_railgun" )
? WeaponTech.MagOf( "t5_railgun", "recoil", 1f )
: 1f);
// ── recoil recovery ────────────────────────────────────────
// Stabilizer, and it gets its OWN figure rather than scaling `recoil` above.
//
// ⛔ THE KICK MUST NOT MOVE. Weapon.Getters.QueueRecoilRecovery substitutes one
// frame's Time.Delta for the recovery time and leaves `applied` — the queued
// angles — exactly as FinishRecoil returned them. So the node changes how FAST
// the kick comes off and never how MUCH was kicked; multiplying `recoil` by
// anything here would invent an effect the gun does not have, and a time and an
// angle are not one number in the first place.
//
// ⛔ `Has`, NOT `Factor`. The catalogue stores 0 for this node — a set-to-zero,
// which TechEffects.KindOf classifies Absolute so `nz_tech_amp` leaves it alone.
// There is no magnitude to read; the only question is whether it is owned.
var instantRecovery = TechEffects.Has( w, "t3_recovery" );
// ⚠️ "Instant" AND NOT "0.00s", because 0 is the one value the weapon never uses.
// Writing the catalogue's literal zero into the recovery time trips the `t <= 0f`
// guard in QueueRecoilRecovery, which RETURNS and queues nothing — every kick
// would stay in the eye angles forever. That method spells the node as one tick's
// worth of recovery instead, so "0.00s" here would name a number the code
// deliberately avoids.
//
// ⚠️ "Never" IS NOT DECORATION. `ShootInfo.RecoilRecoveryTime` DEFAULTS TO 0 and
// that same guard returns, so an unauthored weapon genuinely never walks its kick
// back. Every shipped prefab authors 0.25 (enumerated above), but this panel's
// rule is that a weapon pack which never heard of it still reads correctly — and
// showing 0 as "0.00s" with a full bar would sell the worst recoil in the game as
// the best.
var recoveryText = instantRecovery ? "Instant"
: si.RecoilRecoveryTime > 0f ? $"{si.RecoilRecoveryTime:0.00}s" : "Never";
var recoveryBar = instantRecovery ? 1f
: si.RecoilRecoveryTime > 0f ? Inv( si.RecoilRecoveryTime, MaxRecovery ) : 0f;
// ── draw ────────────────────────────────────────────────────────────
// Fast Deploy, and unlike the two spawn-time tier-3 nodes below there IS work
// for this panel to do: `Weapon.GetDrawInfo` zeroes the delay it RETURNS and
// never writes `DrawTime`, so the stored field is untouched on a weapon that
// owns the node. A read-time node, like Point Shooting and Match Trigger.
//
// ⚠️ THE `DrawAnim` TEST IS GetDrawInfo'S, not caution added here: with no draw
// animation that method never assigns a delay at all, so a weapon with an empty
// DrawAnim pays nothing and showing its authored DrawTime would be a fabricated
// cost. All 31 prefabs author "draw" (enumerated), so this only ever matters for
// a pack that has not.
//
// ⚠️ THE EMPTY-MAGAZINE BRANCH IS LEFT OUT ON PURPOSE. GetDrawInfo uses
// `DrawEmptyTime` when the clip is empty, which depends on how much you have
// fired — the same objection that keeps GetRealSpread's movement terms out of the
// Hip spread row. Fast Deploy zeroes both branches there, so the node's effect on
// this row is right either way.
//
// ⛔ `Has`, NOT `Factor`, and IN GetDrawInfo'S ORDER — node first, global override
// LAST. That order decides the winner, which is why the weapon-side block spells
// it out: `nz_draw_time` exists to pin all 31 weapons to one value for comparison,
// and it deliberately sits BELOW the tech check. Applying them the other way round
// here would print "Instant" while the gun took the override's time.
var draw = string.IsNullOrEmpty( w.DrawAnim ) ? 0f : w.DrawTime;
if ( TechEffects.Has( w, "t3_deploy" ) ) draw = 0f;
// ⛔ AND SPEED COLA'S m2 "SWIFT DRAW", DIVIDED BELOW THE NODE exactly as `Weapon.cs`
// does it — so a Fast Deploy weapon stays at zero rather than the augment
// reintroducing a delay.
//
// ⚠️ THIS ROW ONLY SHOWS HALF A SWAP. The put-away is `NZInventory.HolsterTime`, a
// shared static the augment also divides, and there is no row for it — a per-weapon
// card is the wrong place for a value every weapon shares. The augment's other half
// is therefore real and invisible here; `nz_aug_speed` prints both.
draw /= NZombies.SpeedColaAugments.SwapSpeedFor( w );
if ( Weapon.DrawTimeOverride >= 0f ) draw = Weapon.DrawTimeOverride;
var drawText = draw > 0f ? $"{draw:0.00}s" : "Instant";
// ── aim-in time ─────────────────────────────────────────────────────
// The view model slerps toward the aim pose at `10 * AnimSpeed` per
// second — an exponential approach, not a fixed duration — so there is no
// stored ADS time to read. 3/k is the ~95% settle point, i.e. when the
// sights are actually on target.
// ⚠️ DIVIDED by the aim-SPEED multiplier, because this is a TIME. Deadshot
// returns 2 there, so the displayed aim-in halves — matching the slerp in
// ViewModelHandler rather than restating the perk with its own number.
//
// ⚠️ THE THIRD COMMENT IN THIS FILE THAT DESCRIBED A SYSTEM THAT DID NOT
// EXIST. ViewModelHandler's aim lerp ignored the multiplier entirely, so this
// number was the only place a faster ADS was ever visible. All three claims
// — RPM, spread, aim-in — are now true because the code they described was
// finally written. Three of them in one file is not a coincidence: this panel
// was where each perk LOOKED implemented, which is precisely why nobody went
// to check whether it was.
// ⚠️ Quickdraw rides the same `adsSpeed` local in ViewModelHandler that Deadshot
// does, so it belongs on the same multiplier here.
//
// ⛔ AND IT IS NOW `AdsSpeedFactor`, NOT A LOCAL `Factor( "t1_ads" )` CALL — the same
// swap ViewModelHandler and PlayerCameraHandler made, for the same reason. That
// accessor is Quickdraw x Emplacement's x0.25 in one place; this panel was the THIRD
// site reading the rate and a `Factor` call here would have shown a Quickdraw-only
// aim-in on a weapon that takes four times as long to come up. Read
// `TechEffects.AdsSpeedFactor` — its header records that the two weapon-side sites
// asked in writing for exactly this accessor after Quickdraw shipped half-wired.
//
// ⚠️ THE PRODUCT IS ViewModelHandler'S, TERM FOR TERM: `animSpeed = 10 * AnimSpeed *
// speedMod * (AimSpeedMultiplierFor x AdsSpeedFactor)`. `speedMod` is left out because
// it is the weapon's own aim-in/aim-out asymmetry, not a loadout property.
var aimSpeedMult = PerkEffects.AimSpeedMultiplier( nz )
* TechEffects.AdsSpeedFactor( w );
// ⛔ BULL BARREL CANNOT AIM AT ALL, SO BOTH AIM ROWS READ "None" RATHER THAN A NUMBER.
// `Weapon.cs` ands `!Has( "t4_bullbarrel" )` into the `IsAiming` assignment (read it),
// so the state these two rows describe is unreachable — an aim-in TIME for a weapon
// that never aims is a fabricated cost, and an aim-WALK speed for it is a fabricated
// penalty. "None" with an empty bar is the Penetration row's idiom for exactly this:
// a real zero, a capability the weapon does not have, not a class minimum.
//
// ⚠️ THE NODE ONLY. The same assignment also requires `AimAnimData != AngPos.Zero`, so
// a pack shipping a weapon with no aim offset also never aims — that clause is NOT
// mirrored here, because it is a pre-existing gap on 0 of the 31 prefabs (all author
// one) and folding it in would change what this row shows for a reason unrelated to
// tier 5. Named so the next reader knows it was seen, not missed.
//
// ⚠️ THE COST OF LOSING ADS SHOWS UP AS ITS OWN ROWS ANYWAY: `Hip spread` carries the
// x3 on a cone the player can no longer close, and `Move speed` shows the full walk
// speed this weapon keeps — the node's one undocumented UPSIDE, which the ADS walk
// penalty can never take.
var noAds = TechEffects.Has( w, "t4_bullbarrel" );
var aimIn = w.AnimSpeed > 0 ? 3f / (10f * w.AnimSpeed * aimSpeedMult) : 0f;
// ── aim walk speed ──────────────────────────────────────────────────
// ⚠️ Taken from the CONFIG times the ADS penalty, never from the live
// PlayerController.WalkSpeed — that value is already being written every
// frame by the ADS penalty itself, so reading it while aiming would show
// the penalty applied twice.
var baseWalk = Difficulty.WalkSpeed;
// ⚠️ CLAMPED AFTER THE TECH FACTOR, IN THAT ORDER, because NZPlayer.TickAdsSpeed
// clamps after it too — 0.5 x 1.25 is 0.625, but a weapon starting at 0.9 would
// reach 1.0 and no further. Clamping first and scaling after would show a walk
// speed the player can never actually have.
// ⛔ AND STAMIN-UP'S m1 "STEADY AIM" AS A FLOOR AFTER THE CLAMP, which is exactly
// what `NZPlayer` does to the live value. It was missing, so the card showed an
// aim-walk speed of half the walk while the player actually moved at 80% of it.
//
// ⚠️ A `Max`, NOT A MULTIPLY, for the reason recorded on the augment: `t1_strafe`
// already multiplies this clamped value, and two multipliers on one clamped term
// means a player owning both pegs at the ceiling with no way to tell which did it.
var adsMult = nz.IsValid()
? MathF.Max(
(nz.AdsSpeedMultiplier * TechEffects.Factor( w, "t1_strafe" )).Clamp( 0.05f, 1f ),
NZombies.StaminUpAugments.AdsSpeedFloor( nz ) )
: 0.5f;
// ⛔ DRUM MAGAZINE'S -15% GOES OUTSIDE THE CLAMP, WHICH IS TickAdsSpeed'S OWN ORDER.
// That method clamps the ADS fraction alone and then multiplies the perk and tech
// factors into the finished assignment (read it), so folding this into the clamped
// term would let the ceiling of 1 swallow it on a weapon whose ADS fraction is
// already high.
//
// ⛔ `Has`, NOT `Factor`: t4_drum's catalogue Factor is 3 — the MAGAZINE multiplier,
// already on si.ClipSize from spawn — so `Factor` here would TRIPLE the displayed
// walk speed. And NOT `BoundOf` either: that slot is spoken for by the node's x2
// reload, which NZPlayer reads as `BoundOf( "t4_drum", DrumReload )`, so borrowing it
// would show x2 walk speed instead of x0.85.
//
// ⛔ AND THE LITERAL 0.85 IS GONE, WHICH WAS THE SECOND COPY OF NZPlayer'S PRIVATE
// `DrumWalk` CONST — the fault this block used to report and could not fix. Tier 5
// fixed it: `NZPlayer.TechMoveMultiplier( weapon, sprint )` is the public accessor
// TickAdsSpeed and Stamina.ApplySprintBlock both read, so this row now asks the same
// method the player's own speed is computed from and there is no magnitude at this
// call site at all. Drum Magazine's -15% and Emplacement's -50% both arrive through
// it, and so will the next node that slows a walk.
//
// ⛔ WHICH IS ALSO THE ONLY REASON EMPLACEMENT'S WALK PENALTY IS ON THIS CARD. It is a
// READ-TIME per-weapon term — TickAdsSpeed recomputes `c.WalkSpeed` from the config
// every frame and multiplies this in (read it) — so there is no stored field for any
// row to have picked it up from, the way Magazine picks up a spawn-time ClipSize.
//
// ⚠️ STAMIN-UP IS IN THE PRODUCT NOW TOO, and its absence was a real understatement:
// TickAdsSpeed's assignment is `config.WalkSpeed * adsFraction * perkSpeed * techSpeed`
// and this row had three of the four terms. Every other row on the card shows what the
// gun does in your hands, perks included — the Reload row's own ⛔ block is the same
// correction, one system earlier.
var perkSpeed = PerkEffects.SpeedMultiplier( nz );
var techWalk = nz.IsValid() ? nz.TechMoveMultiplier( w, sprint: false ) : 1f;
var aimWalk = baseWalk * adsMult * perkSpeed * techWalk;
// ── move speed ──────────────────────────────────────────────────────
// ⛔ A ROW OF ITS OWN, BECAUSE THE AIM-WALK ROW ABOVE IS CONDITIONAL AND THIS IS NOT.
// Emplacement's -50% walk applies whether or not you are aiming, and its -70% sprint
// applies where the aim rows cannot reach at all — a player who never presses the
// right mouse button would have seen the whole mobility cost of the tier's loudest
// node on no row of this card. Bull Barrel makes that literal: it cannot aim, so both
// aim rows read "None" and this is the only movement figure it has.
//
// ⚠️ TWO FIGURES IN ONE ROW, walk then sprint, which is the `Limb damage` row's shape
// and for the same reason — they are one axis with two states, and two rows would
// double the space for a number that moves with the same factor.
//
// ⛔ THE SPRINT TERM IS NOT `TechMoveMultiplier( sprint: false )` WITH A DIFFERENT
// BASE. The two calls return different numbers on purpose: Emplacement authors both
// `walk` and `sprint` magnitudes, and Drum Magazine authors walk ONLY — its wiring
// deliberately left RunSpeed alone (that method's own note says extending a tier-4
// node to sprint while wiring tier 5 would be a balance change smuggled in as
// plumbing). Asking once and reusing the answer would invent a sprint penalty for
// Drum Magazine.
//
// ⚠️ THE SPRINT PRODUCT IS Stamina.SprintSpeed's, TERM FOR TERM: `Settings.SprintSpeed
// * PerkEffects.SpeedMultiplier * TechMoveMultiplier( sprint: true )` (read
// ApplySprintBlock — the tech term is applied at that call site, not folded into the
// property, and its own note says why). The exhaustion branch, which drops sprint to
// walk speed, is left out for the reason the aim rows leave out the empty-magazine
// draw: it is a state, not a property of the loadout.
var walkSpeed = baseWalk * perkSpeed * techWalk;
// ⛔ AND STAMIN-UP'S M2 "LIGHTWEIGHT" ON THE SPRINT HALF ONLY, which was missing.
// `perkSpeed` above is `PerkEffects.SpeedMultiplier` and covers walking AND sprinting
// — so Fleet Footed already reached both figures — but Lightweight lives in
// `Stamina.SprintSpeed` precisely because it must NOT touch the walk. This row is the
// only place the two numbers sit side by side, so it is the only place the
// distinction is visible at all.
var sprintSpeed = Difficulty.SprintSpeed * perkSpeed
* NZombies.StaminUpAugments.SprintMultiplier( nz )
* (nz.IsValid() ? nz.TechMoveMultiplier( w, sprint: true ) : 1f);
// ── penetration ─────────────────────────────────────────────────────
// `Penetration` is SWB's pierce/don't-pierce bool; `PenetrationDepth` is
// the material budget in source units (1 unit = 1 inch), so it converts
// straight to a thickness a player can picture.
//
// ⛔ AND THAT IS WHY OVERPENETRATOR NEEDS NO CODE HERE. `t3_pierce` is a
// SPAWN-TIME node: ApplyStoredUpgrades writes
// `si.PenetrationDepth = TechBase( … ) * 3` on both ShootInfos on every equip
// (read there — verified, not assumed), so the live field this row already reads
// is the tripled one. A `* Factor( w, "t3_pierce" )` on top would DOUBLE COUNT
// the node and show 30 inches on a gun that pierces 10 — the identical fault the
// Magazine row records for Extra Rounds, and the reason to read that note first.
//
// ⛔ AND A DEPTH OF ZERO IS "UNLIMITED", NOT "NO PIERCE AT ALL" — WHICH IS WHAT THIS
// ROW SHOWED FOR THE TWO CAPSTONES THAT SET IT. `HitScanBulletInfo` documents 0 as an
// unlimited budget and enforces it by simply not running either of its two
// `penBudget > 0f` guards (read it), and NZPlayer writes `PenetrationDepth = 0` plus a
// forced `Penetration = true` for Railgun and Ricochet Rounds. Left alone, `PenDepth(
// 0 )` printed "0 cm" and `Fraction` clamped the bar to its 10% floor — the single
// worst reading available on the card, advertising the strongest pierce in the game as
// the weakest, on nodes costing 1,750 salvage.
//
// ⚠️ THE SAME SHAPE AS THE Reserve ROW'S "Unlimited" AND FOR THE SAME REASON: a word
// rather than a number, because the number has stopped meaning anything, and a FULL
// bar because unlimited is the top of any scale. Not "∞" — same objection as there,
// the font is Inter and an unchecked glyph is a tofu box.
//
// ⚠️ IT READS THE FIELD, NOT THE TWO NODES. Any weapon arriving at depth 0 with
// `Penetration` true really does pierce without limit — including a pack that authors
// it that way, which is this card's standing rule — so testing `Has( "t5_railgun" ) ||
// Has( "t5_ricochet" )` here would be a second copy of NZPlayer's condition that could
// only ever disagree with the field the bullet loop actually reads.
//
// ⚠️ TEN BODIES IS THE REAL CEILING (`MaxPenetrations = 10`, and there are two copies
// of it — BulletInfo.HitScan and PhysicalBullet.Mover). Both node rows in the catalogue
// say "every body in the line" rather than "infinite" for that reason; this row says
// "Unlimited" about the BUDGET, which is exactly what the field means.
var pen = !si.Penetration ? "None"
: si.PenetrationDepth <= 0f ? "Unlimited"
: PenDepth( si.PenetrationDepth );
// ── ricochets ───────────────────────────────────────────────────────
// ⛔ THE ONE ROW RICOCHET ROUNDS HAS, and the node changes NO ShootInfo field — so
// unlike every spawn-time node on this card there is nothing here that could have
// picked it up by accident. `BulletInfo.HitScan` adds `|| forcedRicochet` to the
// bounce predicate, which drops the angle gate, the 0.33 roll AND the eight-material
// surface whitelist together (read it — the whitelist's removal is written down there,
// with the reason). What the forced path KEEPS is `si.Ricochet` and the `MaxRicochets`
// cap, which is why both appear on this row either way.
//
// ⚠️ THE GATES ARE IN THE VALUE, NOT THE BAR, because they are not fractions of one
// number: `GetGrazingAngle` returns `90 - angle`, so `≤30°` means a bullet within 30
// degrees of PARALLEL to the surface. A player shooting a wall in front of them sees
// zero bounces today, which is what makes this node's observability the best in the
// tier and its old state indistinguishable from unwired.
//
// ⚠️ THE BAR IS THE ROLL ONLY — a display scale where a full bar means "off every wall
// you hit". 0.33 to 1 is the node, and no honest scale exists for "any angle, any
// material" beyond it.
// ⛔ AND VIGOR RUSH'S m2 "RICOCHET", WHICH WAS MISSING ENTIRELY. It bypasses the
// surface, angle and chance roll exactly as the tech node does, and supplies its own
// bounce budget — so a card reading "×3 at 15%, ≤30°" on a weapon that now always
// bounces once is stating the opposite of the truth.
//
// ⚠️ THE CAP IS RESOLVED THE SAME WAY `BulletInfo.HitScan` RESOLVES IT — tech first,
// then the augment, then the authored value — rather than recomputed. A second copy
// of that precedence is exactly the §3 shape this file's Fire mode row documents
// avoiding by asking `EffectiveFiringType` instead of re-deriving it.
var vigorBounces = NZombies.VigorAugments.BouncesFor( w );
var forcedRicochet = TechEffects.Has( w, "t5_ricochet" );
var bounceCap = forcedRicochet
? (int)WeaponTech.Find( "t5_ricochet" ).Factor
: vigorBounces > 0 ? vigorBounces : si.MaxRicochets;
var ricochet = !si.Ricochet ? "None"
: forcedRicochet || vigorBounces > 0 ? $"×{bounceCap} any angle"
: $"×{si.MaxRicochets} at {si.RicochetChance * 100:0}%, ≤{si.RicochetAngle:0}°";
// ── hit radius ──────────────────────────────────────────────────────
// Wide Bore, which likewise touches no stored field — it plumbs a radius into a
// SECOND, zombie-only trace consulted when the thin one missed every body.
//
// ⛔ `si.BulletSize * Factor( …, 0f )` IS THE SITE'S OWN ARITHMETIC, NOT THE NODE'S
// DECLARED `radius`. `BulletInfo.HitScan` computes the radius exactly this way, and
// its comment gives the reason: `BulletSize` is read by nothing else in the project,
// so the node uses it as the authored BASE to make the catalogue's "BulletSize x5"
// text true. The node ALSO declares a fixed `Mag( "radius", 10f )` that nothing reads
// — they agree today only because all 31 prefabs author 2. Mirroring the site rather
// than the declaration is what keeps this row honest on a pack that authors something
// else; the duplicate is reported as a hand-off.
//
// ⚠️ `ifAbsent: 0f`, NOT THE DEFAULT 1, for the reason the site passes it: this is not
// a multiply on an existing radius, it is the radius itself, and a silent 1 would give
// every weapon in the game a fat trace it never bought.
//
// ⚠️ "—" AND AN EMPTY BAR WITHOUT THE NODE, because there IS no second trace without
// it. The thin trace's own radius is a DEFAULT PARAMETER on `Weapon.TraceBullet` (2.0f)
// and printing that as a baseline would assert that `BulletSize` is what the bullet
// sweeps, which is the dead-field reading this node was rewritten to avoid.
//
// ⚠️ THE BAR IS A CAPABILITY, LIKE Penetration's "None" AND Reserve's "Unlimited" —
// there is exactly one radius the node can produce, so a proportional ceiling would be
// invented headroom rather than a scale.
var wideRadius = si.BulletSize * TechEffects.Factor( w, "t5_widebore", 0f );
// ── headshot ────────────────────────────────────────────────────────
// Base zombie multiplier × the weapon's own. A weapon with no multiplier
// carries 1, so this collapses to the zombie's on its own.
// ⚠️ Death Perception rides on the TEXT only. The bar below stays on the
// raw `si.HeadMultiplier` so the class comparison keeps meaning what it says —
// same split as Damage. A perk is not a property of the gun.
// ⚠️ Precision Rounds rides the SAME product Death Perception does — read in
// Health.OnDamage, which does `amount *= HeadshotDamageScale *
// HeadshotScaleFor(attacker) * Factor(weapon, "t2_headshot")`. So it belongs on
// the text beside the perk, and on the text only, for the same reason.
// ⚠️ DEADEYE IS A FOURTH MULTIPLY ON THE SAME PRODUCT. Health.OnDamage does
// `amount *= HeadshotDamageScale * HeadshotScaleFor( attacker )
// * Factor( weapon, "t2_headshot" ) * Factor( weapon, "t5_deadeye" )` (read there),
// so it belongs on the text beside the perk and the tier-2 node, for the reason both
// of those give — and `Factor`'s default of 1 is correct because this is a multiply.
//
// ⛔ AND BODY SHOT REMOVES THE WHOLE HEALTH-SIDE PRODUCT, NOT JUST THE 2.5. That
// node joins the existing `ImmuneToHeadshotBonus` gate in Health.OnDamage, so a head
// hit takes NONE of those four terms. What survives is `si.HeadMultiplier` alone,
// because that one is applied by ShootInfo.DamageFor on the bullet and not by Health
// — a distinction worth being exact about, since it is the difference between this
// row reading x1 and reading x0 on the four prefabs that author 2.0.
//
// ⚠️ SO THIS ROW CAN READ BELOW THE TORSO ROW, and the inversion is the node rather
// than a fault in the arithmetic: x1 on the head against x1.5 on the chest is
// exactly what "stop aiming" buys.
//
// ⚠️ NEITHER NODE TOUCHES THE BAR, which stays on `si.HeadMultiplier` — the same
// split as Damage and Rate of fire. Both move the PRODUCT, which is the text; the
// weapon's own multiplier is what the class comparison is about and neither node
// writes it.
var hasDeadeye = TechEffects.Has( w, "t5_deadeye" );
var bodyShot = TechEffects.Has( w, "t4_bodyshot" );
// ⛔ AND DEADSHOT'S M1 "DEADEYE" AND M4 "FOCUS", WHICH WERE MISSING. Both multiply
// into the head product in `Health` beside the three terms already here, and Focus in
// particular can reach ×5 — a card understating a headshot by five times is worse
// than no card.
//
// ⚠️ FOCUS IS LIVE STATE, unlike everything else on this row, and it is included
// anyway. The usual rule keeps per-shot state off the card (the burst's conditional
// ×2, Ten-Round's ramp) but a killstreak is not per-shot — it holds still until you
// kill or miss, and it is exactly the number a player wants before choosing where to
// aim. Same argument that put Trigger Discipline's charge on the Damage row.
var head = bodyShot
? si.HeadMultiplier
: ZombieHeadshot * si.HeadMultiplier
* PerkEffects.HeadshotScaleMultiplier( nz )
* NZombies.DeadshotAugments.HeadshotScale( nz.IsValid() ? nz.GameObject : null )
* TechEffects.Factor( w, "t2_headshot" )
* TechEffects.Factor( w, "t5_deadeye" );
// ── limb damage ─────────────────────────────────────────────────────
// ⛔ THERE WAS NO LIMB ROW AT ALL, which left Hollow Points nowhere to show —
// the same gap the Recoil block above records for Recoil Control, and the
// tier-2 node most likely to be judged "dead" without one, because its value
// is spread across pellets that miss the torso and is invisible per shot.
//
// ⚠️ TWO FLOORS, IN THE TWO STEPS Health.PartMultiplier ACTUALLY USES (read
// there, not inferred): the catalogue target floors arms and legs, and an
// extremity is promoted to THIS ZOMBIE'S OWN softer limb value. That derivation
// is not a shortcut — `WeaponTech.Node` carries a single `Factor`, so the
// node's second target (extremities to 0.75) is not in the catalogue at all,
// and typing 0.75 here would be a second source for a magnitude the catalogue
// is supposed to own. Health's block explains the same choice at length.
//
// ⚠️ `ifAbsent: 0f` BECAUSE THESE ARE MAXES, where 0 is the neutral value the
// way 1 is for a multiply. Factor's default of 1 would floor every limb on
// every weapon to full torso damage and delete the limb penalty from the game.
var limbs = ZombieLimbs;
var limbFloor = TechEffects.Factor( w, "t2_limbs", 0f );
var extremityFloor = limbFloor > 0f ? limbs.Limb : 0f;
// ── the two tier-4 archetypes that reshape every hit zone ────────────
//
// ⚠️ BODY SHOT IS A SECOND FLOOR, NOT A BIGGER FIRST ONE, and it lands on
// DIFFERENT PARTS than Hollow Points does. Health.PartMultiplier (read there, not
// inferred) gives the torso `bodyFloor` alone, arms and legs
// `MathF.Max( limbFloor, bodyFloor )`, and extremities `extremityFloor` alone — the
// node names "torso and limbs", and hands and feet are a separate rung in that
// component whose node is Hollow Points. Widening it onto them here would make this
// panel and that component disagree about a number the player can measure.
//
// ⛔ DEADEYE IS A CEILING, AND ITS MAGNITUDE IS `Bound`, NOT `Factor`. The
// catalogue's 2.5 is the HEAD scale used in the row above; reading it here would show
// a body shot two and a half times BETTER instead of half as good — a penalty
// displayed as its own inverse, which nothing in the numbers would flag. Health reads
// `BoundOf( "t5_deadeye", 0.5f )` and this is the same expression, so declaring
// `Bound = 0.5f` on the node moves both; until it does, the 0.5 here is a second copy
// of Health's fallback. Same gap as Overpressure's 4f above — see the report.
//
// ⚠️ THE CEILING IS APPLIED AFTER THE FLOOR, WHICH IS `Shaped`'S ORDER ON BOTH
// SIDES, so a weapon holding both nodes reads 0.5 on every part: the penalty wins,
// and two contradictory nodes must never display as a buff. Tier 4 is pick-one, but
// `WeaponTech.Unlimited` lifts that in creative, which is where these get tested.
// ⛔ BODY SHOT NO LONGER FLOORS THESE ZONES, so this is 0. It became a flat x1.5
// on `ShootInfo.Damage` — which the Damage row above already reads off the gun, so
// like every other spawn-time node it needs NO code here. Leaving the floor in
// would DOUBLE COUNT it: the zone bars would climb to 1.5 while the damage figure
// they sit under had already absorbed the same 1.5.
//
// ⚠️ Kept as a named zero rather than deleted, because `Shaped` below takes a
// floor and a ceiling and Deadeye still supplies the ceiling. A literal 0 in two
// call sites would not say why it is zero.
var bodyFloor = 0f;
var bodyCeiling = hasDeadeye ? WeaponTech.BoundOf( "t5_deadeye", 0.5f ) : 0f;
var torsoMult = Shaped( ZombieTorso, bodyFloor, bodyCeiling );
var limbMult = Shaped( limbs.Limb, MathF.Max( limbFloor, bodyFloor ), bodyCeiling );
var extremityMult = Shaped( limbs.Extremity, extremityFloor, bodyCeiling );
// ⛔ THE HIT-ZONE BARS SHARE ONE CEILING AND IT IS NO LONGER 1.0, FOR THE REASON
// Range damage'S CEILING STOPPED BEING 1.0 FOR BOAT TAIL. Torso parity was a true
// ceiling until a node could push a part multiplier PAST the chest; Body Shot's
// target is 1.5, so at 1.0 both the torso and the limb bar peg and the node's whole
// span is invisible on the bar while its own row displays x1.5.
//
// ⛔ AND IT IS READ FROM THE CATALOGUE, NOT TYPED HERE — `Find( … )?.Factor`, the
// same number Health floors with, so the bar's top and the game's floor cannot
// drift apart. `MathF.Max( 1f, … )` because a missing node must not shrink the scale
// below torso parity, and `?? 1f` for the same reason: no invented magnitude, just
// the old ceiling back.
//
// ⚠️ `nz_tech_amp` DOES NOT MOVE THIS. It scales `Factor` at READ time inside
// TechEffects, so an amplified Body Shot floor climbs past a ceiling that stays put
// and all three bars peg. That is the instrument being loud, which is its purpose;
// Health's own note says the same about the same node.
// ⚠️ THE ZONE BARS NOW TOP OUT AT PARITY. Body Shot used to be the only node
// that pushed a zone multiplier above 1, so it set this scale; with its x1.5 moved
// onto damage, nothing raises a zone above torso parity and 1.0 is the honest
// ceiling. Hollow Points still RAISES limbs toward 1.0 and Deadeye still lowers
// them, so the bar keeps its range — it just no longer needs headroom for a node
// that is not here any more.
var zoneCeiling = 1f;
// ── damage at range ─────────────────────────────────────────────────
// ⛔ READ LIVE, AND DELIBERATELY NOT RE-FLOORED HERE. Long Barrel is a
// SPAWN-TIME node: NZPlayer.ApplyFalloffTech writes
// `si.FalloffMultiplier = Max( authoredBase, floor )` on every equip, so this
// field is ALREADY floored by the time the panel reads it and a second
// `MathF.Max( …, 0.75f )` here would only make the panel agree with itself. If
// the stamp ever fails to reach the gun this row shows the failure instead of
// covering for it — the §2 rule the Damage row's rarity note spells out.
//
// ⛔ AND BOAT TAIL IS THE SAME KIND OF NODE, WHICH IS WHY THERE IS NO `+ 0.5`
// HERE EITHER. `t3_inverse_falloff` rides the SAME ApplyFalloffTech call as Long
// Barrel rather than a second helper — the write is now
// `Min( Max( authored, floor ) + add, bound )` (read there — verified) — so the
// field this row reads has already had the node's half added. Adding it again
// would DOUBLE COUNT and show 123% on a weapon doing 73%. Only the BAR's ceiling
// had to move, and that is on the row itself, below.
//
// ⚠️ FALLOFF ONLY EXISTS WHERE A WINDOW IS AUTHORED. ShootInfo.DamageFor gates
// the lerp on `FalloffEnd > FalloffStart`, so a weapon with no window keeps
// 100% at any distance however its multiplier reads. All 31 shipped prefabs do
// author a window (checked), but this panel's rule is that a weapon pack which
// never heard of it still gets correct numbers, and showing a stored 0.23 on a
// gun that never applies it would be a fabricated weakness.
var falloff = si.FalloffEnd > si.FalloffStart ? si.FalloffMultiplier : 1f;
// ⛔ AND THE DISTANCE THE MULTIPLIER LANDS AT, WHICH IS THE ONLY PLACE SLUG LOADER'S
// x3 RANGE CAN SHOW. That node does not touch FalloffMultiplier at all — NZPlayer
// passes a `rangeMult` into ApplyFalloffTech and it multiplies FalloffStart and
// FalloffEnd (read there — verified), which is deliberate: a shotgun's problem is
// that its band ENDS close in, and raising the multiplier would only lift the floor
// it lands on. So the percentage alone is IDENTICAL before and after the node, and
// this row would have shown nothing at all for it — the §2 failure, on a node whose
// own Effect string promises "x3 range".
//
// ⚠️ `FalloffEnd`, NOT `FalloffStart`, BECAUSE THAT IS WHERE THE PERCENTAGE IS TRUE.
// ShootInfo.DamageFor lerps from 1 toward FalloffMultiplier across the band and
// clamps `t` at 1, so the figure to the left of the "@" is what a bullet keeps AT
// that distance and beyond, and nothing above it. Reading the start instead would
// pair the number with the distance at which it is still 100%.
//
// ⚠️ AND IT MAKES BOAT TAIL'S REMAP VISIBLE FOR THE FIRST TIME. That node moves the
// band to `0 → authoredStart`, so its row goes from "125% @ 148.6 m" — a bonus that
// exists and can never be observed — to "125% @ 19.8 m", which is the sentence
// ApplyFalloffTech's own comment writes about why it remaps at all.
//
// ⚠️ NO DISTANCE WITHOUT A WINDOW. DamageFor gates the whole lerp on
// `FalloffEnd > FalloffStart`, so a weapon with no band keeps 100% everywhere and
// printing a distance for it would be a fabricated one.
var falloffText = si.FalloffEnd > si.FalloffStart
? $"{falloff * 100:0}% @ {si.FalloffEnd * 0.0254f:0.#} m"
: $"{falloff * 100:0}%";
// ── zombie speed ────────────────────────────────────────────────────
// Adrenaline Rounds' downside, and the only half of that node this card can carry —
// its x2 damage is spawn-time and already inside the Damage row.
//
// ⛔ A ROW FOR A VICTIM-SIDE EFFECT, WHICH THIS CARD ALREADY DOES THREE TIMES. The
// Headshot, Torso and Limb rows all read multipliers off a LIVE zombie's Health; this
// is the same kind of fact about the same victim, and the node's own Effect string is
// half about it. Without a row the entire cost of a 1,750-salvage capstone is a red
// tint on a corpse-to-be — the plan's own words are that it "reads as the round got
// harder", which is the §2 failure this file exists to stop.
//
// ⛔ `TechEffects.Mag`, THE SAME ACCESSOR `Health.OnDamage` PASSES INTO
// `StatusEffects.Apply` — not `WeaponTech.MagOf`, and not the status rule's own
// `SpeedScale`. Those are three readings of one catalogue number and only one of them
// is what lands: Health resolves the magnitude with the WEAPON in scope, which is the
// only accessor that applies `nz_tech_amp`, and passes it per application so the
// amplified value never gets stamped onto the shared static rule. Reading the rule
// here would show the unamplified figure while an amplified run made zombies faster.
//
// ⚠️ `ifAbsent: 0f` SO "—" MEANS ABSENT, the convention `limbFloor` and `fabIncome`
// already use on this card. A neutral 1 would read as "x1.00 zombie speed" on all 31
// weapons — true, and indistinguishable from a row that is broken.
//
// ⚠️ IT IS PER ZOMBIE YOU HIT AND FAIL TO KILL, PERMANENTLY, AND NON-STACKING —
// `StatusEffects.Add` refreshes rather than stacks, which is what stops a 16-pellet
// shotgun applying it sixteen times. So this figure is the ceiling for any one zombie,
// not a rate; the horde accumulates fast zombies, one zombie does not accumulate speed.
// ⛔ NO NODE SPEEDS ZOMBIES UP ANY MORE (2026-10-04). Adrenaline Rounds, the one that did, was redesigned twice and is
// now the shooter's own stacks; reading its old `speed` warned once and drew "—" anyway. The row stays, always "—".
var zombieSpeed = 0f;
// ── points per kill ─────────────────────────────────────────────────
// ⛔ THE BODY-KILL AWARD, AND THE LABEL HAS TO SAY WHICH ONE. Kills pay 50 body
// / 100 headshot / 130 knife and Bounty's flat +10 lands on all three, so a row
// labelled just "Kill points" would be one number standing for three and a
// player comparing it against what the HUD actually paid would find it wrong two
// times in three. The body figure is the one the node moves most in proportion
// (+20%, against +10% on a headshot), which is the entire reason WeaponTech chose
// the flat form over a percentage.
//
// ⚠️ ADDED AND ROUNDED TO AN INT, mirroring ZombieAI.AwardPoints, which does
// `amount += (int)MathF.Round( Factor( … ) )`. `ifAbsent: 0f` again: this node
// adds.
//
// ⚠️ DOUBLE POINTS IS LEFT OUT ON PURPOSE, even though AwardPoints multiplies by
// PowerupEffects.PointsMultiplier immediately after this addition. It is a
// 30-second powerup, not a property of the loadout, and folding it in would make
// this row double and halve while a pickup ran — the same objection that keeps
// GetRealSpread's movement terms out of the Hip spread row.
// ⚠️ THE MATCH'S KILL POINTS (the lobby's Difficulty, 2026-10-05), as the walk and sprint above are its speeds
var kill = Difficulty.PointsKill
+ (int)MathF.Round( TechEffects.Factor( w, "t2_bounty", 0f ) );
var all = new[]
{
// ⛔ THE BAR USES `si.Damage`, THE TEXT USES THE PACKED FIGURE. Requested
// explicitly: "pack a punch does not affect the bars". It also has to work
// that way — the class range is built from authored prefab values, so a
// ×2.5 MK1 compared against them would peg every bar at 100% and tell you
// nothing about the gun underneath.
new Stat( "Damage", damage,
WeaponClassStats.Fraction( cls,
s => WeaponClassStats.Get( s, "Damage" )
* Math.Max( 1f, WeaponClassStats.Get( s, "Bullets" ) ),
si.Damage * Math.Max( 1, si.Bullets ) ) ),
// ⚠️ THE BAR IS THE SHARE, NOT THE DAMAGE — a display scale where a full bar means
// "a whole extra round's worth". The figure beside it is already scaled by the
// weapon's own damage, so putting it on the class Damage range would compare a
// blast against per-round damage and read as the node being weakest on the
// strongest gun. Scaled from the catalogue's `BoundOf` rather than a literal, so
// declaring the node's `Bound` moves the bar, the number and the blast at once.
new Stat( "Blast damage", blast > 0f ? $"{blast:0.#}" : "—",
blast > 0f ? Frac( WeaponTech.BoundOf( "t5_explosive", 0.5f ), 1f ) : 0f ),
// ⚠️ NO BAR — the one categorical row on this card. See the markup for why a
// track that can never fill is worse than no track at all.
new Stat( "Fire mode", fireModeText, 0f, Bar: false ),
@* ⛔ THE PERK IS IN THE DISPLAYED FIGURE. Double Tap divides the shot
delay in Weapon.GetRealRPM, so the panel would otherwise read the
unperked RPM while the gun fires 20% faster — the same disagreement
Speed Cola had with the reload line.
⛔ THIS COMMENT WAS FALSE FOR AS LONG AS IT EXISTED, and that is the
lesson worth keeping. It asserted the division in GetRealRPM as an
accomplished fact; the panel here was duly corrected to match it. But
the division was never actually written — GetRealRPM was a static
`60f / rpm` that could not even see the owner. So the number above was
the ONLY place Double Tap existed: the panel promised 720 RPM and the
gun kept firing 600.
It went unnoticed for exactly the reason the comment was written: the
only visible evidence of the perk was this figure, and this figure
agreed with itself. Anyone checking whether Double Tap worked read the
stat, saw it move, and stopped. Only a x10 test — an absurd value that
the ear cannot rationalise — exposed it. See PATTERNS OF MISTAKES §2
(self-referential measurement) and §6 (a wrong comment is worse than
none: it does not merely fail to help, it actively stops the check).
⚠️ The BAR is left on the base value on purpose. Its scale is the
class min/max (see WeaponClassStats), so a perked gun would read as
off-the-end rather than as fast, and comparing two weapons — which
is what the bar is FOR — would stop working while the perk is held.
The number tells you what you have; the bar tells you what the gun
is. *@
new Stat( "Rate of fire", $"{perkedRPM:0} RPM",
WeaponClassStats.Fraction( cls, "RPM", si.RPM ) ),
// ⚠️ `lowerIsBetter` — a short reload is a GOOD reload, so the fill has to
// invert or the worst weapon in the class would show the fullest bar.
// ⛔ MAGAZINE AND RESERVE WERE NOT SHOWN AT ALL, which made Extended Mag
// and Deep Pockets the only two tier-1 nodes with no way to see they had
// worked — and they are the two that DO write stored fields, so they were
// the easiest of the seven to display. An unshown stat is not a neutral
// omission when a purchase is supposed to change it.
//
// ⚠️ Read straight off the live ShootInfo and NZAmmo, so they reflect
// whatever ApplyStoredUpgrades last stamped on — no recomputation to drift.
//
// ⛔ AND THAT IS WHY EXTRA ROUNDS NEEDS NO CODE HERE. `t2_clip_flat` is a
// SPAWN-TIME node: ApplyStoredUpgrades → ApplyClipTech writes
// `(authored × 1.15) + 4` straight onto `si.ClipSize`, so this row already
// shows it. Adding `+ Factor( w, "t2_clip_flat", 0f )` on top would DOUBLE
// COUNT the node and display a magazine four rounds larger than the one the
// gun reloads — the inverse of the Double Tap failure and just as wrong.
new Stat( "Magazine", $"{si.ClipSize}",
WeaponClassStats.Fraction( cls, "ClipSize", si.ClipSize ) ),
// ⚠️ SCALED AGAINST 480, NOT A CLASS RANGE. Reserve is not in
// WeaponClassStats' index — every prefab authors the same 240 — so there
// is no roster spread to compare against and `Fraction` would return its
// neutral half-bar for every weapon. 480 is twice the authored default,
// so a stock gun sits at half and Deep Pockets visibly moves it. It is a
// display scale, deliberately, not a comparison.
// ⛔ AND THIS ROW IS WHERE LAST RESORT'S INFINITE AMMO SHOWS, BECAUSE THE NODE
// DOES NOT TOUCH `MaxReserve` AT ALL. NZPlayer.ApplyShotTech sets
// `si.InfiniteAmmo = InfiniteAmmoType.reserve` and nothing else (read there), so
// the capacity above stays whatever Deep Pockets left it at while the number has
// stopped meaning anything — Weapon.Reload's OnReloadFinish assigns
// `Primary.Ammo = maxClipSize` and RETURNS without calling TakeAmmo when that
// enum is `reserve` (read it), and OnShellReloadFinish skips TakeAmmo the same
// way. The reserve is never spent, so printing a finite figure for it would be
// the one wrong answer available.
//
// ⚠️ A WORD, NOT "∞", matching "Instant", "Never" and "None" elsewhere on this
// card — and the font is Inter, which the stylesheet chose because Consolas was
// not shipped; a glyph nobody has checked is a tofu box on the row that is
// supposed to be reassuring.
//
// ⚠️ A FULL BAR, because unlimited IS the top of any scale and this card's rule
// is that a full bar always means good. The 480 display scale is unreachable and
// meaningless in that state.
//
// ⚠️ `Primary`, VIA `si`, WHICH IS WHAT THE WEAPON READS. Every one of those
// reload checks tests `Primary.InfiniteAmmo` even when firing the secondary, so
// reading the same ShootInfo keeps this row honest for an underbarrel too.
new Stat( "Reserve",
si.InfiniteAmmo == InfiniteAmmoType.reserve ? "Unlimited" : Reserve,
si.InfiniteAmmo == InfiniteAmmoType.reserve ? 1f : Frac( ReserveValue, 480f ) ),
// ⛔ NOT A WEAPON STAT, AND IT IS HERE FOR THE REASON `Body kill` BELOW IS.
// Fabricator pays a magazine into the reserve every 60 seconds whether the gun
// is in your hands or on your back, which is ammo INCOME — a different KIND of
// number from every other row on this card, and the only row here measured per
// unit of time. The alternative is a tier-3 node with nowhere at all to show,
// and this file's own history (the Recoil and Limb damage blocks) is that an
// unshown stat gets judged DEAD rather than neutral.
//
// ⚠️ AND IT IS GENUINELY PER-WEAPON, which is what makes the row honest here
// rather than belonging on a perks screen: the payout is ONE MAGAZINE, so it is
// 100/min on the M60 and 2/min on the Olympia. That spread is the whole point
// of the node — it inverts Extra Rounds — and a bar can show it.
//
// ⚠️ "—" AND AN EMPTY BAR WITHOUT THE NODE, exactly as Penetration reads "None"
// on a weapon that cannot pierce: no income is a real zero, not a class floor.
//
// ⚠️ POTENTIAL INCOME, NOT BANKED INCOME. TickFabricator clamps the credit to
// MaxReserve and WASTES the remainder, so this figure is what the node delivers
// into a reserve with room. Showing the wasted part would need the live
// `Reserve`, which falls as you shoot — the same reason the Reserve row above
// shows capacity and not the current count.
new Stat( "Ammo income", fabIncome > 0f ? $"{fabIncome:0}/min" : "—",
Frac( fabIncome, MaxIncome ) ),
new Stat( "Reload", reload,
WeaponClassStats.Fraction( cls, "ReloadTime", w.ReloadTime, lowerIsBetter: true ) ),
// ⛔ SHOWN AS AN ANGLE, BECAUSE THE RAW NUMBER IS NOT A UNIT. "0.223" is the
// magnitude of a vector added to a unit forward vector before renormalising
// (`BulletInfo.HitScan`), so what the player experiences is a CONE: `atan( spread )`
// at the bound. A number with no unit cannot be compared to anything the player can
// see; 12.6° can be paced out against a wall.
//
// ⚠ THE BOUND, NOT THE MEAN, because the question the row answers is "how wide can
// this shot go". `nz_spread_angle` measures both — on the MAC11, mean 3.6° against a
// 12.6° bound, because `Vector3.Random` is a point INSIDE the unit sphere (measured
// mean length 0.75) rather than on it.
//
// ⚠ THE BAR IS UNCHANGED AND STILL SCALES ON THE RAW VALUE. `atan` is monotonic, so
// the ordering against the other thirty weapons is identical either way — and leaving
// the bar alone keeps the class min/max comparison working without re-deriving it.
new Stat( "Hip spread", $"{GlobalHandling.MaxConeDegrees( spread ):0.0}°",
WeaponClassStats.Fraction( cls,
s => WeaponClassStats.Get( s, "Spread" )
+ WeaponClassStats.Get( s, "SpreadAddHipFire" ),
// ⚠️ THE BASE VALUE, not the perked one — this bar's scale is the
// class min/max, so a perked gun would peg it and stop comparing
// with the other thirty weapons. Same split as Rate of fire: the
// NUMBER is what you have, the BAR is what the gun is.
spreadBase, lowerIsBetter: true ) ),
// ⚠️ Aim speed and aim walk stay on their ABSOLUTE scales. Both are
// derived from player settings and weapon aim data that the prefab index
// does not carry, and a comparison built from values I cannot read for the
// other thirty weapons would be a fabricated ranking.
// ⚠️ `Inv`, because less recoil is better — the same inversion the reload row needs.
//
// ⛔ AND THE BAR IS COMPRESSED, BECAUSE THE REAL RANGE IS 60:1 AND A LINEAR BAR WOULD
// BE ALL BOTTOM. Measured across 490 weapons under base mode:
//
// min 0.29 25% 0.69 median 1.02 75% 2.62 90% 10.26 max 17.60
//
// Scaled linearly to 17.6 the MEDIAN weapon sits at 6% of the bar and nothing below the
// 75th percentile is distinguishable from anything else. The skew is not an artefact:
// a 42 rpm bolt-action is stamped ×22.86 so that firing it nineteen times less often
// averages out, so per SHOT it really does kick sixty times an SMG.
//
// ⚠️ SAME EXPONENT AS THE MODEL LEAN (0.45), and for the same reason — it keeps the
// ordering, stays monotonic, and spreads the crowded end without inventing a cap that
// would make every sniper identical. The PRINTED figure is untouched and exact; only
// the bar is curved, which is the half that is a comparison rather than a fact.
new Stat( "Recoil", $"{recoil:0.00}",
Inv( MathF.Pow( recoil.Clamp( 0f, RecoilBarMax ), 0.45f ),
MathF.Pow( RecoilBarMax, 0.45f ) ) ),
// ⛔ ITS OWN ROW RATHER THAN FOLDED INTO Recoil ABOVE, and the choice is forced
// by what the node actually does, not by tidiness. Stabilizer leaves the kick
// untouched and only shortens the walk-back, so there is nothing to multiply the
// Recoil figure by — see the block where `instantRecovery` is computed. Folded in,
// the two directions would also cancel: a gun with a big kick that recovers
// instantly is a GOOD gun, and one number cannot say that.
//
// ⚠️ Placed immediately under Recoil so the pairing is obvious, which is what
// lets the label be "Recovery" — eight characters, comfortably inside the 210px
// label column, where "Recoil recovery" would wrap and make this row taller than
// the other twenty-three.
new Stat( "Recovery", recoveryText, recoveryBar ),
// ⚠️ THE BRING-UP ONLY, AND THE LABEL SAYS SO. A whole weapon swap is this plus
// `NZInventory.HolsterTime` — a static, 0.3s, shared by every weapon and player —
// which Fast Deploy cannot touch and Weapon.cs records why. It is left out on
// this panel's OWN rule rather than to flatter the node: HolsterTime is identical
// for every gun, so folding it in would shift all 31 rows by the same 0.3 and
// change no ranking, which is precisely why the Headshot row below compares on
// the weapon's multiplier instead of on the product with the zombie's. Showing
// "0.80s → 0.30s" would also read as the node underdelivering on a description
// that promises "draws instantly"; "0.50s → Instant" is what it does.
new Stat( "Draw time", drawText, Inv( draw, MaxDraw ) ),
// ⚠️ BOTH AIM ROWS COLLAPSE TO "None" ON A WEAPON THAT CANNOT AIM — see `noAds`.
new Stat( "Aim speed", noAds ? "None" : $"{aimIn:0.00}s",
noAds ? 0f : Inv( aimIn, MaxAimIn ) ),
new Stat( "Aim walk", noAds ? "None" : $"{aimWalk:0} u/s",
noAds ? 0f : Frac( aimWalk, baseWalk ) ),
// ⛔ THE FIRST ROW ON THIS CARD ABOUT MOVING WITHOUT AIMING, and it exists because
// Emplacement's -50% walk and -70% sprint had no home: the aim rows above are
// conditional on a state Bull Barrel cannot enter and Emplacement makes expensive,
// and every other row is about the gun rather than the player carrying it. Drum
// Magazine's walk penalty gets an unconditional home here too — it had only the
// aim-walk row before, which is the narrower half of what it does.
//
// ⚠️ THE BAR IS THE WALK FRACTION AGAINST THE UNPENALISED CONFIG SPEED — a display
// scale, deliberately: a stock weapon fills it, Drum Magazine takes 15% off it and
// Emplacement half. There is no roster of movement speeds to compare against,
// because these are player settings times a per-weapon factor, not weapon stats.
// The sprint figure shares the bar for the reason the Limb damage row's second
// figure does — one axis, two states, and the factors move together.
new Stat( "Move speed", $"{walkSpeed:0} / {sprintSpeed:0} u/s",
Frac( walkSpeed, baseWalk ) ),
// ⚠️ A weapon that cannot pierce keeps an EMPTY bar, not the 10% floor —
// "None" is a real zero, not a class minimum, and giving it a sliver would
// read as "pierces a little".
//
// ⚠️ AND THIS BAR MOVES WITH OVERPENETRATOR, UNLIKE Damage'S WITH PACK-A-PUNCH.
// The scale is the class range of AUTHORED depths, so a tripled weapon pegs at
// 100% — which is TRUE (x3 puts it clear of everything in its class) and is
// already how the Magazine and Reserve bars behave for spawn-time tech. What is
// kept off the bars is Pack-a-Punch, by request; a node that rewrites the stored
// field is indistinguishable from a weapon authored that way, which is the whole
// reason those two rows needed no code either.
// ⚠️ AND AN UNLIMITED BUDGET TAKES A FULL BAR RATHER THAN THE CLASS FRACTION,
// which would clamp a depth of 0 to the 10% floor — see the `pen` block.
new Stat( "Penetration", pen, !si.Penetration ? 0f
: si.PenetrationDepth <= 0f ? 1f
: WeaponClassStats.Fraction( cls, "PenetrationDepth", si.PenetrationDepth ) ),
// ⚠️ NEXT TO Penetration BECAUSE THEY ARE THE SAME LOOP. Both rows describe what
// one bullet does after its first impact, and the two capstones that force pierce
// also compete for the same ten iterations — a bounce and a pierced body cannot
// both happen on one segment (`BulletInfo.HitScan`: the bounce requires
// `target is null`).
new Stat( "Ricochets", ricochet, !si.Ricochet ? 0f
: forcedRicochet || vigorBounces > 0 ? 1f
: si.RicochetChance.Clamp( 0f, 1f ) ),
new Stat( "Hit radius", wideRadius > 0f ? $"{wideRadius:0.#} u" : "—",
wideRadius > 0f ? 1f : 0f ),
// ⚠️ Compared on the WEAPON's multiplier, not the product with the zombie's
// — the zombie term is identical for every gun, so including it would just
// shift the whole class and change no ranking.
new Stat( "Headshot", $"×{head:0.##}",
WeaponClassStats.Fraction( cls, "HeadMultiplier", si.HeadMultiplier ) ),
// ⛔ THERE WAS NO TORSO ROW, AND IT IS HALF OF WHAT TWO TIER-4 NODES DO. Body
// Shot's own Effect string is "torso and limbs x1.5" and Deadeye's is "body and
// limbs x0.5"; the limb halves land on the row below, and without this one the
// chest — the zone a spraying player hits most and the one both nodes name
// first — moved with nothing to show it. That is the gap the Recoil and Limb
// damage blocks in this file each record having had, and the reason a node gets
// judged dead rather than neutral.
//
// ⚠️ IT IS A FLAT x1.00 ON EVERY SHIPPED ZOMBIE, and that is not an argument
// against the row — Recovery reads 0.25s on all 31 weapons for the same reason
// and exists so that ONE node has somewhere to move. The value is read off a
// live Health rather than assumed, so a tuned boss variant reads correctly too.
//
// ⚠️ "Torso damage" IS TWELVE CHARACTERS, the same as "Rate of fire", which the
// stylesheet's 210px label column is sized for. No new style: this is the same
// label/track/value markup as the other twenty-three. Every label added since is
// held to the same twelve — "Blast damage" and "Zombie speed" are exactly it.
new Stat( "Torso damage", $"×{torsoMult:0.##}", Frac( torsoMult, zoneCeiling ) ),
// ⚠️ ALL THREE OF THE LABELS ADDED WITH THIS ROW ARE KEPT NO LONGER THAN
// "Rate of fire", which the stylesheet's 210px label column is sized for — a
// longer one wraps and knocks its row taller than the others.
//
// ⚠️ BOTH FIGURES, because Hollow Points moves both and one number would hide
// half of it. Arms and legs first, hands and feet second — outward from the
// chest, so the three hit-zone rows read head, torso, limb, extremity down the
// card.
//
// ⚠️ THE BAR IS A DISPLAY SCALE, NOT A CLASS COMPARISON. Nothing here is a
// weapon stat — WeaponClassStats' index carries Damage, Bullets, RPM,
// ClipSize, ReloadTime, Spread, SpreadAddHipFire, PenetrationDepth and
// HeadMultiplier, and no limb term — so `Fraction` has no roster range to
// work from and would return its neutral half-bar on all 31 weapons.
//
// ⛔ THE CEILING WAS 1.0 AND IS NOW SHARED WITH THE TORSO ROW ABOVE. Torso
// parity was the honest top of the scale until Body Shot could push a part
// multiplier PAST the chest; at 1.0 that node pegs this bar and the torso bar
// together and its whole span is invisible. See `zoneCeiling`.
new Stat( "Limb damage", $"×{limbMult:0.##} / ×{extremityMult:0.##}",
Frac( limbMult, zoneCeiling ) ),
// ⚠️ ALSO A DISPLAY SCALE — the fraction of damage a bullet keeps past
// FalloffEnd. Falloff is not in the class index either, and unlike Reserve it is
// genuinely spread across the roster (0.23 to 0.75, enumerated), so the ceiling
// is a real one rather than invented headroom.
//
// ⛔ AND THE CEILING IS NO LONGER 1.0, BECAUSE OF BOAT TAIL — THE ONE PANEL
// CHANGE THAT NODE DOES NEED. At 1.0 this row read "a full bar means no falloff
// at all", which was a true ceiling until damage could RISE with range;
// ApplyFalloffTech now clamps to `Min( … + add, bound )`, so the bound is the
// reachable maximum and 1.0 is merely the middle of the range. Left at 1.0,
// `Frac` clamps and every weapon from no-falloff upward pegs — the node's whole
// span would be invisible on the bar while its own row displayed 125%.
//
// ⛔ AND IT IS READ FROM THE CATALOGUE, NOT TYPED HERE. `WeaponTech.Node.Bound`
// exists precisely because Boat Tail spends its `Factor` on the +0.5 and still
// needs a ceiling; the same `BoundOf` call with the same fallback is what
// ApplyFalloffTech clamps with, so the bar's scale and the game's cap cannot
// drift apart. A 1.25 typed in this file would be a second source for a number
// the catalogue owns and `nz_tech` prints — the fault that record's own comment
// records having been added to fix.
new Stat( "Range damage", falloffText,
Frac( falloff, WeaponTech.BoundOf( "t3_inverse_falloff", WeaponTech.FalloffCeiling ) ) ),
// ⛔ RAILGUN NEEDS NOTHING ON THE ROW ABOVE AND READS CORRECTLY ANYWAY, which is
// worth writing down because it looks like an omission. `ApplyFalloffTech` sets
// `FalloffStart = FalloffEnd = 0` for that node, so the `FalloffEnd > FalloffStart`
// test in `falloff` fails, the row prints a bare "100%" with no distance, and
// `DamageFor` skips the lerp for exactly the same reason. The panel and the bullet
// agree because they ask the same question of the same two fields.
//
// ⚠️ INVERTED, BECAUSE A FASTER HORDE IS WORSE FOR THE PLAYER — the same inversion
// the Reload and Recoil rows need, and the reason this card's rule is that a full
// bar always means good. Scaled against x2 as a display scale: the status is one
// flat value with no roster to compare it against, and a doubled zombie is the
// sensible edge of the axis rather than an invented one.
//
// ⚠️ SO "—" TAKES A FULL BAR HERE WHILE IT TAKES AN EMPTY ONE ON Ammo income AND
// Hit radius, AND THAT IS THE RULE RATHER THAN AN INCONSISTENCY: those two rows
// measure a BENEFIT the weapon does not have, this one measures a PENALTY it does
// not inflict. Full still means good in all three.
new Stat( "Zombie speed", zombieSpeed > 0f ? $"×{zombieSpeed:0.##}" : "—",
zombieSpeed > 0f ? Inv( zombieSpeed, 2f ) : 1f ),
// ⚠️ SCALED AGAINST THE HEADSHOT AWARD, which is a value from the same
// config block rather than a number picked here: a body kill sits at half
// the bar by construction and Bounty visibly moves it toward the figure a
// headshot already pays. Still a display scale — there is no roster of
// weapons to compare a points award against.
new Stat( "Body kill", $"{kill} pts",
Frac( kill, Difficulty.PointsKillHeadshot ) ),
};
return Shown( all );
}
/// <summary>
/// The rows the card actually shows, in the order it shows them.
///
/// ⛔ AN ALLOW-LIST, NOT DELETIONS. Every row above is still computed and still correct;
/// this decides which nine reach the screen. Deleting the other fifteen would have thrown
/// away the derivations behind them — the hit-zone shaping, the range falloff, the points
/// awards — and every one of those is a place where an augment's contribution was traced
/// and verified. Getting a row back is a one-line edit here; getting it back from a
/// deletion is re-deriving it.
///
/// ⚠️ THIS LIST DEFINES THE ORDER TOO, so the card reads in the order asked for rather
/// than in whatever order the array happens to build. The two agree today; making the list
/// authoritative means they cannot silently stop agreeing.
///
/// ⛔ A NEW ROW WILL NOT APPEAR UNTIL IT IS NAMED HERE, and that is a real trap — a stat
/// added above and not added here is wired, correct, and invisible, which is the §2 shape
/// this card has already been bitten by three times. `nz_stats_rows` prints both lists side
/// by side so the gap is one command away rather than a puzzle.
///
/// ⚠️ FILTERS INSIDE `Stats()`, so the render loop, `BuildHash` and `nz_stats_dump` all
/// see one set of rows. A hash built over hidden rows would rebuild the card when nothing
/// visible had changed, and a dump listing rows the card does not show would answer a
/// different question than the one being asked of it.
///
/// ⚠️ A METHOD, NOT A STATIC ARRAY. Static collections are migrated across hotload and
/// this project has seven bugs from exactly that (§1); `PerkAugments.Pools()` is a method
/// for the same reason.
/// </summary>
static string[] RowOrder() => new[]
{
"Damage",
"Rate of fire",
"Magazine",
"Reload",
"Hip spread",
"Recoil",
"Aim speed",
"Penetration",
"Headshot",
};
static Stat[] Shown( Stat[] all )
=> RowOrder()
.Select( name => all.FirstOrDefault( r => r is not null && r.Label == name ) )
.Where( r => r is not null )
.ToArray();
/// <summary>
/// `nz_stats_rows` — which rows are shown, and which are computed but hidden.
///
/// ⚠️ EXISTS SO THE ALLOW-LIST CANNOT SILENTLY SWALLOW A ROW. A stat that is wired,
/// correct and simply not named in `RowOrder` looks identical to one that is broken, and
/// this card's whole history is bugs of that shape.
/// </summary>
[ConCmd( "nz_stats_rows" )]
public static void RowsCmd()
{
var panel = Game.ActiveScene?.GetAllComponents<WeaponStatsPanel>().FirstOrDefault();
if ( !panel.IsValid() )
{
// ⚠️ STILL USEFUL WITH NO PANEL: the order is a compile-time list, so it can be
// printed without a weapon in hand.
Log.Info( $"[nz-stats] shown ({RowOrder().Length}): "
+ string.Join( ", ", RowOrder() ) );
Log.Warning( "[nz-stats] no panel — cannot list the hidden rows" );
return;
}
var shown = panel.Stats().Select( r => r.Label ).ToArray();
Log.Info( $"[nz-stats] shown ({shown.Length}): {string.Join( ", ", shown )}" );
var missing = RowOrder().Where( n => !shown.Contains( n ) ).ToArray();
if ( missing.Length > 0 )
Log.Warning( $"[nz-stats] ⛔ named but NOT produced: {string.Join( ", ", missing )}"
+ " — a typo in RowOrder, or the row is conditional" );
}
/// <summary>
/// Penetration budget as a thickness. One source unit is one inch.
///
/// ⚠️ This row is what makes the M1911's outlier visible: its lua authors
/// `SWEP.Penetration = 4 * 39` (156 units, ~4 m) while every other weapon in
/// the pack authors a plain 2–10. Both are ported faithfully — the pack is
/// inconsistent with itself, and the number is shown as-is rather than
/// quietly normalised, because guessing which convention was meant would be
/// inventing balance.
/// </summary>
/// <summary>
/// Penetration, in ZOMBIES. Not centimetres.
///
/// ⛔ THIS ROW SAID "4.0 m" ON 249 OF 496 WEAPONS AND THE ARITHMETIC WAS NEVER WRONG. One
/// source unit is one inch, so 157.97 x 2.54 really is 401 cm — the conversion was correct
/// and the SENTENCE was meaningless. `PenetrationDepth` is not a thickness of material: the
/// bullet loop spends it as `budget -= thicknessCrossed + 1.0f` on the way OUT of each
/// surface, so it is a per-body budget. Telling a player their pistol shoots through four
/// metres of wall describes nothing they can act on; telling them it goes through three
/// zombies is the same number in the unit the game is played in.
///
/// ⚠️ `DtapAugments.BodyDepth` IS THE CONVERSION AND IS READ, NOT COPIED. 11 — about ten
/// units of torso plus the one-unit surcharge — and it is already the number Double Tap's
/// m1 uses to turn "+8 zombies" into a budget. A second copy here would be a second
/// definition of what a zombie costs, and the card would eventually disagree with the
/// augment that promises it.
///
/// ⚠️ CAPPED AT `MaxPenetrations`, WHICH IS THE REAL CEILING. A budget of 14.4 bodies is
/// still ten bodies through a trace loop that stops at ten — the row above already makes
/// this point about "Unlimited", and a card printing 14 for a gun that delivers 10 is the
/// same lie in a different place.
///
/// ⚠️ UNDER ONE BODY IS SAID PLAINLY. 187 weapons carry a budget below a single torso — the
/// old formatter rendered those as a confident "20 cm", which reads like a small amount of
/// penetration rather than none at all.
/// </summary>
static string PenDepth( float units )
{
// ⛔ ZERO IS THE UNLIMITED SENTINEL, NOT "NO PENETRATION", and it has to be caught
// before the arithmetic. The spend is gated on `penBudget > 0f`, so a depth of 0 is never
// charged and the bullet runs to the `MaxPenetrations` valve — `NZPlayer.ApplyTech` sets
// exactly this for Railgun's infinite pierce. Falling through to the divide would print
// "1 zombie" for the strongest penetration in the game.
if ( units <= 0f ) return $"{MaxPenetrationBodies} zombies (unlimited)";
// ⛔ CEILING, NOT TRUNCATION, BECAUSE THE FIRST BODY IS FREE. The budget is charged on
// the way OUT of each body, so a bullet with any budget at all crosses one; a budget of
// 2.5 survives two charges and stops on the third, which is 3 bodies. Truncating gave 2,
// and the `X.97` values throughout the prefab data — 9.97, 5.97, 3.97 — only make sense
// read this way: someone authored them as "10, 6, 4 bodies, just under the boundary".
var perBody = MathF.Max( 0.01f, NZombies.DtapAugments.BodyDepth );
var bodies = (int)MathF.Ceiling( units / perBody );
return bodies >= MaxPenetrationBodies
? $"{MaxPenetrationBodies} zombies (max)"
: bodies <= 1 ? "1 zombie" : $"{bodies} zombies";
}
/// <summary>
/// `MaxPenetrations` from the bullet loop. 10.
///
/// ⚠️ A THIRD COPY OF A NUMBER THAT ALREADY HAS TWO — `BulletInfo.HitScan` and
/// `PhysicalBullet.Mover` — and this card's own comment above complains about exactly
/// that. It is here rather than read because both existing copies are private locals in
/// the trace loops; the honest fix is to promote one of them to a shared constant and have
/// all three read it, which is a change to the bullet path and not to a stats card.
/// </summary>
const int MaxPenetrationBodies = 10;
/// <summary>
/// One hit-zone multiplier after the tier-4 archetypes — a floor, then a ceiling.
///
/// ⛔ THE SAME SHAPE AND THE SAME NAME AS `Health.Shaped`, WHICH IS THE POINT. That
/// method is `static` and private, so it cannot be called from here; this is a
/// deliberate mirror of it rather than a second design, and the name is kept so that
/// anyone changing one goes looking for the other. Both take the floor first and the
/// ceiling second, which is what decides that Deadeye beats Body Shot when a creative
/// weapon holds both.
///
/// ⚠️ 0 MEANS "ABSENT" FOR BOTH BOUNDS, the convention `limbFloor` in Stats already
/// uses and the one Health documents — not a sentinel invented here.
/// </summary>
static float Shaped( float part, float floor, float ceiling )
{
if ( floor > 0f ) part = MathF.Max( part, floor );
if ( ceiling > 0f ) part = MathF.Min( part, ceiling );
return part;
}
/// <summary>
/// A fire mode as a word, for the Fire mode row.
///
/// ⚠️ NOT `mode.ToString()`. The enum's members are lowercase (`semi`, `auto`, `burst`)
/// because the prefab JSON authors them that way and `Enum.TryParse` reads them back, so
/// printing the enum would put "semi" on a card whose other word values are "Instant",
/// "Never", "None" and "Unlimited". One row using the serialiser's casing is the kind of
/// detail that makes a card look generated rather than written.
///
/// ⚠️ NO COUNT HERE, deliberately: the burst LENGTH belongs to `BurstRoundsFor` and is
/// appended by the caller, because the same helper also names the AUTHORED mode — where
/// the length would be `SwbBurstRounds`, a private const in Weapon.Shoot.cs, and a
/// literal 3 in this file would be a second copy of it.
/// </summary>
static string ModeName( FiringType mode ) => mode switch
{
FiringType.auto => "Auto",
FiringType.burst => "Burst",
_ => "Semi",
};
static float Frac( float v, float max ) => max <= 0 ? 0 : (v / max).Clamp( 0f, 1f );
/// <summary>Lower is better: a short reload should draw a LONG bar.</summary>
/// <summary>
/// The heaviest per-shot recoil on the roster, base mode. 17.6 degrees.
///
/// ⚠️ MEASURED, NOT GUESSED, and it is `VerticalBase 0.35 × RecoilScale 2.2 × 22.86`, the
/// largest `RecoilVerticalMult` in the game (a 42 rpm bolt-action). The old scale said "3.0,
/// the highest RecoilUp on the roster" and the real highest was 5.0 — a number that drifted
/// because nothing recomputed it. If the base or the scale move, this moves with them.
/// </summary>
const float RecoilBarMax = 17.6f;
static float Inv( float v, float max ) => max <= 0 ? 0 : (1f - v / max).Clamp( 0f, 1f );
protected override void OnUpdate()
{
// ⚠️ "View" is C, and it is free because third person is disabled
// (NZPlayer sets ToggleCameraModeButton = ""). If that ever comes back
// the two will fight over the same key.
if ( Input.Pressed( "View" ) )
Open = !Open;
}
/// <summary>Toggle without the keyboard. `nz_stats` / `nz_stats 1`.</summary>
[ConCmd( "nz_stats" )]
public static void Toggle( int state = -1 )
{
Open = state < 0 ? !Open : state > 0;
Log.Info( $"[stats] panel {(Open ? "open" : "closed")}" );
}
/// <summary>Print the same numbers to console, for checking them remotely.</summary>
[ConCmd( "nz_stats_dump" )]
public static void Dump()
{
var panel = Game.ActiveScene?.GetAllComponents<WeaponStatsPanel>().FirstOrDefault();
if ( !panel.IsValid() ) { Log.Info( "[stats] no panel in scene" ); return; }
Log.Info( $"[stats] {panel.WeaponName}" );
if ( panel.PackLabel.Length > 0 ) Log.Info( $"[stats] pack · {panel.PackLabel}" );
foreach ( var s in panel.Stats() )
// ⚠️ "no bar" RATHER THAN "bar 0%" for the categorical row, because 0% is a
// measurement and this row has none — the same distinction the markup draws.
Log.Info( $"[stats] {s.Label,-13} {s.Value,-22} "
+ (s.Bar ? $"bar {s.Frac * 100:0}%" : "no bar") );
}
// ⚠️ Every displayed number changes with the held weapon and with the round,
// so the hash covers the weapon identity and the values themselves — hashing
// only `Open` would freeze the card on whatever was held when it opened.
// ⚠️ RarityTier IS IN HERE EXPLICITLY, even though changing it also changes the
// damage row and would therefore rebuild anyway. Relying on that is the trap
// ArsenalPanel records: a displayed value the hash cannot see only repaints by
// luck, and the luck runs out the first time something moves the tier without
// moving the damage — which `nz_rarity_set` on a weapon whose push failed does
// exactly.
//
// ⚠️ THE SEVEN TIER-2 NODES NEEDED NOTHING ADDED HERE, and that was checked rather
// than assumed: the join covers EVERY row's Value string, so the three new rows and
// the four changed ones are all inside it already — buying any of the seven moves a
// Value and the card repaints.
//
// ⚠️ THE FIVE TIER-3 NODES NEEDED NOTHING ADDED EITHER, and this was checked term by
// term against the rule the PointsKillHeadshot note below states — a new row's BAR
// needs a hash term only when it depends on something that appears in NO Value
// string. Recovery's bar comes from `RecoilRecoveryTime` and the node, and both show
// in its own value ("0.25s" / "Instant" / "Never"). Draw time's comes from the same
// composed `draw` its value prints, so `nz_draw_time` and the node both move it.
// Ammo income's comes from ClipSize and the catalogue interval, both in its value.
// Range damage's new ceiling comes from the catalogue, which is code — editing it
// recompiles and rebuilds the panel, so no runtime term can move it. Penetration and
// Range damage are spawn-time rows whose values were already joined.
//
// ⚠️ THE ELEVEN TIER-4 NODES NEEDED NOTHING ADDED EITHER, and this was checked bar by
// bar against the same rule — a bar needs a hash term only when its scale depends on
// something that appears in NO Value string. The one new row's bar (Torso damage) is
// `Frac( torsoMult, zoneCeiling )`, and `torsoMult` is printed in that row's own value
// while `zoneCeiling` is read from the catalogue, which is code: editing it recompiles
// and rebuilds the panel, so no runtime term can move it. Same for the Limb damage
// bar's new ceiling. The Reserve bar switches to a flat 1 on Last Resort, and the
// thing that switches it also rewrites that row's value to "Unlimited". Every other
// changed row moves a Value: Damage, Recoil, Aim walk, Headshot, Limb damage and Range
// damage are all joined already.
//
// ⚠️ ONE TERM WAS STILL MISSING, for the reason the RarityTier note above gives.
// `PointsKillHeadshot` sets the Body kill BAR's ceiling and appears in no Value
// string, so `nz_points headshot 200` would rescale that bar with nothing in the
// hash to notice — a width that only ever repaints because some other row happened
// to change. Every other bar's scale is either a constant or derived from a value
// that is already joined.
// ⚠️ AND THE ELEVEN TIER-5 NODES NEEDED ONE TERM, CHECKED THE SAME WAY. Every new row's
// value is inside the join, and every new bar's scale is either a constant, a catalogue
// number (code — editing it recompiles and rebuilds the panel) or a value the same row
// prints: Blast damage's scale is `BoundOf`, Fire mode has no bar at all, Move speed's is
// the config walk speed and its own value moves with it, Ricochets' is the chance printed
// beside it, Hit radius' and Penetration's unlimited branch are switched by the thing that
// rewrites their text, and Zombie speed's is a constant. The four CHANGED bars are the
// same story: Hip spread and Recoil now carry tech factors that move their own values,
// and both aim rows switch to "None" with the bar.
//
// ⛔ THE TERM IS `IsChimera`, FOR EXACTLY THE REASON `RarityTier` IS IN HERE. It is drawn
// in the header and appears in NO value string — and the one roll in thirty-one per axis
// that draws the weapon's own number back is a roll that changes nothing else on the card,
// so the marker would appear only by the luck of some other value moving. That is the trap
// ArsenalPanel records, and this node gets ONE roll ever.
//
// ⛔ AND IT NO LONGER COSTS ANYTHING WHILE THE CARD IS CLOSED, WHICH IS ALMOST ALWAYS.
// BuildHash runs every frame whatever `Open` says, and everything after it — six
// `Scene.GetAllComponents` sweeps and, since tier 5, roughly twenty `TechEffects` lookups,
// each an ancestor component get plus a prefab resolve plus a `TechFor` that allocates a
// fresh List — was being paid on every frame of every round to hash values nothing was
// drawing. `Open` is the only term that can change what a closed panel renders, so
// returning it alone is exact rather than approximate: the frame it flips, the hash
// changes and the card repaints with current numbers.
//
// ⚠️ THE RULE R17 STATES FOR THE BULLET LOOP, APPLIED TO THE ONE THAT RUNS EVEN MORE
// OFTEN. `nz_stats_dump` calls `Stats()` directly, so the console path is unaffected.
protected override int BuildHash()
{
if ( !Open ) return System.HashCode.Combine( Open, HudTheme.Class );
// ⚠️ PackLabel TOO, for RarityTier's reason: it is drawn and appears in no Value
return System.HashCode.Combine(
Open, Held?.ClassName, RarityTier, IsChimera, HudTheme.Class,
Difficulty.PointsKillHeadshot, PackLabel,
string.Join( "|", Stats().Select( s => s.Value ) ) );
}
}