Bullet Heaven — weapons, loadout & build-craft
Bullet Heaven — weapons, loadout & build-craft
Section titled “Bullet Heaven — weapons, loadout & build-craft”Extracted from
CLAUDE.mdon 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. SeeCLAUDE.md§ “Subsystem architecture — read on demand”.

The original 7 weapons, at a glance (8 more have shipped since without icon art — see WEAPON_ORDER in sim/upgrade_system.gd for the full current list):
![]()
Loadout + interface overhaul (M2 cycle 11, DONE)
Section titled “Loadout + interface overhaul (M2 cycle 11, DONE)”Weapon-slot system + a proper anchored HUD/dock. Re-pinned determinism baseline (start-with-1 is a different run): snapshot_string().hash()=3746855395, state_checksum()=380627596.
- Loadout: start with 1 weapon, acquire up to
Sim.MAX_WEAPONS(6) at level-up. All five weapon instances still exist (sim.weapon/nova/orbit/beam/turret— tests + display read them), but onlyactive_weapon_idstick; a fresh run starts["pulse"]. The tick loopsfor wid in active_weapon_ids: _weapon_by_id[wid].update(self, dt).roll_upgrade_choicesadds a"weapon:<id>"grant per un-owned weapon while a slot is free;apply_upgraderoutesweapon:togrant_weapon.active_weapon_views()feeds the dock. Adding the weapon-grant pool changed the upgrade_rng draw sequence but NOT the determinism baseline (the baseline test never rolls upgrades) — only start-with-1 (fewer weapons ticking) moved it. - The “UI lands mid-screen” trap:
project.godothad no[display]section at all, so stretch was disabled — CanvasLayer UIs positioned in a hardcoded 1152×648 space rendered at the wrong spot (the weapon panel floated mid-field, HP bar overlapped the level). Fix:[display]withwindow/stretch/mode="canvas_items"+aspect="expand", then anchor every HUD/dock Control (set_anchors_preset(PRESET_TOP_LEFT/CENTER_TOP/TOP_RIGHT/CENTER_BOTTOM)) so they stick to real screen edges at any window size. Absolute pixel positioning in a CanvasLayer is the bug; anchors are the fix. - Weapon dock (
ui/weapon_panel.gd) is bottom-centre, compact by default (a row of element-coloured weapon glyphs), expands to full cards (name + stat) on hover OR Tab (_inputtoggles_sticky; a STOP-filter hit-area toggles_hover). Rebuilds tiles only when the active-weapon count changes; data-driven onactive_weapon_views(). - HUD (
ui/hud.gd) rebuilt with anchored Controls: HP bar top-left, level in its own badge (no overlap), timer top-centre, kills top-right; bigger fonts. - Early-game tuning (the start-with-1 opening, addressed): spawn ramp softened
2.0+0.5t→1.0+0.22t(SpawnDirector.rate_at); pulsebase_damage1→2 inseed.js(two-shots a swarmer);PlayerState.xp_to_next10→5 (weapon #2 by ~5s); gem magnetism added inSim._collect_gems(gems withinGEM_MAGNET_RADIUS=240drift to the player atGEM_MAGNET_SPEED=520, deterministic) so collection is reliable while kiting. Baseline re-pinned again to3746855395/380627596. - Level-up offer rules (
Sim.roll_upgrade_choices): GUARANTEES aweapon:<id>grant in the 3 choices while the player has<4weapons (so weapons reliably drop); transformative mods (Overcharge/Catalyst/Lingering) are gated by_mod_eligibleuntil_active_element_count() >= 2(“don’t offer reaction buffs before reactions are possible”). Weapon stats display one decimal (dmg 2.5) so a +25% buff is visible — note damage is already float end-to-end (enemies.data/radiusarePackedFloat32Array,_damage_enemy(amount:float)); the old “3 shots even with +25%” was just dmg-1 + a breakpoint, not an int-truncation bug. - Level-up reroll + skip (issue #77, spec
docs/superpowers/specs/2026-10-20-levelup-reroll-skip-design.md).roll_upgrade_choices(sim, pilot, n, exclude = []):excludeids are filtered out of every pool (mods, Stasis Web, weapon grants,wm:,evolve:, crystal grants) and_top_upre-offers the replaced cards in shown order if the pool runs short (never a second crystal card). Withexcludeempty every pool andupgrade_rngdraw is the pre-reroll one, so no baseline moves.reroll_choicesspends one ofSim.meta_rerollsand rolls a fresh offer for the same level (tunableREROLL_EXCLUDE_SHOWN);skip_upgradedrops the pending level forSKIP_XP_REFUND_FRACofxp_to_next(a refund over the bar levels on the NEXT tick’s sweep). Hidden in the Tutorial and the App Clip (scripted offers;_actions_allowed) and in networked runs (main passes{}; not SimCommands yet). UI:LevelUpPanelX / R and B / Backspace (two presses withinSKIP_CONFIRM_S) plus touch REROLL / SKIP buttons; main_on_levelup_reroll/_on_levelup_skip, replay ids#reroll/#skip. - Attract-mode auto-player (
ai/auto_player.gd,AutoPlayer): a CONTROLLER (not/sim) that reads sim state and returns a move dir — kites enemies (hash.query_circlerepulsion), seeks the nearest gem, avoids walls;pick_upgrade_for(sim, pilot, ids)(2026-10-04, Chris: “always make the best decision on what upgrades to choose”) takes the top-scored offered card using the SAMEupgrade_effectsscore the level-up panel badges BEST (rung-crossing crystal grant > new weapon > long-term crystal > plain stat > dead), with a tutorial lesson’s forced card always winning its own step; the context-freepick_upgradeprefix fallback (“weapon, else first”) survives for stateless callers (StoryAutoPlayer traces,ExperimentalUpgradePlayer.PRIORITY, which is exactly what old-vs-new soaks compare against).main.gdruns it by default (_auto=true) and hands control to a human the instantinput_router.poll().move_diris non-zero; auto-picks level-ups after a 0.8s beat so the un-played demo plays itself instead of freezing on the choice screen. Does NOT affect determinism (the sim is deterministic given any input stream). Newclass_namein the newai/dir tripped the stale-class-cache trap — rungodot --headless --importafter adding a class in a new dir or the boot path parse-errors (Could not find type AutoPlayer) even though GUT passes.
Build-craft depth + evolutions + boss phase-2 + new content (M2 cycle 17, DONE) — builds 20-28
Section titled “Build-craft depth + evolutions + boss phase-2 + new content (M2 cycle 17, DONE) — builds 20-28”
Nine TDD’d chunks 2026-06-24/25, each committed to main + synced to tvOS + deployed to the TV. All determinism-neutral at the time (baseline was 4152236597/1267954985 — the baseline run is blade-only and never rolls upgrades; the boss is past the 40s window; new enemies are gated past 50s; difficulty scaling is ×1.0 inside the 120s grace window; the shooter fix and leech don’t touch the blade-only baseline). Re-pinned to 4152236597/2325839371 by task 10 (per-enemy variation).
- Projectile mods pierce/split (build 26):
ModState.projectile_pierce/projectile_split+SimModsentriesprojectiles_pierce/split_on_hit(previously loaded-but-unhandled);ProjPoolgained pierce/split columns;_resolve_collisionslets a piercing shot survive its hit and a non-piercing shot fansplitchildren (_spawn_split). Offered only when a projectile weapon (scatter/turret) is owned (_mod_eligiblespecial-case). Adding toSimModsgrewContentDB.upgrades()— three offer-set tests had to be updated (the offerable count + exact-set assertions); when you make a previously-unhandled mod offerable, expect those. - Leech (build 27):
ModState.lifesteal_per_kill+SimMods+ bible “Leech”; heals the player on each kill in_sweep_dead(capped at max HP, GUARDED by> 0so the default is a true no-op for the baseline which kills swarmers). - Endless difficulty scaling (build 28):
SpawnDirector.difficulty_mult(run_time)— 1.0 untilDIFFICULTY_START(120s) then +DIFFICULTY_PER_MIN(0.5)/min;_spawn_enemiesmultiplies spawned HP + contact damage by it. ×1.0 is an exact IEEE-754 no-op so the <10s baseline is byte-identical. Exponential mode (2026-10-04):DIFFICULTY_EXP_BASE> 1 swaps the HP curve tobase^minutescapped atDIFFICULTY_EXP_MAX;CONTACT_EXP_*/SPEED_EXP_*add capped compounding contact damage and speed (contact_time_mult/speed_time_mult), and boss hits (Sim.BOSS_DAMAGE_SOURCESin_hurt_player) ride the contact multiplier so bosses stay the hardest hitters. All bases default to 1.0 (off); remote config turns them on. Note the Accumulator rewrites its contact/speed from bible bases every tick, so only its HP scales. - Shooter ghost-fire fix (build 23):
EnemyPoolgained a stableentity_idcolumn (monotonic, swap-removed in lockstep, NOT instate_checksum);_update_shooterskeys_shooter_timersbyentity_idnot pool index, so a swap-removed shooter’s slot no longer makes an unrelated enemy ghost-fire. - Scatter weapon (build 24): 6th weapon (
sim/weapon_scatter.gd) — a scattergun firing a fan of pellets via the projectile pool (collisions + reactions handled bySim._resolve_collisionsfor free, no new renderer). Elementblood. Mods power/pellets/spread; evolves → Fusillade. Full wiring per the new-weapon checklist above. - Visuals (weapon FX v2): every
fx_events.appendin a weapon must besim.fx_events.append(WeaponVfx.stamp({...}, pilot.arsenal, "<wid>"))on one line, and new weapons go inFxManager.V2_LOOKSortests/test_weapon_fx_guards.gdPENDING_V2_LOOKS. What the gate actually checks (tests/test_weapon_fx_guards.gd): guard 1 scans only one-linefx_events.append(calls insim/weapon_*.gdfor the stamp, sopush_back, multi-line appends and appends in other files are not caught; guard 5 fails any weapon id missing from bothV2_LOOKSandPENDING_V2_LOOKS. No guard checks thatadd_projpassesbullet_vfx/rungs; that is tested only for the blaster (tests/test_proj_pool_vfx.gd). Seedocs/architecture/rendering-and-performance.md§ Weapon FX v2. - Brute enemy (build 25): 8th enemy (
EnemyPool.TYPE_BRUTE=8) — slow/huge/tanky walker (160 HP, armor 8, radius 30), innateblood, reusesBEHAVIOR_WALK. Spawns only at run_time ≥ 50s (SpawnDirector.pick_typeband). Render LUT inmain.gdresized toTYPE_BRUTE+1. - Mechanical per-weapon upgrades (build 20): each weapon now offers 2-4 distinct mods beyond
power. The mechanics live in each weapon’sapply_mod(kind, mag)(keeps/simpurity + weapon-local state) — NOT a giant match insim.gd.Sim._apply_weapon_modjust delegates:w.apply_mod(kind, _weapon_mod_mag(wid,kind)). Mods: blade arc/reach, pulse chain (chain-lightning via_nearest_unhit)/range, nova radius/rate, orbit shard/spin/reach, beam width/reach, turret count/rate. Tunable consts (orbit radius/spin, beam width/range, turret max/fire) became instance vars (consts kept as defaults);main.gdturret render pool sized toWeaponTurret.MAX_TURRETS_CAP(4). Level-up preview now/after reads each weapon’smod_now_after(kind, mag). Add a weapon mod = one row inWEAPON_MODS+ a case in that weapon’s two methods. - Weapon evolutions (build 21): the payoff.
sim/weapon_evolutions.gd(WeaponEvolutions) holds metadata (evolved name + desc) +REQUIRES_LEVEL=3. Each weapon gainsevolvedflag +evolve()(blade→Tempest 360°, pulse→Tesla Coil +5 chain, nova→Supernova, orbit→Event Horizon, beam→Lance, turret→Garrison).Sim._weapon_levelscountswm:upgrades per weapon;can_evolve(wid)gates on active + not-evolved +weapon_level >= REQUIRES_LEVEL+mods.has_any()(any transformative mod taken — the “commit a weapon path + a mod” combo).roll_upgrade_choicesPRIORITISES an available evolution;apply_upgraderoutesevolve:<wid>.ModState.has_any()added. Crystal-threshold rungs (WeaponThresholds, applied inSim._apply_weapon_thresholds) do NOT incrementweapon_levels— they’re tracked per pilot inPilotArsenal.thresholds_done["wid:i"], and each weapon’s rung elements are fixed inWeaponThresholds.TABLE; anything that wants “how upgraded is this weapon” must count both (seeWeaponVfx.invested, specdocs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md). - Beam slew, hit count, Lance cadence (FX v2 batch 2, 2026-10-01):
WeaponBeamkeeps a per-instanceaim_dir(per pilot, since eachPilotArsenalowns its beam). At fire time it turns toward the current nearest target by at mostturn_rate x time since the aim last movedand fires along the aim, so damage follows the visible sweep, not the target; the first acquisition snaps. The aim only moves on fire ticks (no O(n) nearest scan while cooling down); the cap is the same as slewing every tick. Turn rate isWeaponBeam.TURN_RATE(6 rad/s) /LANCE_TURN_RATE(3.5 rad/s, evolved);0= the old snap, bit-exact. Thebeamfx event gains"hits"(enemies damaged by that shot; additive, existing keys unchanged). Evolved Lance fires everyLANCE_COOLDOWN_S(0.25 s, base before fire rate) atLANCE_DMG_MULT(2.5x) per hit, same DPS as the old 0.1 s x1.0; those two off values reproduce the old Lance. Note: once evolved, Lance cadence comes fromLANCE_COOLDOWN_S, so aweapons.beam.cooldown_sbible overlay no longer changes it. All four are remote-config tunables. - Orbit Shards shove (2026-10-02, Chris’s BUILD 248 feedback): in
WeaponOrbit.update, every enemy a shard hits gets itsEnemyPool.knockbackimpulse topped UP (capped, not stacked, so a many-tick contact can’t fling it away) toKB_SPEED(160 px/s) along the pilot->enemy radial;Sim._move_enemiesintegrates it with the sharedKNOCKBACK_DECAY(a released shove coasts ~v/12 px) andSim._resolve_wallskeeps it out of story walls, exactly like the manual weapon’s shot shove. Bosses (BEHAVIOR_BOSS/boss_rotation.is_boss_type) get none; elites (is_elite) getKB_ELITE_MULT(0.5); the manual weapon’s immunities (brute, Wyrm segment, mid-lunge dasher, Halo-buffed) are now one helper,ElementalSystem.kinetic_immune, used by both. Enemy bullets overlapping a shard are DEFLECTED: the velocity turns toward the pilot->bullet radial byPROJ_PUSH_ACCEL(1200 px/s^2) / speed rad/s, speed kept exactly (a dead-on bullet turns +angle). Never braked – an earlier additive push could park a persist=1 boss bullet at ~10 px/s for minutes – and never deleted. It is one pass per update overenemy_projwith an annulus reject before the per-shard tests (500 bullets, 6 shards, M5 headless: 0.061 ms/update vs 0.203 ms for the per-shard full scan; 0.006 ms with the push off). Both scale byshove_scale()= (1 +KB_LEVEL_STEP0.2 x min(WeaponVfx.invested,KB_LEVEL_CAP8)) xKB_EVOLVED_MULT(1.4) when evolved, so a fresh orbit shoves at 160 px/s, a level-8 one at 416, a level-8 Event Horizon at ~582. No RNG, no removal, no new render event (the displacement is its own tell; no projectile deflect flash yet). All six are remote-config tunables (docs/architecture/remote-config.md§ Orbit Shards shove). Tests:tests/test_orbit_knockback.gd. - Knockback arena bound (2026-10-11, Chris’s ATV BUILD 297 note “knockback should not push enemies outside of the map”). Every enemy shove (Rift Bolt shot, Orbit Shards, Event Horizon warp) is the shared
EnemyPool.knockbackimpulse thatSim._move_enemiesintegrates, so the bound lives there, inSim.bound_knockback_shove(static, pure): per axis, a shove may not carry an enemy further out past±Sim_Const.ARENA_HALF(the drawn border, the pilot’s own clamp box). An enemy already outside (border spawns enter fromSPAWN_BORDER_MARGIN) is held where it is on that axis, never snapped in (the stray leashSTRAY_MARGIN/STRAY_RECALL_TIMEstill handles genuinely stuck ones). The blocked axis’s impulse is zeroed (spent against the wall); the tangent keeps coasting. Survival-only (story == null), since story rooms bound enemies with walls (_resolve_walls). No RNG, no new tunable (the bound isARENA_HALF). Before this, a shove pushed enemies up to ~230 px past the border, inside the 250 px leash margin, so nothing ever pulled a stationary one back. Tests:tests/test_knockback_arena_bound.gd. - Event Horizon: shard stacking + space-time warp (2026-10-07, Chris).
WeaponOrbit.evolve()ADDSEVOLVE_SHARDS(3) capped atMAX_SHARDS(7) instead of setting the max, so the Cold-5 crystal rung’s +1 stacks: survival 3 + 1 + 3 = 7, evolve alone = 6, order-independent (the rebuild replay path agrees). Both stayconst(MAX sizes main.gd’s legacy sprite pool and is the shader’s constant trail-loop bound; EVOLVE is read in the one-shotevolve()). The evolved ring also bends space, insideWeaponOrbit.update, no RNG, no removal: enemy bullets inside the ring (or withinWARP_LENS_RADIUS56 px of a shard) are turned toward the spin tangent by (WARP_PROJ_ACCEL90 inside the ring +WARP_LENS_ACCEL600 x lens) / speed rad/s, speed kept exactly, lens = (1 - d/R)^2 to the nearest shard; folded into the existing single_deflect_projectilespass (the annulus reject widens to the warp disc only when evolved, so an un-evolved orbit’s path is unchanged). Enemies in the same region get a tangentialEnemyPool.knockbacktop-up toWARP_ENEMY_SPEED30 +WARP_ENEMY_LENS_SPEED60 x lens px/s through the shove’s shared_top_up_knockback(capped not stacked; bosses none, elites xKB_ELITE_MULT,kinetic_immunerespected), onequery_circleper update. Not scaled byshove_scale(). All five are remote tunables, 0 = off. Cost (M5 headless microbench, evolved 7 shards, per update): 500 random bullets 0.077 -> 0.124 ms, 140 random enemies 0.050 -> 0.082 ms. Render consumer:OrbitShardsLookLENS/EDDY instances (docs/architecture/rendering-and-performance.md). Evolution copy:WeaponEvolutions.TABLE.orbitdesc + aflavorline (1997 film nod, original text) shown byWeaponDetailView. Hum:AudioManager.orbit_pitchpitches the same loop toEH_HUM_PITCH0.7 with a slowEH_HUM_WOBBLEdrift while an evolved orbit is hitting. Tests:tests/test_event_horizon.gd. - Railgun mechanics (FX v2 batch 2, 2026-10-01): four additions, each behind a remote-config tunable whose off value is the old behaviour. (1) The
railgun_lancefx event gains"target_dist"(distance to the farthest aimed target; additive). (2) Hit-confirm:EnemyPool.last_hit_time(simrun_timeof the last landed hit, written inElementalSystem.damage_enemy, swap-remove safe, render-only, NOT instate_checksum);FxManager.draw_marksflashes every MARKED enemy hit withinFxManager.MARK_FLASH_S(0.12 s). The scan only runs after arailgun_lancewas consumed and sleeps once no enemy carries a mark. (3) Charge tell: when the cooldown crossesWeaponRailgun.TELL_S(0.1 s) remaining, the aim LATCHES on the farthest target and arailgun_tellevent (pos, dir, length, width, element, lead) is emitted per lance; the shot fires along the latched direction even if the target moved or died, so the tell never lies. A Railgun that was ready and waiting for a target gets the full tell lead before it fires.TELL_S 0= no tell, aim at fire time. (4) Mark detonation: a mark that expires (apply_status_and_decay) or a marked enemy that dies (Sim._sweep_dead) QUEUES a pop onSim.mark_pop_queue(capped atMARK_POP_QUEUE_CAP64, cleared bydev_clear_enemies); the next Railgun update resolves the queue (_resolve_mark_pops, before the cooldown check) forMARK_POP_FRAC(0.25) x lance damage to every enemy withinMARK_POP_RADIUS(40 px) viadamage_enemy, one AoE group per pop, and emitsmark_pop. Never mid-sweep; a pop that kills another marked enemy queues the next link for the following update, so a chain advances one link per tick and every enemy pops at most once. In co-op the first Railgun to update resolves every pop at its own lance damage. Render consumers are all FxManager-owned (v1 and every candidate):_TellNodehairline, a pale ring per pop (own capMARK_POP_CAP6 against the shared ring pool),_MarkFlashNode. Tests:tests/test_weapon_railgun.gd,tests/test_fx_manager.gd. - Supernova rework (2026-10-01, Chris: “increase its dmg, add dmg over time, halve or more its rate of fire”): evolved nova is now big/slow/remnant.
WeaponNova.evolve(): area ×EVOLVE_AREA_MULT1.8, cooldown ×EVOLVE_COOLDOWN_MULT5 (was ×0.6 — it previously fired FASTER, which was also the evolved fill wall: on-screen overlap ∝ cadence), damage ×EVOLVE_DMG_MULT28 (Chris 2026-10-13: x4 damage at half the rate of the 10-01 ×7 / ×2.5), and every evolved pulse drops a burning remnant zone at the pilot position —elemental_system.spawn_zone(..., area × REMNANT_FRAC 0.55, ZONE_DURATION, burst × REMNANT_DPS_FRAC 0.0625). It fires the moment its cooldown is ready AND an enemy is insidearea(the trigger range IS the damage radius, 324 px evolved); at a 10 s cooldown the swarm usually closes in during the wait., the standard terrain-zone infra rendered by ZoneRenderer with the weapon’s element tint. Each evolved pulse also sends a radial shockwave (_shockwave: enemies shoved outward atSHOCK_SPEED, elites ×SHOCK_ELITE_MULT, enemy bullets flung atSHOCK_BULLET_SPEED; all tunables).spawn_zonegained optional radius/duration/dps params (Wildfire’sleaves_decalcall unchanged, byte-identical);update_zonesreads per-zonedps(defaultZONE_DPS, so hand-built dicts keep working). Zones are NOT instate_checksumand no baseline evolves nova, so the determinism baselines held unchanged. Sim tests:tests/test_weapon_nova.gd. Drawn size (TestFlight #27, 2026-10-11): the evolved burst is DRAWN atNovaBurstLook.EVO_DRAW_SCALE(0.6) x the damage radius and dimmed byEVO_GAIN(0.55), both live render tunables; the sim area is unchanged (see rendering-and-performance.md, perf-cuts table). - Boss spiral + enrage (build 22):
BossStategainedATTACK_SPIRAL(4th attack,ATTACK_COUNT=4) +enraged/spiral_phase. Spiral firesBOSS_SPIRAL_ARMS × BOSS_SPIRAL_PER_ARMshots (varying speed → arms spread into lines; rotatesBOSS_SPIRAL_STEPbetween casts) via existingenemy_proj; its telegraph reuses the generic charge-pulse (no render gap). Enrage latches belowBOSS_ENRAGE_FRAC(40% HP) → telegraph/rest times ×BOSS_ENRAGE_TIME_MULT(faster cadence) + spirals throwBOSS_ENRAGE_EXTRA_ARMSmore.boss_render_info().enragedexposed. - ⚠️ The tvOS repo had silently drifted BEHIND main — but only in TEST files. Cycle-16 synced the production code (sim/render/main identical) but left ~6 stale test files + 3 missing ones, so the tvOS suite ran 52 scripts / 9 failing with the OLD determinism baseline (
1998629725/261923457). Diffing every gameplay dir (not trusting “it’s reconciled”) surfaced it; fix was a fullcp tests/*.gd → tvOS(verification-only, zero deploy risk) → 56 scripts / 257 green at main’s baseline. Go-forward: when syncing main→tvOS, copy the WHOLEtests/dir too, not just the changed gameplay files — a partial copy hides a drifting baseline behind a green-looking but smaller suite. - Deploy reality:
devicectl installpersists the new bundle even while the Apple TV is asleep; onlylaunchneeds an awake display (elseFBSOpenApplicationServiceErrorDomain error 1 "System is asleep"). So a chunk genuinely ships to the device regardless — Chris sees the latest installed build on next wake. (devicectl … process terminateneeds--pid, not a bundle id.)
Damage attribution (Sim.damage_by_weapon)
Section titled “Damage attribution (Sim.damage_by_weapon)”ElementalSystem.damage_enemy credits every hit to the ambient Sim.damage_context (weapon id,
"aim", "drone"). Damage that ticks OUTSIDE a weapon’s own update() must set the context
itself, or it lands in "unattributed" and the death screen / run timeline / balance probe show the
weapon at 0% (Chalice and Scar Tissue did, until 2026-10-17). Current out-of-loop sources and how
they credit: Chalice pools (zone["wid"] in Sim._update_chalice_zones), Scar Tissue discharge +
Hemorrhage (Sim._hurt_player), ElementalSystem zones (Supernova remnant, Wildfire decal: the
zone’s "src", stamped at spawn_zone), element status DoT (EnemyPool.aura_src, stamped by
apply_element), Decoy reflect and the Decoy Shield Burst when enemies destroy it (DroneDirector.damage_drones_from_enemies
-> "drone"). Only burn is a status DoT (StatusEffects), so e.g. Chalice’s share is pool damage alone. Still
"unattributed" by design: powerup nuke, story scripted kills. None of this is checksummed;
tests/test_damage_attribution.gd pins it through the real tick path.