Skip to content

Bullet Heaven — weapons, loadout & build-craft

Bullet Heaven — weapons, loadout & build-craft

Section titled “Bullet Heaven — weapons, loadout & build-craft”

Extracted from CLAUDE.md on 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. See CLAUDE.md § “Subsystem architecture — read on demand”.

Weapon dock, drones, and hitscan lightning mid-run

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):

Blade icon Pulse icon Nova icon Orbit icon Beam icon Turret icon Scatter icon

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 only active_weapon_ids tick; a fresh run starts ["pulse"]. The tick loops for wid in active_weapon_ids: _weapon_by_id[wid].update(self, dt). roll_upgrade_choices adds a "weapon:<id>" grant per un-owned weapon while a slot is free; apply_upgrade routes weapon: to grant_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.godot had 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] with window/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 (_input toggles _sticky; a STOP-filter hit-area toggles _hover). Rebuilds tiles only when the active-weapon count changes; data-driven on active_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); pulse base_damage 1→2 in seed.js (two-shots a swarmer); PlayerState.xp_to_next 10→5 (weapon #2 by ~5s); gem magnetism added in Sim._collect_gems (gems within GEM_MAGNET_RADIUS=240 drift to the player at GEM_MAGNET_SPEED=520, deterministic) so collection is reliable while kiting. Baseline re-pinned again to 3746855395/380627596.
  • Level-up offer rules (Sim.roll_upgrade_choices): GUARANTEES a weapon:<id> grant in the 3 choices while the player has <4 weapons (so weapons reliably drop); transformative mods (Overcharge/Catalyst/Lingering) are gated by _mod_eligible until _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/radius are PackedFloat32Array, _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 = []): exclude ids are filtered out of every pool (mods, Stasis Web, weapon grants, wm:, evolve:, crystal grants) and _top_up re-offers the replaced cards in shown order if the pool runs short (never a second crystal card). With exclude empty every pool and upgrade_rng draw is the pre-reroll one, so no baseline moves. reroll_choices spends one of Sim.meta_rerolls and rolls a fresh offer for the same level (tunable REROLL_EXCLUDE_SHOWN); skip_upgrade drops the pending level for SKIP_XP_REFUND_FRAC of xp_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: LevelUpPanel X / R and B / Backspace (two presses within SKIP_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_circle repulsion), 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 SAME upgrade_effects score 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-free pick_upgrade prefix fallback (“weapon, else first”) survives for stateless callers (StoryAutoPlayer traces, ExperimentalUpgradePlayer.PRIORITY, which is exactly what old-vs-new soaks compare against). main.gd runs it by default (_auto=true) and hands control to a human the instant input_router.poll().move_dir is 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). New class_name in the new ai/ dir tripped the stale-class-cache trap — run godot --headless --import after 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”

Level-up panel and build overview: weapons, DPS, and player stats

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 + SimMods entries projectiles_pierce/split_on_hit (previously loaded-but-unhandled); ProjPool gained pierce/split columns; _resolve_collisions lets a piercing shot survive its hit and a non-piercing shot fan split children (_spawn_split). Offered only when a projectile weapon (scatter/turret) is owned (_mod_eligible special-case). Adding to SimMods grew ContentDB.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 > 0 so 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 until DIFFICULTY_START (120s) then +DIFFICULTY_PER_MIN (0.5)/min; _spawn_enemies multiplies 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 to base^minutes capped at DIFFICULTY_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_SOURCES in _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): EnemyPool gained a stable entity_id column (monotonic, swap-removed in lockstep, NOT in state_checksum); _update_shooters keys _shooter_timers by entity_id not 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 by Sim._resolve_collisions for free, no new renderer). Element blood. Mods power/pellets/spread; evolves → Fusillade. Full wiring per the new-weapon checklist above.
  • Visuals (weapon FX v2): every fx_events.append in a weapon must be sim.fx_events.append(WeaponVfx.stamp({...}, pilot.arsenal, "<wid>")) on one line, and new weapons go in FxManager.V2_LOOKS or tests/test_weapon_fx_guards.gd PENDING_V2_LOOKS. What the gate actually checks (tests/test_weapon_fx_guards.gd): guard 1 scans only one-line fx_events.append( calls in sim/weapon_*.gd for the stamp, so push_back, multi-line appends and appends in other files are not caught; guard 5 fails any weapon id missing from both V2_LOOKS and PENDING_V2_LOOKS. No guard checks that add_proj passes bullet_vfx/rungs; that is tested only for the blaster (tests/test_proj_pool_vfx.gd). See docs/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), innate blood, reuses BEHAVIOR_WALK. Spawns only at run_time ≥ 50s (SpawnDirector.pick_type band). Render LUT in main.gd resized to TYPE_BRUTE+1.
  • Mechanical per-weapon upgrades (build 20): each weapon now offers 2-4 distinct mods beyond power. The mechanics live in each weapon’s apply_mod(kind, mag) (keeps /sim purity + weapon-local state) — NOT a giant match in sim.gd. Sim._apply_weapon_mod just 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.gd turret render pool sized to WeaponTurret.MAX_TURRETS_CAP (4). Level-up preview now/after reads each weapon’s mod_now_after(kind, mag). Add a weapon mod = one row in WEAPON_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 gains evolved flag + evolve() (blade→Tempest 360°, pulse→Tesla Coil +5 chain, nova→Supernova, orbit→Event Horizon, beam→Lance, turret→Garrison). Sim._weapon_levels counts wm: 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_choices PRIORITISES an available evolution; apply_upgrade routes evolve:<wid>. ModState.has_any() added. Crystal-threshold rungs (WeaponThresholds, applied in Sim._apply_weapon_thresholds) do NOT increment weapon_levels — they’re tracked per pilot in PilotArsenal.thresholds_done["wid:i"], and each weapon’s rung elements are fixed in WeaponThresholds.TABLE; anything that wants “how upgraded is this weapon” must count both (see WeaponVfx.invested, spec docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md).
  • Beam slew, hit count, Lance cadence (FX v2 batch 2, 2026-10-01): WeaponBeam keeps a per-instance aim_dir (per pilot, since each PilotArsenal owns its beam). At fire time it turns toward the current nearest target by at most turn_rate x time since the aim last moved and 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 is WeaponBeam.TURN_RATE (6 rad/s) / LANCE_TURN_RATE (3.5 rad/s, evolved); 0 = the old snap, bit-exact. The beam fx event gains "hits" (enemies damaged by that shot; additive, existing keys unchanged). Evolved Lance fires every LANCE_COOLDOWN_S (0.25 s, base before fire rate) at LANCE_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 from LANCE_COOLDOWN_S, so a weapons.beam.cooldown_s bible 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 its EnemyPool.knockback impulse topped UP (capped, not stacked, so a many-tick contact can’t fling it away) to KB_SPEED (160 px/s) along the pilot->enemy radial; Sim._move_enemies integrates it with the shared KNOCKBACK_DECAY (a released shove coasts ~v/12 px) and Sim._resolve_walls keeps 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) get KB_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 by PROJ_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 over enemy_proj with 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 by shove_scale() = (1 + KB_LEVEL_STEP 0.2 x min(WeaponVfx.invested, KB_LEVEL_CAP 8)) x KB_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.knockback impulse that Sim._move_enemies integrates, so the bound lives there, in Sim.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 from SPAWN_BORDER_MARGIN) is held where it is on that axis, never snapped in (the stray leash STRAY_MARGIN/STRAY_RECALL_TIME still 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 is ARENA_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() ADDS EVOLVE_SHARDS (3) capped at MAX_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 stay const (MAX sizes main.gd’s legacy sprite pool and is the shader’s constant trail-loop bound; EVOLVE is read in the one-shot evolve()). The evolved ring also bends space, inside WeaponOrbit.update, no RNG, no removal: enemy bullets inside the ring (or within WARP_LENS_RADIUS 56 px of a shard) are turned toward the spin tangent by (WARP_PROJ_ACCEL 90 inside the ring + WARP_LENS_ACCEL 600 x lens) / speed rad/s, speed kept exactly, lens = (1 - d/R)^2 to the nearest shard; folded into the existing single _deflect_projectiles pass (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 tangential EnemyPool.knockback top-up to WARP_ENEMY_SPEED 30 + WARP_ENEMY_LENS_SPEED 60 x lens px/s through the shove’s shared _top_up_knockback (capped not stacked; bosses none, elites x KB_ELITE_MULT, kinetic_immune respected), one query_circle per update. Not scaled by shove_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: OrbitShardsLook LENS/EDDY instances (docs/architecture/rendering-and-performance.md). Evolution copy: WeaponEvolutions.TABLE.orbit desc + a flavor line (1997 film nod, original text) shown by WeaponDetailView. Hum: AudioManager.orbit_pitch pitches the same loop to EH_HUM_PITCH 0.7 with a slow EH_HUM_WOBBLE drift 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_lance fx event gains "target_dist" (distance to the farthest aimed target; additive). (2) Hit-confirm: EnemyPool.last_hit_time (sim run_time of the last landed hit, written in ElementalSystem.damage_enemy, swap-remove safe, render-only, NOT in state_checksum); FxManager.draw_marks flashes every MARKED enemy hit within FxManager.MARK_FLASH_S (0.12 s). The scan only runs after a railgun_lance was consumed and sleeps once no enemy carries a mark. (3) Charge tell: when the cooldown crosses WeaponRailgun.TELL_S (0.1 s) remaining, the aim LATCHES on the farthest target and a railgun_tell event (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 on Sim.mark_pop_queue (capped at MARK_POP_QUEUE_CAP 64, cleared by dev_clear_enemies); the next Railgun update resolves the queue (_resolve_mark_pops, before the cooldown check) for MARK_POP_FRAC (0.25) x lance damage to every enemy within MARK_POP_RADIUS (40 px) via damage_enemy, one AoE group per pop, and emits mark_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): _TellNode hairline, a pale ring per pop (own cap MARK_POP_CAP 6 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_MULT 1.8, cooldown ×EVOLVE_COOLDOWN_MULT 5 (was ×0.6 — it previously fired FASTER, which was also the evolved fill wall: on-screen overlap ∝ cadence), damage ×EVOLVE_DMG_MULT 28 (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 inside area (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 at SHOCK_SPEED, elites ×SHOCK_ELITE_MULT, enemy bullets flung at SHOCK_BULLET_SPEED; all tunables). spawn_zone gained optional radius/duration/dps params (Wildfire’s leaves_decal call unchanged, byte-identical); update_zones reads per-zone dps (default ZONE_DPS, so hand-built dicts keep working). Zones are NOT in state_checksum and 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 at NovaBurstLook.EVO_DRAW_SCALE (0.6) x the damage radius and dimmed by EVO_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): BossState gained ATTACK_SPIRAL (4th attack, ATTACK_COUNT=4) + enraged/spiral_phase. Spiral fires BOSS_SPIRAL_ARMS × BOSS_SPIRAL_PER_ARM shots (varying speed → arms spread into lines; rotates BOSS_SPIRAL_STEP between casts) via existing enemy_proj; its telegraph reuses the generic charge-pulse (no render gap). Enrage latches below BOSS_ENRAGE_FRAC (40% HP) → telegraph/rest times × BOSS_ENRAGE_TIME_MULT (faster cadence) + spirals throw BOSS_ENRAGE_EXTRA_ARMS more. boss_render_info().enraged exposed.
  • ⚠️ 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 full cp tests/*.gd → tvOS (verification-only, zero deploy risk) → 56 scripts / 257 green at main’s baseline. Go-forward: when syncing main→tvOS, copy the WHOLE tests/ 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 install persists the new bundle even while the Apple TV is asleep; only launch needs an awake display (else FBSOpenApplicationServiceErrorDomain 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 terminate needs --pid, not a bundle id.)

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.