Static helper for the game's weapon tech system. It resolves who owns a tech node for a given weapon or damage instance, exposes accessors to check ownership, read node factors and extra magnitudes (with an Amplify debug override), serializes a tech stamp onto DamageInfo, and provides console diagnostics (nz_tech_amp, nz_tech_live) to inspect effective vs authored weapon values.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// WEAPON TECH — the one place that answers "does this weapon own that node".
///
/// ⛔ EVERY EFFECT SITE GOES THROUGH HERE. Each of the 41 nodes needs the same three
/// steps — find the owner, find the prefab key, check the list — and writing those
/// inline at 41 call sites would be 41 copies of one lookup. PerkEffects already learned
/// this the expensive way: it had FOUR hand-written copies of its owner lookup before
/// they were collapsed into `OwnerOf`, and the risk is not the duplication itself but
/// that a fix lands on some copies and not others (INSTRUCTIONS.md §3).
///
/// ⚠️ THE FACTOR COMES FROM THE CATALOGUE, NOT FROM THE CALL SITE. `Factor` reads
/// `WeaponTech.Node.Factor`, so tuning a node means editing one number in
/// `WeaponTech.cs` and nothing else. A call site that hardcoded 1.15 would be a second
/// source for a value the catalogue already owns, and the catalogue is what `nz_tech`
/// prints — so they would disagree silently and the printed table would be the liar.
///
/// ⚠️ NOTHING HERE IS CACHED. Tech can be bought mid-round and the weapon is a clone
/// that Pack-a-Punch destroys and respawns, so a cached answer is stale in two
/// different ways. These are cheap list lookups on a list that is at most 41 long.
/// </summary>
public static class TechEffects
{
/// <summary>
/// The nZombies player holding a weapon, or null.
///
/// ⚠️ `InAncestors | Enabled`, the same mode `Weapon.Reload` and
/// `PerkEffects.OwnerOf` use. The note in Weapon.Reload explains why the
/// `EverythingIn...` composites are avoided; do not "simplify" this to one of them.
/// </summary>
static NZPlayer OwnerOf( Component weapon )
=> weapon.IsValid()
? weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
: null;
/// <summary>Does the owner of this weapon have that node, on THIS weapon.</summary>
public static bool Has( Component weapon, string nodeId )
{
if ( !weapon.IsValid() || string.IsNullOrEmpty( nodeId ) ) return false;
var player = OwnerOf( weapon );
if ( !player.IsValid() ) return false;
// ⚠️ Resolved from the WEAPON, not from whatever the player is holding. With two
// slots those differ, and reading the active weapon's prefab here would apply the
// tech from one gun to the other — the trap NZPlayer records having cost a free
// Pack-a-Punch level three separate times.
var prefab = Rarity.PrefabOf( weapon as SWB.Base.Weapon );
if ( string.IsNullOrEmpty( prefab ) ) return false;
return player.HasTech( prefab, nodeId );
}
/// <summary>Does a known player+prefab pair own that node. For the spawn-time path.</summary>
public static bool Has( NZPlayer player, string prefab, string nodeId )
=> player.IsValid()
&& !string.IsNullOrEmpty( prefab )
&& player.HasTech( prefab, nodeId );
/// <summary>
/// Chimera's rolled fire mode for this weapon, or null: no roll on its prefab, or the drawn
/// name did not parse (`NZPlayer.ChimeraMode` is then absent, meaning "keep the authored mode").
///
/// ⛔ READ BY `Weapon.EffectiveFiringType`, AND UNTIL 2026-10-03 NOTHING READ IT. The roll stored
/// a mode from day one and the gun kept firing its own — the stats card even said so in a note.
/// Resolved from the WEAPON's prefab, like `Has`, so the other slot's roll never leaks across.
/// </summary>
public static SWB.Base.FiringType? ChimeraMode( Component weapon )
{
if ( !weapon.IsValid() ) return null;
var player = OwnerOf( weapon );
if ( !player.IsValid() || player.ChimeraRolls.Count == 0 ) return null;
var prefab = Rarity.PrefabOf( weapon as SWB.Base.Weapon );
if ( string.IsNullOrEmpty( prefab ) ) return null;
return player.ChimeraRolls.TryGetValue( prefab, out var roll )
&& roll.TryGetValue( NZPlayer.ChimeraMode, out var mode )
? (SWB.Base.FiringType)(int)mode
: null;
}
// ══ the relayed hit ═══════════════════════════════════════════════════════════════
/// <summary>The `DamageInfo.Extra` key the firing weapon's prefab path travels under.</summary>
public const string WeaponKey = "nz_wep";
/// <summary>The `DamageInfo.Extra` key the firing weapon's penetration depth travels under.</summary>
public const string PenetrationKey = "nz_pen";
/// <summary>
/// WHOSE TREE AND WHICH WEAPON'S, RESOLVED ONCE AND CARRIED — the form every tech read on
/// the VICTIM'S side must use, because on the host a client's weapon does not exist.
///
/// ⛔ `damage.Weapon` IS NULL FOR EVERY HIT A CLIENT RELAYS, AND THAT SILENTLY DISABLED
/// EIGHT NODES. Weapon prefabs are `NetworkMode.Never` — `NZPlayer.WorldModelPath` says so
/// and sends a PATH instead — so the weapon GameObject the shooter names has no counterpart
/// on the host. `Health.FiredBy` returned null there and `TechEffects.Has` therefore answered
/// "does not own it" for Hollow Points, Body Shot, Precision Rounds, Deadeye, Wide Bore,
/// Perforator, Bouncy Rounds and Bounty. Nothing logged and nothing threw; a client just had
/// eight nodes that quietly did nothing.
///
/// ⚠️ THE PREFAB PATH IS THE HANDLE, NOT THE WEAPON, and that is not a workaround — the
/// prefab path is what tech is keyed by in the first place. `TechOwned`, `PapLevels` and
/// `RarityTiers` are all keyed on it for the reason `NZPlayer.AddTech` records (the weapon is
/// a clone Pack-a-Punch destroys and respawns), so it is also the only thing that HAS to
/// travel. `NZNet.HurtRemote` stamps it into `DamageInfo.Extra`.
///
/// ⚠️ IT STILL PREFERS THE REAL WEAPON WHEN THERE IS ONE, so the shooter's own reads —
/// which is nearly every `TechEffects` call in the project — resolve exactly as before and
/// never consult the stamp.
///
/// ⚠️ THE PLAYER IS THE OTHER HALF AND IT REPLICATES. A `TechRef` off the wire is
/// (attacker, prefab) with no weapon, and `NZPlayer.TechFor` answers from the synced copy on
/// a machine that does not own the player. See `NZPlayer.TechNet`.
/// </summary>
public readonly struct TechRef
{
public TechRef( NZPlayer player, string prefab, SWB.Base.Weapon weapon )
{
Player = player;
Prefab = prefab;
Weapon = weapon;
}
/// <summary>The shooter. Present on both machines — players replicate, weapons do not.</summary>
public readonly NZPlayer Player;
/// <summary>The firing weapon's prefab path, which is what tech is keyed by.</summary>
public readonly string Prefab;
/// <summary>The firing weapon itself. NULL on the host for a hit a client relayed.</summary>
public readonly SWB.Base.Weapon Weapon;
/// <summary>Is there a tree to ask at all.</summary>
public bool Valid => Player.IsValid() && !string.IsNullOrEmpty( Prefab );
/// <summary>The shooter's body, for a DamageInfo built downstream of this hit.</summary>
public GameObject AttackerGo => Player.IsValid() ? Player.GameObject : null;
/// <summary>The weapon's object, or null where the weapon is not on this machine.</summary>
public GameObject WeaponGo => Weapon.IsValid() ? Weapon.GameObject : null;
/// <summary>
/// The stamp to hang on a DamageInfo this hit produces, so the tech survives one more hop.
///
/// ⛔ BOUNCY ROUNDS DEALS DAMAGE OF ITS OWN, AND A LINK THAT CARRIED NEITHER A WEAPON
/// NOR A STAMP WOULD ARRIVE AT `Health.OnDamage` AS AN ANONYMOUS HIT — no Hollow Points
/// floor on its limbs and no tree to read if it needed one. On the host the chain runs
/// with `Weapon` null, so the stamp is the only thing the link can carry.
/// </summary>
public Dictionary<string, string> Stamp()
=> string.IsNullOrEmpty( Prefab )
? null
: new Dictionary<string, string> { [WeaponKey] = Prefab };
}
/// <summary>
/// A weapon that IS on this machine — the shooter's own reads.
///
/// ⚠️ `as`, NOT A COMPONENT LOOKUP, matching `Has( Component, string )` exactly: a
/// caller that hands in something which is not an `SWB.Base.Weapon` gets no tree, which is
/// the answer it got before this overload existed.
/// </summary>
public static TechRef Of( Component weapon )
{
var wep = weapon as SWB.Base.Weapon;
return wep.IsValid()
? new TechRef( OwnerOf( wep ), Rarity.PrefabOf( wep ), wep )
: default;
}
/// <summary>
/// A hit, from either machine: the weapon when it is here, the relay's stamp when it is not.
///
/// ⚠️ `damage.Weapon` IS RELIABLY SET ON THE LOCAL BULLET ROUTE — checked, not assumed.
/// Both bullet paths build their DamageInfo through `SWB.Shared.DamageInfo.FromBullet`
/// (BulletInfo.HitScan.cs:69 and PhysicalBullet.Mover.cs:105) and both pass the weapon's
/// GameObject as its second argument. That is the same funnel that stamps `TagsHelper.Bullet`.
///
/// ⚠️ AND NOTHING RESOLVES FOR EVERY OTHER SOURCE, which is the right answer rather
/// than a gap: the knife, grenades, traps, StatusEffects and the test commands build their
/// DamageInfo by hand and set neither a weapon nor a stamp, so they cannot pick up a rifle's
/// tree. Every accessor below returns its `ifAbsent` for an invalid ref.
///
/// ⚠️ `EverythingInSelf` on the weapon, not a plain `Get` — trap 2 in INSTRUCTIONS.md. A
/// weapon component is DISABLED while holstered, and a hit resolved a frame after a swap is
/// not worth being fragile about.
/// </summary>
public static TechRef Of( in Sandbox.DamageInfo damage )
{
if ( damage is null ) return default;
var wep = damage.Weapon.IsValid()
? damage.Weapon.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
: null;
if ( wep.IsValid() )
return new TechRef( OwnerOf( wep ), Rarity.PrefabOf( wep ), wep );
var extra = (damage as SWB.Shared.DamageInfo)?.Extra;
if ( extra is null
|| !extra.TryGetValue( WeaponKey, out var prefab )
|| string.IsNullOrEmpty( prefab ) ) return default;
// ⚠️ `EverythingInSelfAndAncestors`, matching `ZombieAI`'s resolve of the same object:
// the attacker GameObject is whatever the damage named, which is not guaranteed to be the
// object the NZPlayer component sits on.
var player = damage.Attacker.IsValid()
? damage.Attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
return new TechRef( player, prefab, null );
}
/// <summary>Does the shooter own that node, on the weapon that fired. Both machines.</summary>
public static bool Has( in TechRef tech, string nodeId )
=> tech.Valid
&& !string.IsNullOrEmpty( nodeId )
&& tech.Player.HasTech( tech.Prefab, nodeId );
/// <summary>The node's factor when owned, or <paramref name="ifAbsent"/>. Both machines.</summary>
public static float Factor( in TechRef tech, string nodeId, float ifAbsent = 1f )
{
if ( !Has( tech, nodeId ) ) return ifAbsent;
var node = WeaponTech.Find( nodeId );
return node is null ? ifAbsent : Amped( node );
}
/// <summary>One of a node's named extra magnitudes. Both machines.</summary>
public static float Mag( in TechRef tech, string nodeId, string name, float ifAbsent = 1f )
=> Has( tech, nodeId ) ? AmpedMag( nodeId, name, ifAbsent ) : ifAbsent;
/// <summary>
/// The firing weapon's penetration depth — Bouncy Rounds' link budget.
///
/// ⛔ THE ONLY WEAPON *STAT* THAT TRAVELS, AND THE REASON NOTHING ELSE HAS TO. Every other
/// relayed node needs one question answered — "does this tree own that id" — and the tree
/// replicates. This one needs a number authored on the weapon's own ShootInfo, and the weapon
/// is not on this machine; reading it off the prefab would mean loading a prefab on the damage
/// path. So the shooter sends it, in the same RPC that already carries the damage.
///
/// ⚠️ PRIMARY FIRE, MATCHING THE LOCAL READ IT REPLACES. Every prefab in the pack authors
/// penetration on primary; a secondary-only pierce stat would read the wrong number, and no
/// weapon has one.
/// </summary>
public static float PenetrationOf( in TechRef tech, in Sandbox.DamageInfo damage )
{
if ( tech.Weapon.IsValid() )
return tech.Weapon.GetShootInfo( true )?.PenetrationDepth ?? 0f;
var extra = (damage as SWB.Shared.DamageInfo)?.Extra;
return extra is not null
&& extra.TryGetValue( PenetrationKey, out var s )
&& float.TryParse( s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var pen )
? pen
: 0f;
}
/// <summary>
/// Exaggerate every node's effect, for testing. 1 is normal.
///
/// ⛔ THE POINT IS THAT A PLAUSIBLE NUMBER CANNOT BE JUDGED BY EYE. -20% recoil and
/// stock recoil look identical, so "is this node wired" is unanswerable by feel at
/// its real magnitude. At x10 the answer takes one magazine. This is the instrument
/// that proved Double Tap, Vigor Rush and Deadshot were applied to NOTHING while
/// their stat panel displayed dutifully perked numbers.
///
/// ⚠️ A STATIC, SO IT SURVIVES HOTLOAD and stays on until switched off — the right
/// trade for a test override, which is useless if a code edit silently resets it, but
/// `nz_tech_amp` prints the state loudly for exactly that reason (§1).
/// </summary>
public static float Amplify { get; set; } = 1f;
/// <summary>
/// Is this node's factor a MULTIPLIER, and therefore safe to exaggerate.
///
/// ⛔ NOT EVERY FACTOR IS A MULTIPLIER, and treating them alike would produce
/// nonsense rather than an exaggeration. `t2_clip_flat` stores 4 meaning "+4 rounds";
/// raised to the tenth power that is a million-round magazine. `t3_fabricator` stores
/// 60 meaning seconds. `t3_recovery` and `t3_deploy` store 0 meaning "set to zero",
/// which no exponent changes.
///
/// ⚠️ DECIDED FROM THE `Lever` STRING, which already documents what kind of lever
/// each node drives — rather than a second field to keep in step with it. If a Lever
/// is ever reworded, this reads FALSE and the node is reported as SKIPPED rather than
/// silently mangled. Failing loudly was the design goal; a heuristic that guesses
/// wrong in the amplifying direction would look like a bug in the node.
/// </summary>
/// <summary>How a node's factor behaves, and therefore how to exaggerate it.</summary>
public enum FactorKind
{
/// <summary>A multiplier. Exaggerated by raising to a power.</summary>
Multiplier,
/// <summary>An addend (+4 rounds, +50 RPM, +10 points). Exaggerated linearly.</summary>
Flat,
/// <summary>A target value, a duration or a set-to-zero. Not exaggerated at all.</summary>
Absolute,
}
/// <summary>
/// Which kind a node's factor is.
///
/// ⛔ THE THREE KINDS NEED THREE DIFFERENT EXAGGERATIONS, and using one rule for all
/// of them produces nonsense rather than a bigger effect:
///
/// Multiplier 1.15 -> pow, so x4.05. Scaling the deviation would send a factor
/// BELOW one negative (0.80 becomes -1.0, firing the kick downward).
/// Flat +4 rounds -> x10 linearly, so +40. `pow` would be 4^10, a
/// million-round magazine.
/// Absolute Long Barrel's 0.75 is a FLOOR, Fabricator's 60 is SECONDS, and
/// Stabilizer's 0 means "set to zero". None of those has a meaningful
/// tenfold. A 7.5 damage floor is not an exaggeration, it is garbage.
///
/// ⚠️ DECIDED FROM THE `Lever` STRING, which already documents what each node drives
/// — rather than a second field to keep in step with it. If a Lever is reworded this
/// falls back to Absolute, so the node is reported as NOT amplified rather than
/// silently mangled. A heuristic guessing wrong in the amplifying direction would look
/// like a bug in the node itself, which is the expensive failure.
/// </summary>
public static FactorKind KindOf( WeaponTech.Node node )
{
if ( node is null || node.Factor <= 0f ) return FactorKind.Absolute;
if ( node.Lever.Contains( "flat" ) ) return FactorKind.Flat;
if ( node.Lever.Contains( "floor" )
|| node.Lever.Contains( "timer" )
|| node.Lever.Contains( "= 0" ) ) return FactorKind.Absolute;
return FactorKind.Multiplier;
}
static bool CanAmplify( WeaponTech.Node node )
=> KindOf( node ) != FactorKind.Absolute;
/// <summary>
/// Apply <see cref="Amplify"/> to a multiplier.
///
/// ⛔ `pow`, NOT A SCALED DEVIATION. The obvious `1 + (f - 1) * amp` works for a
/// factor above 1 and BREAKS every factor below it: Recoil Control's 0.80 becomes
/// 1 - 0.2*10 = -1.0, a NEGATIVE recoil multiplier that would fire the kick downward.
///
/// Raising to a power is symmetric and cannot cross zero — it treats "ten times the
/// effect" as applying the node ten times over, which is what the phrase means
/// multiplicatively. 1.15 becomes 4.05; 0.80 becomes 0.107; 1.0 stays 1.0.
///
/// ⛔ AND THE RESULT IS CLAMPED TO [0.01, 10], WHICH IS NOT COSMETIC. `pow` is right
/// for the SUBTLE factors this instrument exists for, and absurd for the loud ones:
/// Wide Bore's x5 raised to the tenth is x9,765,625, and Bolt Gun's x4 is x1,048,576.
/// Those are not exaggerations, they are overflow with extra steps — a damage figure
/// like that would clamp against zombie health and read as "the node does nothing",
/// which is the exact false negative this command exists to rule out.
///
/// ⚠️ The clamp also means a tier-4/5 node barely moves under amplification, and
/// that is correct: those nodes are already dramatic. x4 damage needs no help being
/// visible. The instrument is for the tier-1 magnitudes that look identical to stock.
/// </summary>
static float Amped( WeaponTech.Node node )
{
var f = node.Factor;
if ( Amplify == 1f ) return f;
return KindOf( node ) switch
{
// ⚠️ Linear, and NOT clamped to 10 — the clamp exists to stop a multiplier
// overflowing, and an addend of +40 rounds or +500 RPM is exactly the absurd
// value the instrument is for.
FactorKind.Flat => f * Amplify,
FactorKind.Multiplier => MathF.Pow( f, Amplify ).Clamp( 0.01f, 10f ),
_ => f,
};
}
/// <summary>
/// Amplify a NAMED SECONDARY MAGNITUDE, the way <see cref="Amped"/> amplifies a
/// node's primary factor.
///
/// ⛔ THIS EXISTED NOWHERE AND THAT WAS A REAL BUG, reported from play as "Tuned
/// Action is not affecting fire rate". It was: at `nz_tech_amp 10` its DAMAGE half
/// (the primary `Factor`) became x9.3 while its FIRE RATE half (a secondary magnitude)
/// stayed at x1.10 — so the damage was unmistakable and the fire rate was invisible,
/// and the node read as half-wired. Every tier-4 and tier-5 node with more than one
/// magnitude had the same hole.
///
/// ⚠️ THE THREE ACCESSORS NOW HAVE ONE RULE EACH, and the split is deliberate:
/// `Factor` an effect -> AMPLIFIED
/// `MagOf` an effect -> AMPLIFIED (this method)
/// `BoundOf` a LIMIT -> NEVER amplified. Boat Tail's 2.0 is a safety rail that
/// stops a mistuned factor making a gun hit harder at any distance;
/// scaling a safety cap by ten is the one thing it must not do.
/// Tier 4 read its secondary magnitudes through `BoundOf`, which is what hid this — a
/// cap accessor doing a magnitude's job inherits the cap's "do not amplify" rule.
/// </summary>
public static float AmpMag( float value, bool multiplier )
{
if ( Amplify == 1f ) return value;
// ⛔ A NON-MULTIPLIER MAG IS NOT AMPLIFIED, and this had to be settled because the
// two halves disagreed. My first version scaled it linearly; the report line below
// already said "(fixed — a count or a distance has no meaningful tenfold)". The
// display was right: every `Multiplier: false` mag in the catalogue today is a COUNT
// or a DURATION — a burst length of 10, a fabrication interval in seconds — and a
// hundred-round burst is not an exaggeration of a ten-round one, it is a different
// node. So the flag means "amplifiable" as well as "multiplicative", and the
// alternative was an instrument whose printout contradicted its own effect.
//
// ⚠️ A genuinely additive-and-amplifiable magnitude would need a third kind. There
// is none today; the primary `Factor` covers that case via `FactorKind.Flat`.
if ( !multiplier ) return value;
// ⚠️ Clamped for the reason Amped is: `pow` is right for subtle values and absurd
// for loud ones, and an overflowed multiplier reads in play as "the node does
// nothing" — the precise false negative this command exists to rule out.
return MathF.Pow( value, Amplify ).Clamp( 0.01f, 10f );
}
/// <summary>
/// The node's factor when owned, or <paramref name="ifAbsent"/> when not.
///
/// ⚠️ `ifAbsent` DEFAULTS TO 1, which is the neutral value for a multiply — the shape
/// almost every site wants: `x *= TechEffects.Factor( this, "t1_recoil" )`. A site
/// that ADDS rather than multiplies must pass 0 explicitly, because a silent 1 would
/// add one unit of something on every weapon that has not bought the node.
///
/// ⚠️ Amplification is applied HERE, so every one of the 41 nodes inherits it from
/// the single accessor they all share. Amplifying at the call sites would mean 41
/// places to remember.
/// </summary>
public static float Factor( Component weapon, string nodeId, float ifAbsent = 1f )
{
if ( !Has( weapon, nodeId ) ) return ifAbsent;
var node = WeaponTech.Find( nodeId );
if ( node is null ) return ifAbsent;
return Amped( node );
}
/// <summary>Factor for a known player+prefab pair. For the spawn-time path.</summary>
public static float Factor( NZPlayer player, string prefab, string nodeId, float ifAbsent = 1f )
{
if ( !Has( player, prefab, nodeId ) ) return ifAbsent;
var node = WeaponTech.Find( nodeId );
if ( node is null ) return ifAbsent;
return Amped( node );
}
/// <summary>
/// One of a node's NAMED extra magnitudes when this weapon owns it, or
/// <paramref name="ifAbsent"/> when it does not.
///
/// ⛔ THIS IS THE ONLY WAY A SECOND MAGNITUDE SHOULD REACH A CALL SITE, and the reason
/// is `Factor`'s own failure mode written down one level up: every tier-5 node spends
/// `Factor` on the number its NAME is about, so `Factor( "t5_railgun" )` at the fire-rate
/// site returns 1.5 and makes the slowest gun in the tier faster. A name cannot be
/// mistaken for the wrong number.
///
/// ⚠️ AMPLIFIED HERE, LIKE `Factor`, which is what closes the gap tier 4 reported: its
/// seven secondary magnitudes were `const`s in `NZPlayer.cs` and `nz_tech_amp` could
/// reach none of them, so amplifying Drum Magazine exaggerated its clip and left its
/// walk penalty at the real value. Anything read through here amplifies with everything
/// else — unless the `Mag` declares itself fixed, which counts and radii do.
///
/// ⚠️ `ifAbsent` DEFAULTS TO 1 for the same reason `Factor`'s does, and the same warning
/// applies twice over: an ADDITIVE site must pass 0, and it is also the value used when
/// the node is owned but the catalogue declares no such name — which warns, once, inside
/// <see cref="WeaponTech.MagOf"/>.
/// </summary>
public static float Mag( Component weapon, string nodeId, string name, float ifAbsent = 1f )
=> Has( weapon, nodeId ) ? AmpedMag( nodeId, name, ifAbsent ) : ifAbsent;
/// <summary>A named extra magnitude for a known player+prefab pair. Spawn-time path.</summary>
public static float Mag( NZPlayer player, string prefab, string nodeId, string name,
float ifAbsent = 1f )
=> Has( player, prefab, nodeId ) ? AmpedMag( nodeId, name, ifAbsent ) : ifAbsent;
static float AmpedMag( string nodeId, string name, float neutral )
{
var mag = WeaponTech.MagFor( nodeId, name );
// ⚠️ Through MagOf on the miss so the "no such magnitude" warning is printed in one
// place rather than two.
if ( mag is null ) return WeaponTech.MagOf( nodeId, name, neutral );
if ( Amplify == 1f || !mag.Multiplier ) return mag.Value;
// ⚠️ The same `pow` and the same clamp `Amped` uses on a node's Factor — see its
// comment for why a scaled deviation sends a factor below one negative.
return MathF.Pow( mag.Value, Amplify ).Clamp( 0.01f, 10f );
}
/// <summary>
/// How fast this weapon comes up to the eye — Quickdraw's bonus and Emplacement's
/// penalty, as one number. 1 is the authored rate; bigger is faster.
///
/// ⛔ IT IS A SHARED ACCESSOR BECAUSE THE ADS RATE HAS THREE CALL SITES AND TWO OF THEM
/// HAVE ALREADY BEEN MISSED ONCE. `ViewModelHandler` drives the pose rate and the
/// viewmodel FOV, `PlayerCameraHandler` drives the world zoom, and the comment above the
/// latter is a record of Quickdraw being wired in one file and forgotten in the other for
/// a build — it asks in writing for exactly this accessor if a second node ever appears.
/// Emplacement is that node, and at x0.25 a one-sided miss desyncs the gun pose from the
/// world zoom by FOUR times, where Quickdraw's 1.2 was barely visible.
///
/// ⚠️ `Has` + `Mag`, NEVER `Factor`, for Emplacement: its `Factor` is the x3 DAMAGE, so
/// `Factor` here would make the emplacement gun aim three times FASTER.
/// </summary>
public static float AdsSpeedFactor( Component weapon )
=> Factor( weapon, "t1_ads" ) * Mag( weapon, "t4_emplacement", "ads" )
// ⚠️ the per-class augments' aiming speed (2026-10-04): Scout x1.7, Quickscope x1.8, Anti-Materiel x0.8…
* TechStats.Mul( weapon, "s.ads" );
/// <summary>
/// `nz_tech_amp [n]` — exaggerate every node's effect n-fold. 1 restores normal.
///
/// ⚠️ PRINTS WHAT EACH NODE BECOMES, not just the setting, because the whole point
/// is to know what to look for before you fire. And it names the nodes it CANNOT
/// amplify rather than leaving them looking broken.
/// </summary>
[ConCmd( "nz_tech_amp" )]
public static void AmpCmd( float amount = -1f )
{
if ( amount > 0f ) Amplify = amount.Clamp( 1f, 20f );
if ( Amplify == 1f )
{
Log.Info( "[nz-tech-amp] normal — every node at its real magnitude. "
+ "`nz_tech_amp 10` to exaggerate." );
return;
}
Log.Info( $"[nz-tech-amp] AMPLIFY x{Amplify:0.##} — every multiplier raised to that "
+ "power. ⚠ this is a static and survives hotload; `nz_tech_amp 1` to stop." );
var skipped = new System.Collections.Generic.List<string>();
foreach ( var tier in WeaponTech.Tiers )
{
foreach ( var node in tier.Pool )
{
if ( CanAmplify( node ) )
{
// ⚠️ A flat node reads as "+4 -> +40", not "x4 -> x40". Printing a plus as
// a times is how someone concludes a magazine node is broken when it is
// working exactly as stated.
var sign = KindOf( node ) == FactorKind.Flat ? "+" : "x";
Log.Info( $"[nz-tech-amp] T{tier.Index} {node.Name,-18}"
+ $" {sign}{node.Factor:0.###} -> {sign}{Amped( node ):0.###}" );
}
else skipped.Add( node.Name );
// ⛔ THE EXTRA MAGNITUDES ARE LISTED TOO, AND THAT IS THE POINT OF `Mag`
// EXISTING. Tier 4 reported the gap from the other side: its secondary
// numbers were `const`s in NZPlayer.cs, so amplifying Drum Magazine
// exaggerated the magazine it prints here and left the walk penalty at its
// real value — which reads as "half the node is not wired".
//
// ⚠️ Printed even for a node whose own Factor cannot be amplified, because
// the two questions are independent: Chimera's Factor is a neutral 1 and
// Stabilizer's is a set-to-zero, but either could still carry a live second
// magnitude.
//
// ⚠️ AND A FIXED MAG PRINTS WITHOUT THE `x`, for the reason the flat-node
// sign above exists: "x10" beside a burst length reads as a tenfold burst
// rather than a burst of ten.
foreach ( var m in node.Extra ?? System.Array.Empty<WeaponTech.Mag>() )
Log.Info( $"[nz-tech-amp] T{tier.Index} {node.Name,-16}"
// ⚠️ CALLS AmpMag RATHER THAN REPEATING ITS FORMULA. This line
// inlined `pow(...).Clamp(0.01, 10)`, which is the §3 shape: the
// display and the effect computing the same thing separately, so a
// change to one silently stops matching the other. The printed
// number now cannot disagree with what the gun does.
+ (m.Multiplier
? $" {m.Name,-8} x{m.Value:0.###}"
+ $" -> x{AmpMag( m.Value, true ):0.###}"
: $" {m.Name,-8} {m.Value:0.###}"
+ " (fixed — a count or a distance has no meaningful tenfold)") );
}
}
if ( skipped.Count > 0 )
Log.Info( $"[nz-tech-amp] NOT amplified ({skipped.Count}) — floors, timers and "
+ $"set-to-zero nodes have no meaningful tenfold: {string.Join( ", ", skipped )}" );
}
// ── diagnostics ──────────────────────────────────────────────────────────
/// <summary>
/// `nz_tech_live` — what the held weapon's tech is ACTUALLY doing to it.
///
/// ⛔ READS THE GUN, NOT THE OWNED LIST. `nz_tech_owned` already prints what was
/// bought; that is DATA. This prints what the gun IS, which is the WORLD, and §13 is
/// the rule that changing one is not changing the other. A node can be owned, and
/// priced, and listed, and still be wired to nothing — which is exactly how Double
/// Tap, Vigor Rush and Deadshot survived for months while their stat panel agreed
/// with them.
///
/// ⚠️ IT PRINTS TWO LINES ON PURPOSE — `authored` (the prefab's stored fields) and
/// `effective` (what GetRealSpread and GetRealRPM actually return). Roughly half the
/// nodes never touch a stored field, so one line alone would report a working node as
/// dead. See the note at the second Log.Info for why that failure mode is the more
/// expensive one.
/// </summary>
[ConCmd( "nz_tech_live" )]
public static void Live()
{
var scene = Game.ActiveScene;
if ( scene is null )
{
Log.Info( "[nz-tech] no active scene — press Play first" );
return;
}
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Info( "[nz-tech] no player" ); return; }
var wep = Rarity.HeldBy( p );
if ( !wep.IsValid() ) { Log.Info( "[nz-tech] no weapon in hand" ); return; }
var prefab = Rarity.PrefabOf( wep );
var si = wep.Primary;
if ( si is null ) { Log.Warning( "[nz-tech] no Primary ShootInfo" ); return; }
Log.Info( $"[nz-tech-live] {wep.DisplayName}"
+ $" clip {si.ClipSize} rpm {si.RPM} dmg {si.Damage:0.#}"
+ $" pellets {si.Bullets}" );
// ⛔ THE COMPUTED VALUES, NOT ONLY THE AUTHORED FIELDS. Roughly half the nodes
// never touch a stored field at all — they scale at the point of READ, inside
// GetRealSpread or GetRealRPM or FinishRecoil. Printing `si.SpreadAddHipFire`
// alone would therefore show an UNCHANGED number on a weapon whose Point Shooting
// is working perfectly, and a reader would conclude the node was dead.
//
// That is the §2 trap running backwards: a measurement that reports failure on
// working code is as useless as one that reports success on broken code, and it is
// more expensive, because it sends someone to fix what is not broken. So both are
// printed side by side and labelled: `authored` is what the prefab says,
// `effective` is what the gun actually uses this frame.
Log.Info( $"[nz-tech-live] authored: hipspread {si.SpreadAddHipFire:0.###}"
+ $" recoilUp {si.RecoilUp:0.##}"
+ $" recovery {si.RecoilRecoveryTime:0.##}"
+ $" falloffMult {si.FalloffMultiplier:0.##}"
+ $" pen {si.PenetrationDepth:0.#}" );
// ⚠️ GetRealSpread and GetRealRPM are the SAME methods the bullets go through, so
// these two figures cannot disagree with what the gun does — which is the whole
// point. GetRealSpread returns 0 with no valid Owner, so a machine holding the
// weapon will read zero rather than lying.
Log.Info( $"[nz-tech-live] effective: spread {wep.GetRealSpread( si.Spread ):0.####}"
+ $" shot interval {wep.GetRealRPM( si.RPM ):0.####}s"
+ $" ({(wep.GetRealRPM( si.RPM ) > 0f ? 60f / wep.GetRealRPM( si.RPM ) : 0f):0} rpm)" );
// ⛔ THE FIRE-MODE ROW, AND IT IS NOT POLISH. FIVE nodes write `FiringType` and a
// sixth draws it — Micro-Burst, Bolt Gun, Overclocked, Ricochet Rounds, Ten-Round
// Burst and Chimera — and before this line NOTHING in the project could observe any
// of them: neither this command nor `WeaponStatsPanel` printed `FiringType`, let
// alone `EffectiveFiringType`. A read-time enum leaves no trace in either row above,
// so all six were unfalsifiable by every instrument we own.
//
// ⚠️ AUTHORED AND EFFECTIVE SIDE BY SIDE, the same reason the two rows above are:
// Micro-Burst converts a weapon at read time and leaves the authored field alone, so
// printing one of them would report a working node as dead. `burst(N)` because the
// length is a node's magnitude too — 3 authored, 2 for Micro-Burst, 10 for Ten-Round
// — and a burst of the wrong length looks exactly like a burst.
var mode = wep.EffectiveFiringType( si );
Log.Info( $"[nz-tech-live] fire mode: authored {si.FiringType}"
+ $" effective {mode}"
+ (mode == SWB.Base.FiringType.burst
? $"({wep.BurstRoundsFor( si )}) ramp x{wep.BurstDamageRamp( si ):0.###}"
: "") );
// ⛔ READS `NZAmmo`, NOT `NZWeapon`. This line printed
// `NZWeapon.ReserveAmmo` and would therefore have shown BLANK on every real
// weapon — NZWeapon is the legacy placeholder gun and is on none of the 31
// prefabs. A diagnostic printing an empty field is worse than one printing
// nothing at all: it reads as "the value is zero", which is a wrong answer
// rather than a missing one.
//
// ⚠️ `EverythingInSelf` because a HOLSTERED weapon is a DISABLED component and
// the default Get skips those — the trap NZPlayer records having cost a bug three
// separate times.
var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
Log.Info( $"[nz-tech-live] reload {wep.ReloadTime:0.##}s"
+ $" draw {wep.DrawTime:0.##}s"
+ $" reserve {(ammo.IsValid() ? $"{ammo.Reserve}/{ammo.MaxReserve}" : "no NZAmmo")}"
+ $" adsStrafe {p.AdsSpeedMultiplier:0.##}" );
// ⛔ THE DAMAGE CURVE, MEASURED AT REAL DISTANCES. "Damage still decreases with
// range" cannot be diagnosed from `FalloffMultiplier` alone, because THREE fields
// decide the curve and two of them are distances: below `FalloffStart` the
// multiplier is not applied at all, and it only reaches its full value at
// `FalloffEnd`. A multiplier of 1.25 sitting behind a start of 1755 units is a
// bonus that exists and can never be observed.
//
// ⚠️ CALLS THE REAL `DamageFor`, the same method both bullet paths use, so these
// figures cannot disagree with what a shot actually deals. Passing null hitTags
// deliberately excludes the head and limb multipliers — this row is about RANGE.
//
// ⚠️ 1 unit = 1 inch, so the metre labels are the distances a player can judge.
// A rising row means Boat Tail is working; a falling row means falloff is intact.
Log.Info( $"[nz-tech-live] falloff band: start {si.FalloffStart:0}u"
+ $" ({si.FalloffStart * 0.0254f:0.#}m) end {si.FalloffEnd:0}u"
+ $" ({si.FalloffEnd * 0.0254f:0.#}m) mult x{si.FalloffMultiplier:0.###}" );
var probe = new[] { 0f, 5f, 10f, 20f, 40f, 80f };
var curve = new System.Collections.Generic.List<string>();
foreach ( var metres in probe )
{
var units = metres / 0.0254f;
curve.Add( $"{metres:0}m {si.DamageFor( units, null ):0.#}" );
}
Log.Info( $"[nz-tech-live] damage by range: {string.Join( " ", curve )}" );
// ⚠️ WeaponTuning.Apply runs on DEPLOY, i.e. AFTER ApplyStoredUpgrades has
// stamped the tech on — so a saved override on any of these three fields silently
// wins over the node. Printing the override list here is cheaper than discovering
// that from behaviour.
var over = WeaponTuning.Overrides( wep );
var clash = new System.Collections.Generic.List<string>();
foreach ( var kv in over )
// ⛔ `Damage` AND `Bullets` WERE MISSING FROM THIS LIST, and they are the two
// fields tier 4 writes most. Six tier-4 nodes scale Damage and two rewrite
// Bullets; both are `[Property]` numerics on ShootInfo and therefore valid
// `nz_wep_set` targets, and `WeaponTuning.Apply` runs from Weapon.OnEnabled on
// EVERY deploy — after ApplyStoredUpgrades. So a saved Damage override silently
// reverted Tuned Action, All-Rounder, Scattergun and Slug Loader on the next
// weapon switch, and a saved Bullets override reverted Scattergun's pellets and
// Slug Loader's single slug, with NO warning from the one diagnostic built to
// catch exactly this.
//
// ⛔ AND `Reload` WAS MISSING FROM BOTH THE LIST AND THE ENUMERATION ABOVE IT,
// while `ReloadTech` has always written FIVE reload fields — ReloadTime,
// ReloadEmptyTime and the three ShellReload times. Last Resort's x5 and Drum
// Magazine's x2 were therefore silently revertible by a saved override, on
// exactly the shell-reloading shotguns those nodes are aimed at, with no warning
// from the diagnostic built to catch it. Tier 5 adds a sixth reader (Chimera's
// reload axis), which is what made the omission worth finding.
//
// ⚠️ Enumerated rather than guessed: the fields tech writes are Damage, RPM,
// ClipSize, Bullets, FalloffStart, FalloffEnd, FalloffMultiplier,
// PenetrationDepth, Penetration, RecoilRecoveryTime, RecoilUp, SpreadAddHipFire
// and the five Reload/ShellReload durations. Every one is matched by a substring
// below.
if ( kv.Key.Contains( "Falloff" ) || kv.Key.Contains( "Clip" )
|| kv.Key.Contains( "RPM" ) || kv.Key.Contains( "Penetration" )
|| kv.Key.Contains( "Recoil" ) || kv.Key.Contains( "Spread" )
|| kv.Key.Contains( "Damage" ) || kv.Key.Contains( "Bullets" )
|| kv.Key.Contains( "Reload" ) )
clash.Add( $"{kv.Key}={kv.Value:0.###}" );
if ( clash.Count > 0 )
Log.Warning( $"[nz-tech-live] ⛔ WeaponTuning OVERRIDES a field tech writes, and "
+ $"it runs AFTER on every deploy: {string.Join( ", ", clash )}"
+ " — `nz_wep_reset` clears them" );
var owned = p.TechFor( prefab );
// ⛔ THE ROLL ROW IS THE ONLY FALSIFIER CHIMERA HAS, WHICH IS WHY IT IS HERE AND NOT
// IN THE UI BACKLOG. The node's median roll is deliberately WORSE than the gun it
// replaces, so "did the substitution happen" cannot be answered by feel: a weapon
// that got worse is the expected outcome, and so is a weapon that changed nothing on
// the axes that drew their own values back. Printing the drawn numbers beside the
// `authored` row above turns that into a comparison anyone can make in one line.
//
// ⚠️ `mode` is stored as the ENUM'S INT in a float store — see NZPlayer's roll
// dictionary for why one store rather than two — so it is printed back as the enum.
if ( p.ChimeraRolls.TryGetValue( prefab, out var roll ) )
Log.Info( "[nz-tech-live] chimera roll: " + string.Join( " ", roll
.Where( kv => kv.Key != NZPlayer.ChimeraRolled )
.Select( kv => kv.Key == NZPlayer.ChimeraMode
? $"mode {(SWB.Base.FiringType)(int)kv.Value}"
: $"{kv.Key} {kv.Value:0.###}" ) ) );
else if ( owned.Contains( "t5_chimera" ) )
// ⛔ OWNED WITH NO ROLL IS A REAL FAILURE STATE, not a missing row: the roll
// happens in AddTech and nowhere else, so this means the node was recorded by
// some path that bypassed it — or the pool returned nothing to draw from.
Log.Warning( "[nz-tech-live] ⛔ Chimera is OWNED and NO ROLL IS STORED — the"
+ " weapon is running its authored stats. The roll happens in NZPlayer.AddTech;"
+ " something recorded the node without going through it." );
if ( owned.Count == 0 )
{
Log.Info( "[nz-tech-live] no tech owned on this weapon" );
return;
}
foreach ( var id in owned )
{
var node = WeaponTech.Find( id );
if ( node is null ) continue;
Log.Info( $"[nz-tech-live] owned: {node.Name,-18} x{node.Factor:0.###}"
+ $" -> {node.Lever}" );
}
Log.Info( "[nz-tech-live] ⚠ owning a node is not proof it is wired. Read-time "
+ "nodes move the `effective` line and leave `authored` alone; spawn-time nodes "
+ "(clip, reserve) move `authored`. A node that moves NEITHER is not wired." );
}
}