Static class defining Double Tap perk augments for players. It exposes configurable tuning values, helper queries (fire rate, shot multiplier, twin spread, recoil, piercing and overpressure behavior), weapon-facing wrappers, console commands for diagnostics and live tweaking, and an Overpressure roll that charges extra ammo and doubles damage sometimes.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Double Tap's augments. Base perk: fire rate ×1.2.
///
/// All nine are wired. **m5 Overpressure Round** was the last — a 10% chance to fire a ×2 round
/// costing 2 from the magazine. It is a damage-and-ammo mechanic and shares nothing with the pierce
/// pair it used to be grouped with in this file's report, which is why it outlived them.
///
/// ⚠️ m2 AND m3 DO NOT LIVE HERE, THEY LIVE IN `NZPlayer.ApplyTech`. Both write `ShootInfo` fields
/// that the tech applier ASSIGNS on every deploy, so this file resolves the values and that one
/// expression applies them. See `PierceDepthBonus` for why splitting them would not work.
///
/// ⛔ M1 IS A REAL SECOND PROJECTILE HERE, WHERE THE ORIGINAL GAVE UP ON THAT. Its own
/// comment records the retreat: *"hitscan Double Fire no longer doubles the pellet COUNT.
/// Two hitscan pellets fired down the (near-)identical line frequently resolve as a SINGLE
/// hit, so the damage 'double' only landed sometimes."* It fell back to a flat ×2 damage on
/// hitscan and kept true doubling only for weapons that spawn a projectile entity.
///
/// ⚠️ THAT PROBLEM DOES NOT EXIST IN THIS ENGINE, and the reason is one line in ZombieAI:
/// `_hp.ImmunityAfterHit = 0f; // zombies get no mercy window`. Two traces landing on one
/// zombie in one frame each run `Health.Apply` in full. In GMod the second hit was being
/// swallowed; here it is not. So the honest implementation — fire twice — is available, and
/// a ×2 damage stand-in would be a worse version of it for no reason.
///
/// ⚠️ IT ALSO NEEDS NO PER-BULLET-TYPE BRANCH. `HitScanBulletInfo` and
/// `PhysicalBulletInfo` are both reached through one `BulletType.Shoot( weapon, isPrimary,
/// spreadOffset )` call inside one loop, so doubling the loop count doubles hitscan traces
/// AND spawns a genuine second travelling projectile with its own tracer. The original
/// needed two separate primitives (`EnableDoubleProjectile` for entities, a damage
/// multiplier for hitscan) precisely because it had no such single seam.
/// </summary>
public static class DtapAugments
{
const string Perk = "dtap";
// ── tuning ───────────────────────────────────────────────────────────────
/// <summary>
/// M1 Double Fire — how many times the shot's bullets are multiplied.
///
/// ⚠️ A MULTIPLIER ON THE PELLET COUNT, so it doubles a shotgun's 8 pellets to 16 as
/// readily as a rifle's 1 to 2. The original's description says "the
/// projectiles/pellets fired, on ANY weapon", which is a multiply and not an add.
/// </summary>
public static int DoubleFireMultiplier { get; set; } = 2;
/// <summary>
/// Extra angular spread applied ONLY to the added bullets, in the same units as
/// `ShootInfo.Spread`.
///
/// ⛔ ADDED TO THE COPIES, NEVER TO THE ORIGINALS, and that split is the whole point.
/// An augment must not make your aimed shot less accurate — so bullet 0 of a rifle
/// still goes exactly where the weapon's own spread would have put it, and only the
/// twin fans out. Applying the fan to both would turn a damage augment into an
/// accuracy penalty.
///
/// ⚠️ AND IT MUST NOT BE ZERO. While aiming, `GetRealSpread` on most of these prefabs
/// returns something very near zero — the guns author `Spread` 0.0 and get their real
/// inaccuracy from `SpreadAddHipFire`, which is not added at all when aimed. Two
/// perfectly collinear projectiles are invisible as two, and on a physical-bullet
/// weapon the second tracer would be hidden inside the first.
///
/// ⚠️ 0.012 IS A GUESS AND HAS A COMMAND FOR EXACTLY THAT REASON. It cannot be
/// derived from anything — `Spread` is a unitless multiplier on a random vector — and
/// this project has been bitten three times by shipping an underivable number with no
/// way to tune it live (ParticleEffect.Scale, LineRenderer.Width, the cherry burst).
/// `nz_aug_dtap_set` exists from the first commit.
/// </summary>
public static float TwinSpread { get; set; } = 0.012f;
/// <summary>M2 Rapid Fire — fire-rate multiplier. +30%.</summary>
public static float RapidFireRate { get; set; } = 1.30f;
/// <summary>
/// M3 Trigger Discipline — the DAMAGE multiplier at a full charge.
///
/// ⛔ THIS REPLACED "REV UP", A FIRE-RATE RAMP TIED TO MAGAZINE EMPTINESS. Three things
/// were wrong with that and only one was a number: it was invisible while it happened,
/// it peaked immediately before you were forced to reload, and it sat on M2 Rapid Fire's
/// axis — which is the only reason those two ever needed a `MathF.Max` tie-break.
///
/// ⛔ AND IT REPLACED A FIRST ATTEMPT THAT RAMPED WHILE THE TRIGGER WAS *HELD*. That
/// version rewarded holding fire, which is the opposite of what a thing called Trigger
/// Discipline should reward, and it made the augment strongest exactly when you were
/// already spraying. This one builds while you are NOT shooting and drains while you
/// are, so the payoff belongs to restraint and target selection.
///
/// ⚠️ DAMAGE, NOT RATE. Damage numbers already render in the world, so the charge is
/// legible where it is spent — and it keeps M3 off M2's axis so the two stack honestly.
/// </summary>
public static float TriggerDisciplineMax { get; set; } = 5f;
/// <summary>
/// M3 Trigger Discipline — seconds of NOT shooting to charge from empty to full.
///
/// ⚠️ THE CHARGE IS NORMALISED 0..1 AND THESE TWO DURATIONS DRIVE IT DIRECTLY, so
/// changing <see cref="TriggerDisciplineMax"/> does not change how long anything takes.
/// The alternative — rates expressed in multiplier units per second — would couple the
/// three values so that retuning the ceiling silently retuned the timings.
/// </summary>
public static float TriggerBuildSeconds { get; set; } = 10f;
/// <summary>
/// M3 Trigger Discipline — seconds of continuous fire to drain from full back to 1×.
///
/// ⚠️ THE 5:1 RATIO AGAINST <see cref="TriggerBuildSeconds"/> IS THE MECHANIC — ten
/// seconds to earn it, two to spend it — and it is a COINCIDENCE that the ceiling is
/// also 5×. Do not "simplify" the three numbers into two; they are independent and the
/// ratio is meant to be tunable without moving the ceiling.
/// </summary>
public static float TriggerDrainSeconds { get; set; } = 2f;
/// <summary>m1 Rapid Rounds — fire-rate multiplier. +12% (`RPMMult = 1.12`).</summary>
public static float RapidRoundsRate { get; set; } = 1.12f;
/// <summary>
/// m4 Steady Barrel — recoil multiplier. 0.5 = half.
///
/// ⛔ IT TOOK OVER DEADSHOT'S HALVING. Owning Deadshot Daiquiri used to halve recoil on
/// every weapon before any augment was picked, which made it the largest recoil reduction in
/// the game and made it free. The perk now keeps only spread and ADS time; this augment is
/// where "half the recoil" lives, and it has to be chosen.
///
/// ⚠️ STILL RECOIL ONLY, deliberately kept out of `PerkEffects.HandlingMultiplier` — that
/// one is read by `GetRealSpread` as well, so folding this in would hand the player accuracy
/// the augment does not advertise.
/// </summary>
public static float SteadyBarrelRecoil { get; set; } = 0.5f;
// ── the pierce pair (m2, m3) ─────────────────────────────────────────────
//
// ⛔ NULLABLE-BACKED GETTERS FOR THESE TWO, unlike the auto-properties above. A static's
// VALUE survives a hotload but its initialiser does not re-run (INSTRUCTIONS.md §1). The
// older knobs in this file predate that rule and are left alone rather than churned.
static float? _pierceBodies;
/// <summary>
/// m2 Overpenetration — how many extra zombies a bullet crosses. 2, by request.
///
/// ⛔ IT WAS 8 AND IT NEVER DELIVERED 8 — IT DELIVERED THE CEILING, ALWAYS. The bonus is
/// `PierceBodies * BodyDepth`, and BodyDepth was 11 on the belief that the budget charged a
/// torso's thickness per body. It charges 1. So the augment added 88 to a budget where one
/// unit is one zombie, and every weapon that bought it jumped straight to
/// `MaxPenetrations = 10` no matter what it started at. A shotgun and a sniper came out
/// identical, which is the opposite of what a +N augment is for.
///
/// ⚠️ SO 2 IS A REAL +2 NOW, AND IT IS THE FIRST TIME THIS AUGMENT HAS HAD A SLOPE. With
/// the class averages stamped at 2-10 zombies, a shotgun goes 2 -> 4 and a battle rifle
/// 7 -> 9, so the augment is worth buying on everything and decisive on nothing.
///
/// ⚠️ `MaxPenetrations = 10` STILL CAPS IT, and now that actually bites in the right
/// place: snipers sit at 10 already, so m2 is worth nothing on them and the player can see
/// why. That is a real choice rather than a hidden no-op.
///
/// ⚠️ AND THE CEILING COUNTS WORLD GEOMETRY TOO — a bullet that clipped a crate on the way
/// in has fewer bodies left, so the delivered count is a maximum rather than a guarantee.
/// </summary>
public static float PierceBodies { get => _pierceBodies ?? 2f; set => _pierceBodies = value; }
static float? _bodyDepth;
/// <summary>
/// What one zombie costs from the penetration budget. 1 — so `PenetrationDepth` IS a body
/// count, and `ceil(depth)` is how many zombies a bullet crosses.
///
/// ⛔ IT WAS 11 AND THE 11 WAS DERIVED FROM A MISREADING. The reasoning was: the catalogue
/// says depth is "9.97 on 20 weapons (about ten inches, roughly one body)", the spend line
/// adds `+ 1.0f`, therefore ten for the torso plus one is eleven. Every step is sound except
/// the premise — the ten-inch term is `EndPosition.Distance( HitPosition )`, which for a
/// trace that hits is the swept shape's ORIGIN to its contact point, i.e. approximately the
/// TRACE RADIUS. That radius ships at 0, so the term was always zero and the real charge was
/// always the surcharge alone. The 20 weapons at 9.97 were not crossing one body; they were
/// crossing ten, which is the engine ceiling.
///
/// ⛔ AND THE STATS CARD WAS PRINTING THE CONSEQUENCE. `PenDepth` divided by this, so those
/// 20 weapons read "Under 1 zombie" while delivering the maximum the engine allows — a stat
/// that was not merely imprecise but inverted.
///
/// ⚠️ IT IS NOW EXACT RATHER THAN AN AVERAGE. The old note apologised that "a torso and a
/// forearm do not cost the same"; with a flat per-body charge they do, and the promise a
/// weapon makes is the number it delivers.
///
/// ⚠️ THREE READERS, ONE AUTHOR: the bullet loop's spend, `PierceDepthBonus`'s
/// bodies-to-budget conversion, and `WeaponStatsPanel.PenDepth`'s conversion back. Changing
/// this changes all three together, which is the point of it being a field.
/// </summary>
public static float BodyDepth { get => _bodyDepth ?? 1f; set => _bodyDepth = value; }
static float? _armorPiercingKeep;
/// <summary>
/// m3 Armor Piercing — the share of damage a bullet keeps per body crossed. 0.92.
///
/// ⚠️ IT REPLACES AN AUTHORED 0.75, WHICH IS THE SAME ON ALL 31 PREFABS. The catalogue
/// text is "penetrating shots keep more damage", so this is the field it means:
/// `HitScanBulletInfo` multiplies the running damage by
/// `shootInfo.PenetrationDamageMult.Clamp( 0f, 1f )` once per body.
///
/// ⛔ IT COMPOUNDS, SO SMALL CHANGES MATTER MORE THAN THEY LOOK. Across three bodies
/// 0.75 keeps 42% and 0.92 keeps 78% — the augment nearly doubles the damage of the third
/// zombie in a line, which is exactly the case it is sold for.
///
/// ⛔ AND THE ENGINE CLAMPS IT AT 1, so there is no point setting this above 1.0 and no
/// way to make pierced shots gain damage. 0.92 rather than 1.0 deliberately keeps a
/// penalty in place: a bullet that loses nothing through flesh makes the base weapon's
/// authored 0.75 meaningless rather than improved.
/// </summary>
public static float ArmorPiercingKeep
{
get => _armorPiercingKeep ?? 0.92f;
set => _armorPiercingKeep = value;
}
/// <summary>
/// m2 — extra penetration depth in world units, or 0 when the augment is not held.
///
/// ⛔ CONSUMED BY `NZPlayer`'s TECH APPLIER, NOT WRITTEN FROM HERE, AND THAT IS DELIBERATE.
/// `ApplyTech` assigns `PenetrationDepth` outright on EVERY deploy — `basePen * penFactor`
/// — so an augment that wrote the field itself would be silently reverted by the next
/// weapon switch. That applier's own comment already warns that "two nodes writing the same
/// two fields is two chances for one to undo the other". One author, one expression.
/// </summary>
public static float PierceDepthBonus( NZPlayer p )
=> Has( p, "m2" ) ? MathF.Max( 0f, PierceBodies ) * MathF.Max( 0f, BodyDepth ) : 0f;
/// <summary>
/// m3 — the damage kept per body crossed, or 0 when the augment is not held.
///
/// ⚠️ 0 MEANS "NOT HELD, LEAVE THE AUTHORED VALUE ALONE" rather than "keep no damage".
/// Zero is not a sensible setting for this field — it would make any pierced hit deal
/// nothing — so it is free to be the sentinel, and the applier tests `> 0f`.
/// </summary>
public static float PierceDamageKeep( NZPlayer p )
=> Has( p, "m3" ) ? MathF.Max( 0f, ArmorPiercingKeep ) : 0f;
// ── helpers ──────────────────────────────────────────────────────────────
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
/// <summary>
/// The combined fire-rate multiplier from every rate augment. 1 when none apply.
///
/// ⛔ M2 AND M3 DO NOT STACK WITH EACH OTHER — `MathF.Max`, not a product. Both scale
/// the same axis, and normal play cannot hold two majors so the original never had to
/// say what happens; our Creative override lifts that limit, which is exactly where
/// fire rate gets tested. Multiplying them would give +95% — a number no real player
/// can reach, so the test bench would disagree with the game for reasons nobody could
/// see. This is the same argument, and the same resolution shape, as
/// `EffectiveFiringType`'s documented tie-break for four tier-5 mode nodes.
///
/// ⛔ MAX RATHER THAN A PRIORITY ORDER, deliberately. A priority would depend on
/// purchase order — `Loadout.Majors` is a list in the order you bought them — which is
/// invisible state deciding a number. `Max` is order-independent.
///
/// ⚠️ m1 IS A MINOR AND DOES STACK, multiplicatively, on top of whichever major won.
/// A major and a minor spent on one axis is a legitimate build; two majors is not a
/// state the game can produce.
///
/// ⚠️ M1 Double Fire is NOT here. It is on the projectile-count axis, so it neither
/// competes with nor stacks against these — it multiplies shots, not rate.
/// </summary>
public static float FireRateMultiplier( NZPlayer player, SWB.Base.Weapon weapon )
{
if ( !player.IsValid() || !player.HasPerk( Perk ) ) return 1f;
// ⚠️ M3 IS NO LONGER ON THIS AXIS. Trigger Discipline replaced Rev Up and scales
// DAMAGE, so the `MathF.Max` that used to resolve M2 against M3 is gone — there is
// nothing left to resolve. That collision was the main argument against Rev Up.
var major = Has( player, "M2" ) ? RapidFireRate : 1f;
var minor = Has( player, "m1" ) ? RapidRoundsRate : 1f;
return major * minor;
}
/// <summary>
/// M3 Trigger Discipline's damage multiplier for a given charge. 1 when not equipped.
///
/// ⛔ TAKES THE CHARGE RATHER THAN READING IT, because only the weapon knows it. A
/// player-side timer could not tell "not shooting" from "holding a trigger on an empty
/// gun", and under Mule Kick it could not tell which of three weapons was firing.
///
/// ⚠️ LINEAR, NOT EASED. The player has to feel where they are on the charge from the
/// stats row and the damage numbers alone, and a curve makes "half charged" mean
/// something other than half the bonus.
/// </summary>
public static float TriggerDamage( NZPlayer player, float charge )
{
if ( !Has( player, "M3" ) ) return 1f;
var t = MathX.Clamp( charge, 0f, 1f );
return MathX.Lerp( 1f, System.MathF.Max( 1f, TriggerDisciplineMax ), t );
}
/// <summary>
/// Advance a weapon's charge by one frame. Returns the new normalised charge.
///
/// ⛔ THE RATES LIVE HERE, NOT ON THE WEAPON, so both durations are tunable from one
/// console command and the weapon holds nothing but the number. The weapon would
/// otherwise need its own copies of two statics — §3, and the copy that got the fix
/// would not be the one that ran.
///
/// ⚠️ IT ADVANCES WHETHER OR NOT M3 IS EQUIPPED, deliberately. Gating the tick on
/// ownership means buying the augment mid-fight hands you an empty charge and a ten
/// second wait, and the augment reads as broken for exactly as long as it takes to give
/// up on it. Tracking a float nobody reads costs nothing.
///
/// ⚠️ DRAINS ON `IsShooting`, WHICH IS RATE-AWARE. That accessor is "a shot landed
/// within one shot-interval", so a 600 RPM weapon held down drains continuously and a
/// bolt-action counts as firing for its whole long interval. Draining on the trigger
/// BEING HELD instead would drain on a dry gun and would never drain on a semi-auto,
/// where holding the trigger fires nothing.
/// </summary>
public static float AdvanceCharge( float charge, bool shooting, float dt )
{
var build = System.MathF.Max( 0.01f, TriggerBuildSeconds );
var drain = System.MathF.Max( 0.01f, TriggerDrainSeconds );
charge += shooting
? -dt / drain
: dt / build;
return MathX.Clamp( charge, 0f, 1f );
}
// ⛔ `Emptiness` WAS HERE AND IS DELETED, NOT KEPT "IN CASE". It existed only for
// Rev Up's magazine ramp, and Trigger Discipline does not read the magazine at all — a
// helper nobody calls is the §12 shape that produced a dead `BonusMaxHealth` in
// PerkEffects and a dead `DeathSequences` on the hound. If a later augment wants
// magazine emptiness it can be written then, against whatever that augment actually
// needs.
/// <summary>
/// M4 Full Auto — does this player convert semi-auto weapons to automatic.
///
/// ⛔ ANSWERED HERE AND CONSUMED IN `EffectiveFiringType`, which is the switchboard
/// every fire-mode reader already goes through — including the stats panel's Fire mode
/// row, which asks that method precisely so a copy of the precedence cannot drift. So
/// this augment shows up on the card for free.
///
/// ⚠️ It only has anything to do on a weapon the prefab authored as `semi`. Sixteen of
/// the 31 are already `auto`, so on those M4 is a genuine no-op and the panel's
/// "(was Semi)" qualifier is the only thing that can say which.
/// </summary>
public static bool FullAuto( NZPlayer player ) => Has( player, "M4" );
/// <summary>
/// m4 Steady Barrel's recoil multiplier. 1 when not equipped.
///
/// ⚠️ BELOW 1 MEANS LESS RECOIL, matching the original's `RecoilMult = 0.65`
/// convention rather than inverting it into a "reduction" figure that would then have
/// to be subtracted somewhere.
/// </summary>
public static float RecoilMultiplier( NZPlayer player )
=> Has( player, "m4" ) ? SteadyBarrelRecoil : 1f;
/// <summary>
/// How many times to run a shot's bullet loop. 1 when Double Fire is not held.
///
/// ⛔ RETURNS A LOOP COUNT RATHER THAN WRITING `shootInfo.Bullets`, and writing it
/// would have been the obvious mistake. Two separate things read that field and both
/// would break:
/// • `GetRealSpread` does `if ( IsAiming && Primary.Bullets == 1 )` before
/// applying the ADS spread bonus — so setting it to 2 would silently switch the
/// aim bonus OFF on every single-pellet weapon in the game;
/// • `WeaponTuning.Apply` runs on every deploy and reverts saved overrides of
/// `Bullets`, which TechEffects already documents having been bitten by twice
/// (Scattergun's pellets, Slug Loader's single slug).
///
/// A local count touches neither.
/// </summary>
public static int ShotMultiplier( NZPlayer player )
=> Has( player, "M1" ) ? System.Math.Max( 1, DoubleFireMultiplier ) : 1;
/// <summary>
/// Extra spread for a duplicated bullet. 0 for the weapon's own bullets.
///
/// ⚠️ `pass` IS THE COPY INDEX — 0 is the real shot, 1+ are the twins. Passing the
/// bullet index instead would fan out a shotgun's later pellets and leave the first
/// twin perfectly collinear, which is backwards.
/// </summary>
public static float TwinSpreadFor( int pass )
=> pass <= 0 ? 0f : TwinSpread;
// ── weapon-side entry points ─────────────────────────────────────────────
//
// ⚠️ THESE TAKE THE WEAPON AND FIND THE OWNER, mirroring `PerkEffects.*For` exactly.
// Its `OwnerOf` note records that a hand-written copy of the lookup had already
// appeared four times before it was centralised, so these go through it rather than
// adding a fifth.
/// <summary>Fire rate multiplier from a weapon. Folded into GetRealRPM's chain.</summary>
public static float FireRateMultiplierFor( SWB.Base.Weapon weapon )
=> weapon.IsValid()
? FireRateMultiplier(
weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ),
weapon )
: 1f;
/// <summary>
/// Trigger Discipline's damage multiplier from a weapon.
///
/// ⚠️ THE WEAPON SUPPLIES THE HOLD TIME, so a player carrying two guns under Mule Kick
/// charges each independently and swapping does not inherit the other's ramp.
/// </summary>
// ── m5 OVERPRESSURE ROUND ─────────────────────────────────────────
/// <summary>m5 — how often a shot comes out overpressured. 10%.</summary>
public static float OverpressureChance { get => _opChance ?? 0.10f; set => _opChance = value; }
static float? _opChance;
/// <summary>m5 — what an overpressured round hits for. ×2.</summary>
public static float OverpressureDamage { get => _opDamage ?? 2f; set => _opDamage = value; }
static float? _opDamage;
/// <summary>m5 — rounds the magazine pays for one. 2.</summary>
public static int OverpressureCost { get => _opCost ?? 2; set => _opCost = value; }
static int? _opCost;
/// <summary>How many overpressured rounds have gone out, for `nz_aug_dtap`.</summary>
public static int OverpressureFired;
/// <summary>
/// Does THIS trigger pull come out overpressured.
///
/// ⛔ ROLLED BEFORE THE AMMO IS TAKEN, because it changes what the shot costs. `Weapon.Shoot`
/// asks once, at the top, and carries the answer down to the damage seam — the same "read once
/// per trigger pull, never per bullet" rule the burst and charged-trigger factors already
/// follow. A shotgun's eight pellets are ONE shot and must all be the same round.
///
/// ⛔ IT WILL NOT FIRE A ROUND THE MAGAZINE CANNOT PAY FOR. "Costs 2 from the magazine" with
/// one round left would either go negative or silently cost 1, and a ×2 round for the price of
/// one is the augment paying the player. So the roll simply does not happen on the last round —
/// which also means the last shot in a magazine is never the big one, and that is a fair
/// reading of a cartridge that needs a double charge behind it.
///
/// ⚠️ INFINITE-CLIP WEAPONS STILL ROLL. There is no magazine to empty, so there is nothing
/// to refuse; the wonder weapons that use that flag are not balanced against this anyway.
/// </summary>
public static bool RollOverpressure( SWB.Base.Weapon weapon, SWB.Base.ShootInfo shootInfo )
{
if ( !weapon.IsValid() || shootInfo is null ) return false;
var player = weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
if ( !Has( player, "m5" ) ) return false;
// ⚠️ THE WHOLE COST, NOT JUST THIS AUGMENT'S SHARE. The shot already pays `AmmoPerShot`
// (ARC9 weapons author more than 1), and the extra round is on top of that — checking only
// `OverpressureCost` would let a 2-round-per-shot weapon fire its last two and go to -1.
var free = shootInfo.InfiniteAmmo == SWB.Base.InfiniteAmmoType.clip;
var cost = Math.Max( 1, shootInfo.AmmoPerShot ) + OverpressureExtraAmmo();
if ( !free && shootInfo.Ammo < cost ) return false;
if ( Game.Random.Float() > MathF.Max( 0f, OverpressureChance ) ) return false;
OverpressureFired++;
return true;
}
/// <summary>The extra rounds an overpressured shot takes, beyond the one it already pays.</summary>
public static int OverpressureExtraAmmo() => Math.Max( 0, OverpressureCost - 1 );
/// <summary>
/// Force the next shots overpressured: `nz_aug_dtap_overpressure [chance]`.
///
/// ⚠️ A CHANCE, NOT A SWITCH, so the real thing is what gets tested. Called bare it flips
/// between certain and the authored 10%.
/// </summary>
[ConCmd( "nz_aug_dtap_overpressure" )]
public static void OverpressureCmd( float chance = -1f )
{
if ( chance < 0f ) OverpressureChance = OverpressureChance >= 0.999f ? 0.10f : 1f;
else OverpressureChance = chance;
Log.Info( $"[nz] dtap m5 overpressure: {OverpressureChance * 100f:0.#}% chance"
+ $" ×{OverpressureDamage:0.##} for {OverpressureCost} round(s)"
+ $" · {OverpressureFired} fired so far" );
}
public static float TriggerDamageFor( SWB.Base.Weapon weapon )
=> weapon.IsValid()
? TriggerDamage(
weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ),
weapon.TriggerCharge )
: 1f;
/// <summary>m4's recoil multiplier from a weapon. Read by FinishRecoil.</summary>
public static float RecoilMultiplierFor( Component weapon )
=> weapon.IsValid()
? RecoilMultiplier(
weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ) )
: 1f;
/// <summary>Does this weapon's holder convert semi to auto. Read by EffectiveFiringType.</summary>
public static bool FullAutoFor( Component weapon )
=> weapon.IsValid()
&& FullAuto(
weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ) );
/// <summary>Shot multiplier from a weapon. Read by the stats panel's damage row.</summary>
public static int ShotMultiplierFor( Component weapon )
=> weapon.IsValid()
? ShotMultiplier(
weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ) )
: 1;
// ── diagnostics ──────────────────────────────────────────────────────────
public static void Report( NZPlayer player )
{
if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var has = player.HasPerk( Perk );
var equipped = PerkAugments.EquippedOn( player, Perk );
Log.Info( $"[nz-aug] DOUBLE TAP {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]"
+ $" · base fire rate x{PerkEffects.DoubleTapFireRate:0.##}" );
// ⚠️ REPORTS THE RESOLVED PELLET COUNT ON THE HELD WEAPON, not just the
// multiplier. "x2" says nothing about a shotgun, and the whole question with this
// augment is how many things actually leave the barrel.
var wep = player.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
var mult = ShotMultiplier( player );
var authored = wep.IsValid() && wep.Primary is not null ? wep.Primary.Bullets : 0;
Log.Info( $"[nz-aug] M1 Double Fire shots x{mult}"
+ (wep.IsValid()
? $" {wep.DisplayName}: {authored} → {authored * mult} per trigger pull"
+ $" ({wep.Primary?.BulletType?.GetType().Name ?? "no bullet type"})"
: " no weapon held") );
Log.Info( $"[nz-aug] twin spread +{TwinSpread:0.####}"
+ " (added to the COPIES only — your aimed first shot is unaffected)" );
// ⚠️ THE RESOLVED INTERVAL FROM THE WEAPON ITSELF, not a recomputation of the
// chain. `GetRealRPM` is the documented chokepoint and the stats panel reads it for
// the same reason — a second copy of the arithmetic here is exactly how the panel
// came to promise 720 RPM on a gun that fired 600 for months.
var interval = wep.IsValid() && wep.Primary is not null
? wep.GetRealRPM( wep.Primary.RPM )
: 0f;
Log.Info( $"[nz-aug] M2 Rapid Fire rate x{(Has( player, "M2" ) ? RapidFireRate : 1f):0.##}" );
// ⚠️ PRINTS THE CHARGE AS WELL AS THE MULTIPLIER. A console command is by
// definition read while not shooting, so the charge is nearly always full when you
// look — and a multiplier reported at its ceiling is indistinguishable from one that
// is stuck there.
var charge = wep.IsValid() ? wep.TriggerCharge : 0f;
Log.Info( $"[nz-aug] M3 Trigger Disc up to x{TriggerDisciplineMax:0.##}"
+ $", +{TriggerBuildSeconds:0.##}s idle to fill"
+ $", -{TriggerDrainSeconds:0.##}s firing to empty"
+ $" (spends {TriggerBuildSeconds / System.MathF.Max( 0.01f, TriggerDrainSeconds ):0.#}x faster than it fills)" );
Log.Info( $"[nz-aug] charge {charge * 100f:0}%"
+ $" → x{TriggerDamage( player, charge ):0.##}"
+ (Has( player, "M3" ) ? "" : " (not equipped)") );
Log.Info( $"[nz-aug] m1 Rapid Rounds rate x{(Has( player, "m1" ) ? RapidRoundsRate : 1f):0.##}"
+ $" combined x{FireRateMultiplier( player, wep ):0.###}"
+ (interval > 0f ? $" → {60f / interval:0} RPM" : "") );
Log.Info( $"[nz-aug] M4 Full Auto {(FullAuto( player ) ? "semi → auto" : "-")}"
+ (wep.IsValid() && wep.Primary is not null
? $" {wep.DisplayName}: authored {wep.Primary.FiringType}"
+ $" → firing {wep.EffectiveFiringType( wep.Primary )}"
: "") );
Log.Info( $"[nz-aug] m4 Steady Barr recoil x{RecoilMultiplier( player ):0.##}" );
// ⚠️ THE LIVE FIELDS ARE PRINTED, NOT JUST THE INTENDED ONES. `PenetrationDepth` is assigned
// by `NZPlayer.ApplyTech` on every deploy from `basePen * penFactor + bonus`, so the only
// way to know the augment actually landed is to read the value off the weapon in hand. A
// report that echoed `PierceBodies` back would agree with itself while the gun disagreed.
var si = wep.IsValid() ? wep.Primary : null;
var bonus = PierceDepthBonus( player );
var keep = PierceDamageKeep( player );
Log.Info( $"[nz-aug] m2 Overpenetr. {(Has( player, "m2" ) ? $"+{PierceBodies:0.#} bodies" : "-")}"
+ $" = +{bonus:0.#}u at {BodyDepth:0.#}u each"
+ (si is not null ? $" live depth {si.PenetrationDepth:0.#}u" : "")
+ (si is not null ? $" (~{si.PenetrationDepth / MathF.Max( 1f, BodyDepth ):0.#} bodies," : "")
+ " engine cap 10)" );
Log.Info( $"[nz-aug] m3 Armor Pierc. {(Has( player, "m3" ) ? $"keep x{ArmorPiercingKeep:0.##}" : "-")}"
+ (si is not null ? $" live x{si.PenetrationDamageMult:0.##}" : "")
+ (keep > 0f
? $" 3 bodies deep: {MathF.Pow( keep, 3f ) * 100f:0}% vs 42% authored"
: " authored 0.75 keeps 42% at 3 bodies deep") );
// ⛔ NOT `Penetration` ITSELF — it is TRUE on all 31 prefabs, so neither augment grants
// piercing, both deepen it. `t4_solidslug` is the one thing that turns it off, and it beats
// both of these outright: a weapon that cannot penetrate spends no budget at all.
Log.Info( $"[nz-aug] penetration enabled: "
+ (si is not null ? $"{si.Penetration}" : "no weapon")
+ (si is not null && !si.Penetration ? " ⛔ Solid Slug is off — both augments are dead" : "") );
Log.Info( $"[nz-aug] m5 Overpressure — {(Has( player, "m5" ) ? "OWNED" : "not owned")}"
+ $" · {OverpressureChance * 100f:0.#}% ×{OverpressureDamage:0.##}"
+ $" for {OverpressureCost} rounds · {OverpressureFired} fired"
+ " (nz_aug_dtap_overpressure to force)" );
}
// ── commands ─────────────────────────────────────────────────────────────
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>`nz_aug_dtap` — the report.</summary>
[ConCmd( "nz_aug_dtap" )]
public static void DtapCmd() => Report( Me() );
/// <summary>
/// `nz_aug_dtap_set [multiplier] [twinSpread]` — how many shots, and how far the
/// copies fan out.
///
/// ⚠️ THE MULTIPLIER GOES ABOVE 2 ON PURPOSE. Setting it to 8 is how you confirm the
/// extra projectiles are real rather than a damage multiplier wearing a costume: eight
/// tracers are unmistakable, and eight times the damage on a single trace is not.
/// </summary>
[ConCmd( "nz_aug_dtap_set" )]
public static void SetCmd( int multiplier = -1, float twinSpread = -1f )
{
if ( multiplier >= 1 ) DoubleFireMultiplier = multiplier;
if ( twinSpread >= 0f ) TwinSpread = twinSpread;
Report( Me() );
}
/// <summary>
/// `nz_aug_dtap_rate [rapidFire] [revUpMax] [rapidRounds] [recoil]` — the four
/// stat augments.
///
/// ⚠️ SEPARATE FROM `nz_aug_dtap_set`, which owns Double Fire. Six positional floats
/// on one command is more than anyone will remember, and these four move one axis each
/// while that one changes how many things leave the barrel.
/// </summary>
[ConCmd( "nz_aug_dtap_rate" )]
public static void RateCmd( float rapidFire = -1f, float rapidRounds = -1f,
float recoil = -1f )
{
if ( rapidFire >= 0f ) RapidFireRate = rapidFire;
if ( rapidRounds >= 0f ) RapidRoundsRate = rapidRounds;
if ( recoil >= 0f ) SteadyBarrelRecoil = recoil;
Report( Me() );
}
/// <summary>
/// `nz_aug_dtap_trigger [max] [seconds]` — Trigger Discipline's ceiling and how long
/// it takes to get there.
///
/// ⚠️ ITS OWN COMMAND, not two more floats on the rate one. Trigger Discipline is a
/// DAMAGE augment now; grouping it with the three rate knobs is how someone later
/// concludes it must be on the rate axis after all.
/// </summary>
[ConCmd( "nz_aug_dtap_trigger" )]
public static void TriggerCmd( float max = -1f, float build = -1f, float drain = -1f )
{
if ( max >= 0f ) TriggerDisciplineMax = max;
if ( build >= 0f ) TriggerBuildSeconds = build;
if ( drain >= 0f ) TriggerDrainSeconds = drain;
Report( Me() );
}
/// <summary>
/// `nz_aug_dtap_charge [0-1]` — set the held weapon's charge directly.
///
/// ⛔ THE ONLY WAY TO SEE THE TOP OF THE RAMP FROM A CONSOLE. Reading the report always
/// shows a full charge (you are not shooting while you type) and reading it mid-fight is
/// impossible, so the interesting states — half charged, nearly spent — are otherwise
/// unreachable except by feel. This sets one and the stats card shows the result.
/// </summary>
[ConCmd( "nz_aug_dtap_charge" )]
public static void ChargeCmd( float charge = -1f )
{
var p = Me();
var wep = p?.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
if ( !wep.IsValid() ) { Log.Warning( "[nz-aug] no weapon held" ); return; }
if ( charge >= 0f ) wep.SetTriggerCharge( charge );
Report( p );
}
}