Skip to content

Weapon FX v2 — foundation + batch 1 implementation plan

Weapon FX v2 — Foundation + Batch 1 Implementation Plan

Section titled “Weapon FX v2 — Foundation + Batch 1 Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Batch-1 weapons get looks that grow with investment and change when the weapon evolves. The batch is Blaster (and all player bullets), Orbit Shards, Blade, Scythe and Nova. Crystal accents show which elements upgraded each weapon. The v1 looks stay behind an A/B switch and serve as the L3+ quality fallback. Structural guard tests make any future weapon fail the gate if its effect is invisible or doesn’t scale.

Architecture:

  • Sim side. Every weapon fx_event gets wid/power/evolved/rungs from a pure WeaponVfx helper. Player projectiles carry three render-only ProjPool columns: vfx, rungs and kind. Nothing enters state_checksum and no RNG is drawn.
  • Render side. Each v2 look is one WeaponFxLayer, a MultiMeshInstance2D with one shared shader, so each look costs one draw call. Per-instance data travels in COLOR and INSTANCE_CUSTOM.
  • Dispatch. FxManager stays a dispatcher and routes events to v2 looks when v2_active() is true. main.gd swaps between the legacy orbit sprites / proj_renderer and OrbitShardsLook / PlayerBoltRenderer.

Tech Stack: Godot 4.7, typed GDScript, canvas_item shaders with #include, GUT, and the bh-vfx-iteration harness tiers.

Spec: docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md (approved as written). Read it before any task.

  • Visual only. Sim/balance, sound and mechanics are unchanged, including Scar Tissue’s event kind. Enemy/boss effects stay visual only and are out of scope (Chris, 2026-09-29). Enemy element reactions are batch 6 with their own design pass.
  • tests/test_determinism_survival.gd must pass unchanged. Any movement is a bug in this work, not a re-pin. Never run bh-determinism-repin for this plan.
  • /sim stays pure RefCounted. It gets no Node, render, Engine or Time APIs, and no new rng draws. fx_events stays out of state_checksum and snapshot_string.
  • Power curve: power = 1 - pow(0.72, invested), where invested = weapon_levels[wid] + popcount(rungs). A weapon never reaches 1.0.
  • Grow budget: a power-1 look is at most 1.5× the power-0 look in size or thickness. Interiors stay clear so enemies inside an effect remain readable. The element tint shows in every look.
  • The evolved form takes the evolve rung’s element (WeaponThresholds.TABLE kind == "evolve").
  • Crystal motifs are built against a generic (p, mask, edge, strength, t, seed) shape interface, so batch 6 (enemies) can reuse them.
  • Quality: v2 looks run at L0–L2 (QualityManager.weapon_fx_v2_on(l) = l <= 2). L3+ uses the legacy looks.
  • Weapon feedback stays essential. Nova, slash and blaster_fire muzzle are never juice-gated. bolt_impact sparks are juice.
  • The HDR recipe matches Plasma Nova’s: blend_add, unshaded, with hot parts above 1.0 so the existing bloom (glow_hdr_threshold 1.0) picks them up. Colour uniforms use : source_color. Per-instance tint is packed raw, the same as SwarmRenderer.
  • No cp/rsync into platform/tvos/, which holds symlinks.
  • scripts/gate.sh runs git checkout -- project.godot, so commit intentional project.godot edits first. After every windowed Godot run, git diff --stat project.godot then git checkout -- project.godot.
  • Commits use git commit -F <file>. The message ends with:
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01FSXXmmsKp5hKdWgW9LjD34
  • Two developers share the repo. Before merging to main, run git fetch origin && git log HEAD..origin/main --oneline and rebase if needed. Then git push origin main right after the merge.
Terminal window
# after adding any new class_name or shader/include (else "Identifier not declared"):
godot --headless --path . --import
# one test script (-gtest does NOT narrow the run; -gselect does):
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexit
# judge by the GUT summary ("Passing Tests"/"Failing Tests"), NOT the exit code — headless teardown
# SIGABRTs (exit 134) even on a clean run.
# determinism (run after every sim-touching task):
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism -gexit
# full gate (import + suite + test-count guard + tutorial trace + boot smoke):
scripts/gate.sh

scripts/check-test-count.sh counts tests/test_*.gd, so each new test file raises the expected count. Commit new test files in the same commit as their code, and let the gate’s count guard see them.

File Status Responsibility
sim/weapon_vfx.gd new WeaponVfx: invested/power/rungs/evolved/stamp, plus the bullet encode/decode
sim/proj_pool.gd modify render-only columns vfx, rungs, kind, plus kind_for(ctx)
sim/weapon_*.gd (13 files), sim/sim.gd, sim/elemental_system.gd modify stamp every emit; bullets pass vfx/rungs; splits inherit; bolt_impact
fx/weapons/weapon_fx_layer.gd new WeaponFxLayer: the one-draw-call MultiMesh base for every v2 look
fx/weapons/fx_noise.gdshaderinc new (extracted) hash21, vnoise, fbm shared by all weapon shaders
fx/weapons/plasma_nova.gdshader moved Plasma Nova shader (pure git mv plus include)
fx/weapons/plasma_nova_node.gd new (moved) PlasmaNovaNode (+ embers), the v1 nova path moved out of FxManager
fx/weapons/element_accents.gdshaderinc new crystal accent motifs (accent_motif, accents_all, evolve_accent)
fx/weapons/element_accents.gd new ElementAccents: GDScript twin (ids, code()), kept in sync by guard test
fx/weapons/blade_slash_look.gd + .gdshader new Blade look (+ Tempest), real arc
fx/weapons/scythe_reap_look.gd + .gdshader new Scythe look (+ Reaping)
fx/weapons/nova_burst_look.gd + .gdshader new Nova v2 (+ Supernova); Scar Tissue discharge scaled by its own power
fx/weapons/orbit_shards_look.gd + .gdshader new Orbit shards + trail + link ring (+ Event Horizon), all pilots
fx/weapons/player_bolt_renderer.gd + player_bolt.gdshader new all player bullets (+ Auto-Cannon twin cores)
fx/weapons/blaster_flash_look.gd + .gdshader new muzzle flash + Auto-Cannon impact sparks
fx/fx_manager.gd modify V2_LOOKS, v2_active(), look layers, dispatch; legacy slash honours arc
render/visual_look.gd modify weapon_fx_v2 switch (later: fold plasma_nova)
render/quality_manager.gd modify weapon_fx_v2_on(l); _apply sets fx_layer.v2_quality_ok
ui/debug_settings_panel.gd modify “Weapon FX v2 (A/B)” row
main.gd modify orbit look / bolt renderer wiring against legacy
tools/weapon_vfx/{compare,ingame,motifs}.gd + .tscn new tier-1/3 harnesses and motif grid
tools/perf_probe/perf_probe.gd modify --weapons= --evolve --v2= --level=
tests/test_weapon_vfx.gd, tests/test_weapon_fx_guards.gd, tests/test_proj_pool_vfx.gd, tests/test_weapon_fx_layer.gd, tests/test_weapon_fx_looks.gd new unit + structural guards

Custom-data layouts (INSTANCE_CUSTOM, four float32 values; COLOR.rgb is the element tint and COLOR.a is look-defined):

Look COLOR.a custom.x custom.y custom.z custom.w
Blade / Scythe life t power + 2*evolved accent slot arc (rad) seed
Nova life t power + 2*evolved accent slot seed 0
Orbit ring (1 per pilot) spin_norm power + 2*evolved accent slot phase (rad) shard count
Orbit shard 1 power + 2*evolved accent slot seed k -1
Player bolt 1 vfx (power, +1 if evolved) accent slot ProjPool kind remaining life
Blaster flash life t power + 2*evolved accent slot seed mode (0 muzzle, 1 impact)

Evolved decodes as x >= 1.5 for looks and vfx >= 1.0 for bolts. The accent code is an int, 2 bits per element id at bits 2*(id-1) (ids 1–10) and the evolve id at bits 20–23. It never rides per-instance (Task 7 fix round 1): the gl_compatibility (web) renderer stores MultiMesh COLOR and INSTANCE_CUSTOM as HALF floats (max 65504, 11-bit mantissa), so custom.y holds layer.accent_slot(code) (0–15, slot 0 = no accents / table full) and the shader recovers the code with accent_code_for_slot(custom.y) from the layer’s accent_codes int uniform. Every per-instance value must survive half precision: seeds are integers < 2048 or fractions in [0, 1); WeaponVfx.power is capped at POWER_MAX so an unevolved bolt vfx never rounds up to 1.0. Guard 6 round-trips these through encode_half/decode_half.

Known 2D gotcha: the UV y-axis of a QuadMesh in a MultiMeshInstance2D can come out flipped relative to world +y. Every shader below maps p = (UV - 0.5) * 2.0. If a grid shows a look mirrored (for example the blade sweeping the wrong way), flip p.y in that shader. Don’t touch the transform packing, because the layer test pins it.


Task 1: WeaponVfx — the power/evolution data helper

Section titled “Task 1: WeaponVfx — the power/evolution data helper”

Files:

  • Create: sim/weapon_vfx.gd
  • Test: tests/test_weapon_vfx.gd

Interfaces:

  • Consumes: PilotArsenal.weapon_levels: Dictionary, PilotArsenal.thresholds_done: Dictionary (keys "wid:i"), PilotArsenal.weapon_by_id: Dictionary, PilotArsenal.aim: WeaponAim, WeaponThresholds.rules_for(wid) -> Array, weapon.evolved: bool.

  • Produces (all static):

    • WeaponVfx.POWER_BASE := 0.72
    • weapon(arsenal: PilotArsenal, wid: String) -> Object
    • rungs(arsenal, wid) -> int (bit i set ⇔ thresholds_done["wid:i"])
    • invested(arsenal, wid) -> int
    • power(arsenal, wid) -> float
    • is_evolved(arsenal, wid) -> bool
    • stamp(ev: Dictionary, arsenal, wid) -> Dictionary (mutates and returns ev; adds wid, power, evolved, rungs)
    • bullet_vfx(arsenal, wid) -> float (power + 1.0 if evolved)
    • stamp_bullet(ev: Dictionary, wid: String, vfx_v: float, rungs_v: int) -> Dictionary
  • Step 1: Write the failing test

tests/test_weapon_vfx.gd:

extends GutTest
# Weapon FX v2 (spec docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md): WeaponVfx is the ONE
# place that turns a pilot's investment in a weapon into render inputs. Level-up picks and crystal
# rungs BOTH count (weapon_levels alone misses every crystal rung).
func _content() -> ContentDB:
return ContentLoader.load_from_path("res://data/bible.json")
func _arsenal() -> PilotArsenal:
return PilotArsenal.new(_content())
func test_fresh_weapon_has_zero_power() -> void:
var a := _arsenal()
assert_eq(WeaponVfx.invested(a, "blade"), 0)
assert_almost_eq(WeaponVfx.power(a, "blade"), 0.0, 1e-6)
assert_false(WeaponVfx.is_evolved(a, "blade"))
assert_eq(WeaponVfx.rungs(a, "blade"), 0)
func test_levels_and_crystal_rungs_both_count() -> void:
var a := _arsenal()
a.weapon_levels["blade"] = 2
a.thresholds_done["blade:1"] = true
assert_eq(WeaponVfx.rungs(a, "blade"), 0b010, "rung 1 only")
assert_eq(WeaponVfx.invested(a, "blade"), 3, "2 picks + 1 rung")
assert_almost_eq(WeaponVfx.power(a, "blade"), 1.0 - pow(0.72, 3.0), 1e-6)
func test_power_curve_is_monotonic_and_never_reaches_one() -> void:
var a := _arsenal()
var prev := -1.0
for n in range(0, 25):
a.weapon_levels["nova"] = n
var p := WeaponVfx.power(a, "nova")
assert_gt(p, prev, "strictly increasing at n=%d" % n)
assert_lt(p, 1.0, "never reaches 1.0")
prev = p
func test_evolved_reads_the_weapon_flag() -> void:
var a := _arsenal()
a.blade.evolve()
assert_true(WeaponVfx.is_evolved(a, "blade"))
func test_aim_resolves_to_the_arsenal_aim_weapon() -> void:
var a := _arsenal()
assert_same(WeaponVfx.weapon(a, "aim"), a.aim)
a.aim.evolve()
assert_true(WeaponVfx.is_evolved(a, "aim"))
func test_unknown_weapon_is_zero_not_a_crash() -> void:
var a := _arsenal()
assert_almost_eq(WeaponVfx.power(a, "no_such_weapon"), 0.0, 1e-6)
assert_false(WeaponVfx.is_evolved(a, "no_such_weapon"))
assert_eq(WeaponVfx.rungs(a, "no_such_weapon"), 0)
func test_stamp_adds_the_four_fields_in_place() -> void:
var a := _arsenal()
a.weapon_levels["nova"] = 1
var ev := {"kind": "nova"}
var out := WeaponVfx.stamp(ev, a, "nova")
assert_true(is_same(out, ev), "stamps in place and returns the same dict")
assert_eq(out["wid"], "nova")
assert_almost_eq(float(out["power"]), 0.28, 1e-5)
assert_eq(out["evolved"], false)
assert_eq(out["rungs"], 0)
func test_rungs_are_per_pilot() -> void:
var sim := Sim.new(1, _content())
var p2 := sim.add_pilot()
p2.arsenal.thresholds_done["orbit:0"] = true
assert_eq(WeaponVfx.rungs(p2.arsenal, "orbit"), 1)
assert_eq(WeaponVfx.rungs(sim.player.arsenal, "orbit"), 0, "P1 unaffected by P2's rung")
func test_bullet_vfx_encodes_evolved_as_plus_one() -> void:
var a := _arsenal()
a.weapon_levels["blaster"] = 2
var p := WeaponVfx.power(a, "blaster")
assert_almost_eq(WeaponVfx.bullet_vfx(a, "blaster"), p, 1e-6)
a.blaster.evolve()
assert_almost_eq(WeaponVfx.bullet_vfx(a, "blaster"), p + 1.0, 1e-6)
func test_stamp_bullet_decodes_the_column_values() -> void:
var ev := WeaponVfx.stamp_bullet({"kind": "burst"}, "aim", 1.25, 3)
assert_eq(ev["wid"], "aim")
assert_true(ev["evolved"])
assert_almost_eq(float(ev["power"]), 0.25, 1e-6)
assert_eq(ev["rungs"], 3)
var ev2 := WeaponVfx.stamp_bullet({"kind": "burst"}, "blaster", 0.4, 0)
assert_false(ev2["evolved"])
assert_almost_eq(float(ev2["power"]), 0.4, 1e-6)
  • Step 2: Run to verify it fails

Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexit Expected: parse error / Identifier "WeaponVfx" not declared.

  • Step 3: Implement sim/weapon_vfx.gd
class_name WeaponVfx
extends RefCounted
# Weapon FX v2 (spec docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md). Turns how far a pilot
# has invested in one weapon into render inputs, and stamps them onto that weapon's fx_events.
# Pure READ of arsenal state: never writes sim state, never draws an rng, and fx_events are excluded
# from state_checksum/snapshot_string -- so nothing here can move the determinism baseline.
# invested = level-up picks (weapon_levels) + crystal rungs reached (thresholds_done); weapon_levels
# alone misses every crystal rung (they don't increment it).
const POWER_BASE := 0.72 # power = 1 - POWER_BASE^invested: 0 fresh, ~0.5 after 2, ~0.8 after 5, never 1
static func weapon(arsenal: PilotArsenal, wid: String) -> Object:
if wid == "aim":
return arsenal.aim
return arsenal.weapon_by_id.get(wid)
static func rungs(arsenal: PilotArsenal, wid: String) -> int:
var mask := 0
var n := WeaponThresholds.rules_for(wid).size()
for i in range(n):
if arsenal.thresholds_done.has("%s:%d" % [wid, i]):
mask |= 1 << i
return mask
static func invested(arsenal: PilotArsenal, wid: String) -> int:
var m := rungs(arsenal, wid)
var bits := 0
while m != 0:
bits += m & 1
m >>= 1
return int(arsenal.weapon_levels.get(wid, 0)) + bits
static func power(arsenal: PilotArsenal, wid: String) -> float:
return 1.0 - pow(POWER_BASE, float(invested(arsenal, wid)))
static func is_evolved(arsenal: PilotArsenal, wid: String) -> bool:
var w := weapon(arsenal, wid)
return w != null and bool(w.get("evolved"))
# Stamps ev IN PLACE (so emit sites stay one line) and returns it.
static func stamp(ev: Dictionary, arsenal: PilotArsenal, wid: String) -> Dictionary:
ev["wid"] = wid
ev["power"] = power(arsenal, wid)
ev["evolved"] = is_evolved(arsenal, wid)
ev["rungs"] = rungs(arsenal, wid)
return ev
# Per-bullet encoding for ProjPool.vfx: power in [0,1), +1.0 when the firing weapon is evolved.
static func bullet_vfx(arsenal: PilotArsenal, wid: String) -> float:
return power(arsenal, wid) + (1.0 if is_evolved(arsenal, wid) else 0.0)
# Stamps an event raised by a BULLET (burst splash, impact) from its ProjPool columns -- by the time
# it hits, the firing pilot isn't known, so the values were captured at add_proj.
static func stamp_bullet(ev: Dictionary, wid: String, vfx_v: float, rungs_v: int) -> Dictionary:
var evolved := vfx_v >= 1.0
ev["wid"] = wid
ev["power"] = vfx_v - 1.0 if evolved else vfx_v
ev["evolved"] = evolved
ev["rungs"] = rungs_v
return ev
  • Step 4: Run to verify it passes

Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexit Expected: all tests in test_weapon_vfx.gd pass. If test_aim_resolves... fails because WeaponAim.evolve() takes arguments, read sim/weapon_aim.gd, call it with its real signature, and note the change in the commit body.

  • Step 5: Commit
Terminal window
git add sim/weapon_vfx.gd sim/weapon_vfx.gd.uid tests/test_weapon_vfx.gd tests/test_weapon_vfx.gd.uid
printf '%s\n' "feat(fx-v2): WeaponVfx power/evolution helper" "" \
"invested = level-up picks + crystal rungs; power = 1 - 0.72^invested. Pure read, render-only." "" \
"Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" \
"Claude-Session: https://claude.ai/code/session_01FSXXmmsKp5hKdWgW9LjD34" > /tmp/msg.txt
git commit -F /tmp/msg.txt

(Use the session scratchpad instead of /tmp if one is available. The same commit recipe applies to every later task; only the title and body change.)


Task 2: ProjPool render-only columns + bullets carry their weapon’s power

Section titled “Task 2: ProjPool render-only columns + bullets carry their weapon’s power”

Files:

  • Modify: sim/proj_pool.gd (whole file, shown below)
  • Modify: sim/weapon_blaster.gd:43-45, sim/weapon_scatter.gd:51-53, sim/weapon_momentum_core.gd:141-143, sim/weapon_turret.gd:61-63, sim/sim.gd:2416-2418 (_fire_aim), sim/elemental_system.gd:156-158 and :159-179 (spawn_split)
  • Test: tests/test_proj_pool_vfx.gd

Interfaces:

  • Consumes: WeaponVfx.bullet_vfx(arsenal, wid) -> float, WeaponVfx.rungs(arsenal, wid) -> int (Task 1).

  • Produces:

    • ProjPool.vfx: PackedFloat32Array, ProjPool.rungs: PackedByteArray, ProjPool.kind: PackedByteArray
    • ProjPool.KIND_OTHER=0, KIND_BLASTER=1, KIND_SCATTER=2, KIND_TURRET=3, KIND_MOMENTUM=4, KIND_AIM=5
    • static ProjPool.kind_for(ctx: String) -> int
    • add_proj(..., ctx := "unattributed", vfx_v := 0.0, rungs_v := 0) -> int (two new trailing params)
    • ElementalSystem.spawn_split(sim, pos, vel, radius, dmg, el, count, hit_enemy_radius := 0.0, vfx_v := 0.0, rungs_v := 0)
  • Step 1: Write the failing test

tests/test_proj_pool_vfx.gd:

extends GutTest
# Weapon FX v2: player bullets carry render-only vfx/rungs/kind columns (never checksummed) so the
# PlayerBoltRenderer can grow/transform each bullet by its OWN weapon's investment.
func _content() -> ContentDB:
return ContentLoader.load_from_path("res://data/bible.json")
func test_add_proj_defaults_are_zero() -> void:
var pool := ProjPool.new(8)
var i := pool.add_proj(Vector2.ZERO, Vector2.RIGHT, 4.0, 1.0, 10.0)
assert_almost_eq(pool.vfx[i], 0.0, 1e-6)
assert_eq(pool.rungs[i], 0)
assert_eq(pool.kind[i], ProjPool.KIND_OTHER)
func test_kind_is_derived_from_context() -> void:
assert_eq(ProjPool.kind_for("blaster"), ProjPool.KIND_BLASTER)
assert_eq(ProjPool.kind_for("scatter"), ProjPool.KIND_SCATTER)
assert_eq(ProjPool.kind_for("turret"), ProjPool.KIND_TURRET)
assert_eq(ProjPool.kind_for("momentum_core"), ProjPool.KIND_MOMENTUM)
assert_eq(ProjPool.kind_for("aim"), ProjPool.KIND_AIM)
assert_eq(ProjPool.kind_for("unattributed"), ProjPool.KIND_OTHER)
func test_swap_remove_keeps_columns_aligned() -> void:
var pool := ProjPool.new(8)
pool.add_proj(Vector2(1, 0), Vector2.RIGHT, 4.0, 1.0, 1.0, -1, 0, 0, 0.0, 0.0, "blaster", 0.3, 1)
pool.add_proj(Vector2(2, 0), Vector2.RIGHT, 4.0, 1.0, 1.0, -1, 0, 0, 0.0, 0.0, "scatter", 0.6, 2)
pool.add_proj(Vector2(3, 0), Vector2.RIGHT, 4.0, 1.0, 1.0, -1, 0, 0, 0.0, 0.0, "aim", 1.5, 3)
pool.remove_at(0) # last (aim) swaps into slot 0
assert_eq(pool.pos[0], Vector2(3, 0))
assert_almost_eq(pool.vfx[0], 1.5, 1e-6)
assert_eq(pool.rungs[0], 3)
assert_eq(pool.kind[0], ProjPool.KIND_AIM)
assert_almost_eq(pool.vfx[1], 0.6, 1e-6)
assert_eq(pool.kind[1], ProjPool.KIND_SCATTER)
func test_blaster_bullets_carry_the_blaster_investment() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.player.arsenal.weapon_levels["blaster"] = 3
sim.player.arsenal.blaster.evolve()
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 1000.0)
sim.damage_context = "blaster"
for _i in range(120):
sim.player.arsenal.blaster.update(sim, sim.player, Sim_Const.DT)
if sim.projectiles.count > 0:
break
assert_gt(sim.projectiles.count, 0, "blaster fired")
var want := WeaponVfx.bullet_vfx(sim.player.arsenal, "blaster")
assert_gt(want, 1.0, "evolved -> +1 encoding")
assert_almost_eq(sim.projectiles.vfx[0], want, 1e-5)
assert_eq(sim.projectiles.kind[0], ProjPool.KIND_BLASTER)
func test_split_children_inherit_vfx_and_rungs() -> void:
var sim := Sim.new(1, _content())
sim.damage_context = "blaster"
sim.elemental_system.spawn_split(sim, Vector2.ZERO, Vector2(300, 0), 4.0, 5.0, -1, 3, 10.0, 1.4, 2)
assert_eq(sim.projectiles.count, 3)
for i in range(3):
assert_almost_eq(sim.projectiles.vfx[i], 1.4, 1e-6)
assert_eq(sim.projectiles.rungs[i], 2)
assert_eq(sim.projectiles.kind[i], ProjPool.KIND_BLASTER)
  • Step 2: Run to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_proj_pool_vfx -gexit Expected: FAIL. vfx/kind are not members of ProjPool, and add_proj gets too many arguments.

  • Step 3: Implement the columns in sim/proj_pool.gd

Add the fields after var context: PackedStringArray:

# Weapon FX v2 -- RENDER-ONLY (never read into state_checksum/snapshot_string, never read by any sim
# logic). vfx = the firing weapon's power (+1.0 if evolved), rungs = its crystal-rung bitmask, kind =
# which bullet look to draw (derived from ctx). Captured at add_proj because by impact time the firing
# pilot isn't known.
const KIND_OTHER := 0
const KIND_BLASTER := 1
const KIND_SCATTER := 2
const KIND_TURRET := 3
const KIND_MOMENTUM := 4
const KIND_AIM := 5
var vfx: PackedFloat32Array
var rungs: PackedByteArray
var kind: PackedByteArray
static func kind_for(ctx: String) -> int:
match ctx:
"blaster": return KIND_BLASTER
"scatter": return KIND_SCATTER
"turret": return KIND_TURRET
"momentum_core": return KIND_MOMENTUM
"aim": return KIND_AIM
return KIND_OTHER

In _init, after context.resize(cap):

vfx.resize(cap)
rungs.resize(cap)
kind.resize(cap)

Replace add_proj with:

func add_proj(p: Vector2, v: Vector2, r: float, lifetime: float, dmg: float, el: int = -1,
pierce_v: int = 0, split_v: int = 0, knockback_v: float = 0.0, burst_radius_v: float = 0.0,
ctx: String = "unattributed", vfx_v: float = 0.0, rungs_v: int = 0) -> int:
var i := super.add(p, v, r, lifetime)
if i != -1:
damage[i] = dmg
element_idx[i] = el
pierce[i] = pierce_v
split[i] = split_v
knockback[i] = knockback_v
burst_radius[i] = burst_radius_v
context[i] = ctx
vfx[i] = vfx_v
rungs[i] = rungs_v
kind[i] = kind_for(ctx)
return i

In remove_at, inside if i != last: after context[i] = context[last]:

vfx[i] = vfx[last]
rungs[i] = rungs[last]
kind[i] = kind[last]
  • Step 4: Pass vfx/rungs at the four weapon call sites and the aim shot

Each site appends two arguments after sim.damage_context (or damage_context in sim.gd). The rest of each call is unchanged. The wid in each row below was checked against the file name and damage_context (sim.gd sets damage_context = wid around each weapon_by_id[wid].update).

File:line Append after the ctx argument
sim/weapon_blaster.gd:45 , WeaponVfx.bullet_vfx(pilot.arsenal, "blaster"), WeaponVfx.rungs(pilot.arsenal, "blaster")
sim/weapon_scatter.gd:53 , WeaponVfx.bullet_vfx(pilot.arsenal, "scatter"), WeaponVfx.rungs(pilot.arsenal, "scatter")
sim/weapon_momentum_core.gd:143 , WeaponVfx.bullet_vfx(pilot.arsenal, "momentum_core"), WeaponVfx.rungs(pilot.arsenal, "momentum_core")
sim/weapon_turret.gd:63 , WeaponVfx.bullet_vfx(pilot.arsenal, "turret"), WeaponVfx.rungs(pilot.arsenal, "turret")
sim/sim.gd:2418 (_fire_aim) , WeaponVfx.bullet_vfx(pilot.arsenal, "aim"), WeaponVfx.rungs(pilot.arsenal, "aim")

Hoist the two values out of the per-shot loop in blaster, scatter and momentum_core; the investment doesn’t change mid-volley. Blaster example:

var bvfx := WeaponVfx.bullet_vfx(pilot.arsenal, "blaster")
var brungs := WeaponVfx.rungs(pilot.arsenal, "blaster")
for k in range(n):
var off: float = 0.0 if n == 1 else (float(k) - float(n - 1) * 0.5) * SPREAD
var a := base_a + off
sim.projectiles.add_proj(pilot.pos, Vector2(cos(a), sin(a)) * proj_speed,
proj_radius, proj_lifetime, PlayerCrit.roll(sim, pilot, dmg), element_idx,
sim.mods.projectile_pierce, sim.mods.projectile_split, 0.0, 0.0, sim.damage_context,
bvfx, brungs)

Scatter and momentum_core follow the same shape with their own wid literal. The single-shot turret and aim sites call the helpers inline.

  • Step 5: Splits inherit

sim/elemental_system.gd. Change the spawn_split signature and its add_proj call:

func spawn_split(sim: Sim, pos: Vector2, vel: Vector2, radius: float, dmg: float, el: int, count: int, hit_enemy_radius: float = 0.0, vfx_v: float = 0.0, rungs_v: int = 0) -> void:
sim.projectiles.add_proj(spawn_pos, dir * spd, radius, SPLIT_LIFETIME, dmg, el, 1, 0,
0.0, 0.0, sim.damage_context, vfx_v, rungs_v) # children pierce once (no re-split); inherit the parent's context + look

At the call in resolve_collisions (currently lines 156–158):

spawn_split(sim, sim.projectiles.pos[pi], sim.projectiles.vel[pi],
sim.projectiles.radius[pi], dmg * frac, el, spl, sim.enemies.radius[hit_ei],
sim.projectiles.vfx[pi], sim.projectiles.rungs[pi])
  • Step 6: Run the new tests, the pool/weapon suites and determinism

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_proj_pool_vfx -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_blaster -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism -gexit

Expected: all pass. The test_determinism* scripts pass unchanged. If a hash moves, stop. The only legal cause would be a sim read of the new columns, and there must be none (/usr/bin/grep -n "\.vfx\[\|\.rungs\[\|\.kind\[" sim/ should show only proj_pool.gd and elemental_system.gd split/stamp reads).

  • Step 7: Commit — feat(fx-v2): player bullets carry render-only vfx/rungs/kind; splits inherit

Task 3: Stamp every weapon event + the structural guard tests

Section titled “Task 3: Stamp every weapon event + the structural guard tests”

Files:

  • Modify (one line each, exact new lines below): sim/weapon_beam.gd:47, sim/weapon_blaster.gd:46, sim/weapon_chalice.gd:31, sim/weapon_chronowell.gd:98, sim/weapon_melee.gd:48, sim/weapon_momentum_core.gd:146, sim/weapon_nova.gd:27, sim/weapon_pulse.gd:50, sim/weapon_railgun.gd:84, sim/weapon_scar_tissue.gd:63,84, sim/weapon_scatter.gd:54, sim/weapon_scythe.gd:51, sim/weapon_thunder_wake.gd:126, sim/weapon_turret.gd:64, sim/weapon_wildfire.gd:47,55,62-66,96,114,122-129, sim/sim.gd:2419, sim/elemental_system.gd:106-127
  • Test: tests/test_weapon_fx_guards.gd (guards 1–4; guards 5–6 are added in Tasks 4–5)

Interfaces:

  • Consumes: WeaponVfx.stamp, WeaponVfx.stamp_bullet (Task 1); ProjPool.vfx/rungs/kind/KIND_BLASTER (Task 2).

  • Produces:

    • Every weapon fx_event carries wid: String, power: float, evolved: bool, rungs: int.
    • blaster_fire gains dir: Vector2 and element: int.
    • New render-only event {"kind": "bolt_impact", "pos", "dir", "element", + stamp}, emitted when an evolved blaster bullet hits.
    • WeaponWildfire._ignite(sim, pilot, ei, depth) and _step_burn_zones(sim, pilot, dt) (pilot threaded through).
  • Step 1: Write the failing guard tests

tests/test_weapon_fx_guards.gd:

extends GutTest
# Weapon FX v2 STRUCTURAL GUARDS (memory bullet-heaven-weapon-fx-v2). These exist so a weapon added
# later via skill bh-add-content FAILS THE GATE if its effect is invisible or doesn't scale:
# 1. every weapon fx_event is stamped (source scan: one-line appends only, by design);
# 2. the batch-1 weapons' real events carry the stamp at runtime;
# 3. evolving ANY weapon changes its stamp (so its event differs from the unevolved one);
# 4. every weapon-local zone array has a renderer that reads it (or is a known, queued bug);
# 5. (Task 4) every weapon id has a v2 look or is explicitly pending with a batch;
# 6. (Task 5) the accent-motif ids agree between the shader include and its GDScript twin.
const EXCLUDED_FILES := ["weapon_vfx.gd", "weapon_thresholds.gd", "weapon_evolutions.gd"]
const WID_FOR_FILE := {"weapon_melee.gd": "blade"} # everything else: strip "weapon_" and ".gd"
# Weapon-local zone arrays that are drawn through an accessor rather than by name.
const RENDER_HOOKS := {"weapon_turret.gd:_turrets": "turret_positions"}
# Known-invisible zone arrays = spec bugs 3/4, fixed by batch 3. Listed here so the guard stays
# green until then -- and fails the moment a NEW invisible array appears.
const KNOWN_INVISIBLE := {
"weapon_wildfire.gd:_burn_zones": "batch 3 (spec bug 3)",
"weapon_thunder_wake.gd:_zones": "batch 3 (spec bug 4)",
}
func _content() -> ContentDB:
return ContentLoader.load_from_path("res://data/bible.json")
func _read(path: String) -> String:
return FileAccess.get_file_as_string(path)
func _weapon_files() -> Array[String]:
var out: Array[String] = []
for f in DirAccess.get_files_at("res://sim"):
if f.begins_with("weapon_") and f.ends_with(".gd") and not EXCLUDED_FILES.has(f):
out.append(f)
return out
func _wid_for(file: String) -> String:
return WID_FOR_FILE.get(file, file.trim_prefix("weapon_").trim_suffix(".gd"))
# ── Guard 1: every fx_events.append in a weapon file is stamped with its own wid ──
func test_every_weapon_fx_event_is_stamped_with_its_own_wid() -> void:
var checked := 0
for f in _weapon_files():
var wid := _wid_for(f)
var lines := _read("res://sim/" + f).split("\n")
for n in range(lines.size()):
var line: String = lines[n]
if not line.contains("fx_events.append("):
continue
checked += 1
assert_true(line.contains("WeaponVfx.stamp("),
"%s:%d fx_event must be stamped: sim.fx_events.append(WeaponVfx.stamp({...}, pilot.arsenal, \"%s\")) -- on ONE line" % [f, n + 1, wid])
assert_true(line.contains("\"%s\")" % wid),
"%s:%d stamp must name this file's weapon id \"%s\"" % [f, n + 1, wid])
assert_gt(checked, 15, "scanned the real emit sites (sanity: the scan isn't silently empty)")
func test_aim_and_bullet_events_are_stamped() -> void:
for line in _read("res://sim/sim.gd").split("\n"):
if line.contains("\"kind\": \"aim_fire\""):
assert_true(line.contains("WeaponVfx.stamp(") and line.contains("\"aim\")"), "aim_fire stamped as aim")
var es := _read("res://sim/elemental_system.gd")
var found := 0
for line in es.split("\n"):
if line.contains("\"kind\": \"burst\"") or line.contains("\"kind\": \"bolt_impact\""):
found += 1
assert_true(line.contains("WeaponVfx.stamp_bullet("), "bullet-raised events stamped from ProjPool columns: " + line.strip_edges())
assert_eq(found, 2, "burst + bolt_impact emit sites present")
# ── Guard 2: batch-1 weapons' REAL events carry the stamp ──
func _fire_until(sim: Sim, w: Object, kind: String) -> Dictionary:
for _i in range(900):
sim.fx_events.clear()
w.call("update", sim, sim.player, Sim_Const.DT)
for ev in sim.fx_events:
if String(ev.get("kind", "")) == kind:
return ev
return {}
func _sim_with_target() -> Sim:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(60, 0), Vector2.ZERO, 14.0, 1.0e9)
return sim
func _assert_stamped(ev: Dictionary, wid: String) -> void:
assert_false(ev.is_empty(), wid + " emitted its event")
for k in ["wid", "power", "evolved", "rungs"]:
assert_true(ev.has(k), "%s event has %s" % [wid, k])
assert_eq(ev.get("wid"), wid)
func test_batch1_events_carry_the_stamp() -> void:
var cases := {"blade": "slash", "scythe": "slash", "nova": "nova", "blaster": "blaster_fire"}
for wid in cases:
var sim := _sim_with_target()
var w: Object = sim.player.arsenal.weapon_by_id.get(wid)
assert_not_null(w, wid + " exists in bible content")
sim.player.arsenal.weapon_levels[wid] = 2
var ev := _fire_until(sim, w, cases[wid])
_assert_stamped(ev, wid)
assert_almost_eq(float(ev.get("power", -1.0)), 1.0 - pow(0.72, 2.0), 1e-5, wid + " power reflects investment")
func test_scar_tissue_discharge_nova_is_stamped_as_scar_tissue() -> void:
var sim := _sim_with_target()
var st: WeaponScarTissue = sim.player.arsenal.scar_tissue
assert_not_null(st)
var ev := {}
for _i in range(60):
sim.fx_events.clear()
st.on_player_hit(sim, sim.player, 5.0)
for e in sim.fx_events:
if String(e.get("kind", "")) == "nova":
ev = e
if not ev.is_empty():
break
_assert_stamped(ev, "scar_tissue")
func test_aim_fire_is_stamped() -> void:
var sim := _sim_with_target()
var inp := InputState.new()
inp.aim_dir = Vector2.RIGHT
sim.fx_events.clear()
sim._fire_aim(sim.player, inp, Sim_Const.DT)
var ev := {}
for e in sim.fx_events:
if String(e.get("kind", "")) == "aim_fire":
ev = e
_assert_stamped(ev, "aim")
# ── Guard 3: evolving ANY weapon changes its stamp ──
func test_every_weapon_stamp_differs_once_evolved() -> void:
var a := PilotArsenal.new(_content())
var wids: Array = a.weapon_by_id.keys()
wids.append("aim")
var checked := 0
for wid in wids:
var w: Object = WeaponVfx.weapon(a, wid)
if w == null:
continue
checked += 1
var before := WeaponVfx.stamp({"kind": "probe"}, a, wid)
w.call("evolve")
var after := WeaponVfx.stamp({"kind": "probe"}, a, wid)
assert_false(before["evolved"], wid + " starts unevolved")
assert_true(after["evolved"], wid + ".evolve() flips the stamped flag")
assert_ne(before, after, wid + ": an evolved weapon's event differs from an unevolved one")
assert_gt(checked, 12, "covered the real roster")
# ── Guard 4: weapon-local zone arrays must have a renderer ──
func _render_sources() -> String:
var src := _read("res://main.gd")
for dir in ["res://render", "res://fx", "res://fx/weapons"]:
if not DirAccess.dir_exists_absolute(dir):
continue
for f in DirAccess.get_files_at(dir):
if f.ends_with(".gd"):
src += "\n" + _read(dir + "/" + f)
return src
func test_every_weapon_zone_array_has_a_renderer() -> void:
var re := RegEx.new()
re.compile("(?m)^var\\s+(\\w+)\\s*:\\s*Array\\[Dictionary\\]")
var render := _render_sources()
var seen := {}
for f in _weapon_files():
for m in re.search_all(_read("res://sim/" + f)):
var key := "%s:%s" % [f, m.get_string(1)]
seen[key] = true
if KNOWN_INVISIBLE.has(key):
continue
var hook: String = RENDER_HOOKS.get(key, m.get_string(1))
var word := RegEx.new()
word.compile("\\b" + hook + "\\b")
assert_not_null(word.search(render),
"%s is a weapon-local zone array nothing renders -- draw it (render/, fx/ or main.gd) or it's an invisible mechanic (spec bugs 3/4)" % key)
for key in KNOWN_INVISIBLE:
assert_true(seen.has(key), "%s no longer exists -- remove it from KNOWN_INVISIBLE" % key)
  • Step 2: Run to verify the guards fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_guards -gexit Expected: the guard 1, 2 and aim/bullet tests FAIL with “must be stamped”. Guards 3 and 4 already pass: guard 3 exercises WeaponVfx directly, and guard 4 sees turret wired plus the two known-invisible arrays. That is correct; they are regression guards.

  • Step 3: Stamp every emit (exact replacement lines)

Replace each line. Indentation is unchanged, and so is every key already on the line; only the WeaponVfx.stamp( wrapper is new. The one exception is blaster_fire, which also gains dir and element.

# sim/weapon_beam.gd:47
sim.fx_events.append(WeaponVfx.stamp({"kind": "beam", "pos": pilot.pos, "dir": dir, "length": beam_range, "width": width, "element": pilot.arsenal.beam_element_idx}, pilot.arsenal, "beam"))
# sim/weapon_blaster.gd:46
sim.fx_events.append(WeaponVfx.stamp({"kind": "blaster_fire", "pos": pilot.pos, "dir": Vector2(cos(base_a), sin(base_a)), "element": element_idx}, pilot.arsenal, "blaster")) # muzzle flash (v2) + audio cue; render channel, not in checksums
# sim/weapon_chalice.gd:31
sim.fx_events.append(WeaponVfx.stamp({"kind": "chalice_cast", "pos": pilot.pos, "radius": radius, "element": pilot.arsenal.chalice_element_idx}, pilot.arsenal, "chalice"))
# sim/weapon_chronowell.gd:98
sim.fx_events.append(WeaponVfx.stamp({"kind": "chronowell_deploy", "pos": center, "radius": BUBBLE_RADIUS}, pilot.arsenal, "chronowell"))
# sim/weapon_melee.gd:48
sim.fx_events.append(WeaponVfx.stamp({"kind": "slash", "pos": pilot.pos, "dir": dir, "range": range, "arc": arc_deg, "element": pilot.arsenal.blade_element_idx}, pilot.arsenal, "blade"))
# sim/weapon_momentum_core.gd:146
sim.fx_events.append(WeaponVfx.stamp({"kind": "momentum_charged_fire", "pos": pilot.pos, "dir": base_a}, pilot.arsenal, "momentum_core"))
# sim/weapon_nova.gd:27
sim.fx_events.append(WeaponVfx.stamp({"kind": "nova", "pos": pilot.pos, "radius": area, "element": pilot.arsenal.nova_element_idx}, pilot.arsenal, "nova"))
# sim/weapon_pulse.gd:50
sim.fx_events.append(WeaponVfx.stamp({"kind": "bolt", "from": from, "to": tpos, "element": pilot.arsenal.pulse_element_idx}, pilot.arsenal, "pulse"))
# sim/weapon_railgun.gd:84
sim.fx_events.append(WeaponVfx.stamp({"kind": "railgun_lance", "pos": pilot.pos, "dir": dir, "length": lance_range, "width": width, "element": element_idx}, pilot.arsenal, "railgun"))
# sim/weapon_scar_tissue.gd:63
sim.fx_events.append(WeaponVfx.stamp({"kind": "scar_tissue_stack", "pos": pilot.pos, "element": element_idx}, pilot.arsenal, "scar_tissue"))
# sim/weapon_scar_tissue.gd:84
sim.fx_events.append(WeaponVfx.stamp({"kind": "nova", "pos": pilot.pos, "radius": radius, "element": element_idx}, pilot.arsenal, "scar_tissue"))
# sim/weapon_scatter.gd:54
sim.fx_events.append(WeaponVfx.stamp({"kind": "scatter_fire", "pos": pilot.pos, "dir": base}, pilot.arsenal, "scatter"))
# sim/weapon_scythe.gd:51
sim.fx_events.append(WeaponVfx.stamp({"kind": "slash", "pos": pilot.pos, "dir": dir, "range": range, "arc": arc_deg, "element": pilot.arsenal.scythe_element_idx}, pilot.arsenal, "scythe"))
# sim/weapon_thunder_wake.gd:126
sim.fx_events.append(WeaponVfx.stamp({"kind": "thunder_wake_strike", "pos": pos, "from": pilot.pos, "radius": strike_radius, "element": element_idx}, pilot.arsenal, "thunder_wake"))
# sim/weapon_turret.gd:64
sim.fx_events.append(WeaponVfx.stamp({"kind": "turret_fire", "pos": from_pos}, pilot.arsenal, "turret"))
# sim/weapon_wildfire.gd:96
sim.fx_events.append(WeaponVfx.stamp({"kind": "wildfire_pop", "pos": pos, "radius": hop_radius}, pilot.arsenal, "wildfire"))
# sim/sim.gd:2419
fx_events.append(WeaponVfx.stamp({"kind": "aim_fire", "pos": pilot.pos}, pilot.arsenal, "aim")) # audio-only cue (render channel, not in checksums)

base_a is already in scope at weapon_blaster.gd:46: it is declared at line 37, above the loop.

  • Step 4: Thread pilot through Wildfire so ignite can be stamped

sim/weapon_wildfire.gd. _ignite and _step_burn_zones currently have no pilot. Change them as follows (no behaviour change; the new param is only read by the stamp):

# update(), line 47:
_step_burn_zones(sim, pilot, dt)
# update(), line 55:
_ignite(sim, pilot, ei, 1)
# signature, line 62, and its emit, line 66:
func _ignite(sim: Sim, pilot: PlayerState, ei: int, depth: int) -> void:
...
sim.fx_events.append(WeaponVfx.stamp({"kind": "ignite", "pos": sim.enemies.pos[ei], "element": element_idx}, pilot.arsenal, "wildfire"))
# _detonate() (has pilot), line 114:
_ignite(sim, pilot, hi, depth + 1)
# _step_burn_zones signature, line 122, and its call, line 129:
func _step_burn_zones(sim: Sim, pilot: PlayerState, dt: float) -> void:
...
_ignite(sim, pilot, ei, FIRESTORM_CHAIN_LEN) # keeps re-triggering the persistent-zone behavior

Verify there are no other callers: /usr/bin/grep -rn "_ignite(\|_step_burn_zones(" sim tests tools should list only the lines above.

  • Step 5: Stamp the bullet-raised events + add bolt_impact

sim/elemental_system.gd, in resolve_collisions. Right after apply_element(sim, hit_ei, el):

# Weapon FX v2 (render-only, excluded from checksums like every fx_event): an evolved
# Blaster (Auto-Cannon) bullet throws impact sparks. Read before any remove_at repoints [pi].
var pvfx: float = sim.projectiles.vfx[pi]
if pvfx >= 1.0 and sim.projectiles.kind[pi] == ProjPool.KIND_BLASTER:
sim.fx_events.append(WeaponVfx.stamp_bullet({"kind": "bolt_impact", "pos": ppos, "dir": sim.projectiles.vel[pi].normalized(), "element": el}, sim.projectiles.context[pi], pvfx, sim.projectiles.rungs[pi]))

Replace the burst line (currently 127):

sim.fx_events.append(WeaponVfx.stamp_bullet({"kind": "burst", "pos": ppos, "radius": br, "element": el}, sim.projectiles.context[pi], sim.projectiles.vfx[pi], sim.projectiles.rungs[pi]))
  • Step 6: Run the guards, the touched weapon suites and determinism

Run:

Terminal window
godot --headless --path . --import
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_guards -gexit
for t in test_weapon_blaster test_weapon_melee test_weapon_scythe test_weapon_nova test_weapon_orbit; do
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=$t -gexit 2>&1 | grep -E "Passing|Failing"; done
/usr/bin/grep -rln "wildfire\|scar_tissue\|thunder_wake" tests/*.gd | xargs -n1 basename | sed 's/\.gd$//' | while read t; do
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=$t -gexit 2>&1 | grep -E "Failing Tests"; done
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism -gexit

Expected: all pass, with determinism unchanged. The audio manager keys only on kind, so the extra keys are inert. grep -n "blaster_fire\|slash" audio/audio_manager.gd confirms it doesn’t compare whole dicts.

  • Step 7: Commit — feat(fx-v2): stamp every weapon fx_event + structural guard tests

Task 4: The v2 switch, quality tier, Debug row, FxManager dispatch scaffolding + look-registry guard

Section titled “Task 4: The v2 switch, quality tier, Debug row, FxManager dispatch scaffolding + look-registry guard”

Files:

  • Modify: render/visual_look.gd (append a static var)
  • Modify: render/quality_manager.gd:125-137 (tier func), :151-157 (_apply)
  • Modify: ui/debug_settings_panel.gd:148-149, :251, :321
  • Modify: fx/fx_manager.gd (new consts/vars/funcs, listed below)
  • Modify: tests/test_fx_manager.gd:50 (child-count assertion)
  • Test: tests/test_weapon_fx_guards.gd (guard 5), tests/test_weapon_fx_looks.gd (new)

Interfaces:

  • Consumes: nothing new.

  • Produces:

    • VisualLook.weapon_fx_v2: bool (default false while batch 1 is in development; Task 16 flips it)
    • static QualityManager.weapon_fx_v2_on(l: int) -> bool (l <= 2)
    • FxManager.V2_LOOKS: Dictionary (wid → look class name; starts {})
    • FxManager.v2_quality_ok: bool, FxManager.v2_active() -> bool, FxManager.v2_layer_count() -> int
    • FxManager._add_v2_layer(layer: Node, wids: Array) -> void (later tasks register their looks through it, per wid)
    • FxManager.v2_look(wid: String) (untyped return: the look layer or null; callers invoke its spawn_event())
    • Debug toggle id "weapon_fx_v2"
  • Step 1: Write the failing tests

Append to tests/test_weapon_fx_guards.gd:

# ── Guard 5: every weapon id has a v2 look, or is explicitly pending with the batch that owns it ──
# When a batch lands a look: add the wid to FxManager.V2_LOOKS and DELETE it here. A new weapon added
# via bh-add-content must be added to one or the other -- the test fails until it is.
const PENDING_V2_LOOKS := {
"blaster": "batch 1 (this plan, Task 13)",
"orbit": "batch 1 (this plan, Task 12)",
"blade": "batch 1 (this plan, Task 9)",
"scythe": "batch 1 (this plan, Task 10)",
"nova": "batch 1 (this plan, Task 11)",
"beam": "batch 2", "railgun": "batch 2",
"pulse": "batch 3", "thunder_wake": "batch 3", "wildfire": "batch 3",
"turret": "batch 4", "scatter": "batch 4", "momentum_core": "batch 4", "aim": "batch 4",
"chalice": "batch 5", "chronowell": "batch 5", "scar_tissue": "batch 5",
}
func test_every_weapon_has_a_v2_look_or_is_pending() -> void:
var a := PilotArsenal.new(_content())
var wids: Array = a.weapon_by_id.keys()
wids.append("aim")
for wid in wids:
var has_look := FxManager.V2_LOOKS.has(wid)
var pending := PENDING_V2_LOOKS.has(wid)
assert_true(has_look or pending, "%s has no v2 look and isn't listed as pending -- add it to FxManager.V2_LOOKS or PENDING_V2_LOOKS" % wid)
assert_false(has_look and pending, "%s has a v2 look now -- remove it from PENDING_V2_LOOKS" % wid)
for wid in PENDING_V2_LOOKS:
assert_true(wids.has(wid), "%s is pending but no longer a weapon -- remove it" % wid)

Create tests/test_weapon_fx_looks.gd:

extends GutTest
# Weapon FX v2 render side: the A/B switch, the quality fallback, and (Tasks 9-13) each batch-1 look.
# Drives the REAL FxManager.consume() with the real stamped event shapes the sim emits.
var _was_v2 := false
var _was_plasma := true
func before_each() -> void:
_was_v2 = VisualLook.weapon_fx_v2
_was_plasma = VisualLook.plasma_nova
func after_each() -> void:
VisualLook.weapon_fx_v2 = _was_v2
VisualLook.plasma_nova = _was_plasma
func _make() -> FxManager:
var fx := FxManager.new()
fx.setup(SimContentFixture.db())
add_child_autofree(fx)
return fx
func test_v2_active_needs_switch_and_quality() -> void:
var fx := _make()
VisualLook.weapon_fx_v2 = true
fx.v2_quality_ok = true
assert_true(fx.v2_active())
fx.v2_quality_ok = false
assert_false(fx.v2_active(), "L3+ falls back to legacy looks")
VisualLook.weapon_fx_v2 = false
fx.v2_quality_ok = true
assert_false(fx.v2_active(), "switch off = legacy")
func test_quality_tier_boundary() -> void:
assert_true(QualityManager.weapon_fx_v2_on(0))
assert_true(QualityManager.weapon_fx_v2_on(2))
assert_false(QualityManager.weapon_fx_v2_on(3))
func test_quality_manager_pushes_the_tier_to_fx() -> void:
var fx := _make()
var qm := QualityManager.new()
autofree(qm)
qm.bind(null, [], fx, null, null, null, null)
qm.auto = false
qm.set_level(3)
assert_false(fx.v2_quality_ok, "L3 -> legacy")
qm.set_level(1)
assert_true(fx.v2_quality_ok, "L1 -> v2")
func test_debug_row_flips_the_switch() -> void:
var qm := QualityManager.new()
autofree(qm)
VisualLook.weapon_fx_v2 = false
DebugSettingsPanel.apply_graphics_toggle("weapon_fx_v2", true, qm)
assert_true(VisualLook.weapon_fx_v2)
DebugSettingsPanel.apply_graphics_toggle("weapon_fx_v2", false, qm)
assert_false(VisualLook.weapon_fx_v2)

Before writing test_quality_manager_pushes_the_tier_to_fx, run /usr/bin/grep -n "func bind" -A4 render/quality_manager.gd and pass exactly as many nulls as bind has parameters after p_fx. The main.gd call at line 1284 has 7 arguments. If bind reads a member of a null argument, pass Node.new() placeholders via autofree() instead. Also check set_level’s real name and signature in the same file.

  • Step 2: Run to verify failures

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_looks -gexit (and -gselect=test_weapon_fx_guards). Expected: FAIL because weapon_fx_v2 / v2_active / V2_LOOKS / weapon_fx_v2_on don’t exist yet.

  • Step 3: Implement

render/visual_look.gd, appended after plasma_nova:

# Weapon FX v2 (docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md): per-weapon looks that grow
# with investment, transform on evolution and show the crystals that upgraded them. true = FxManager /
# main.gd draw the v2 looks for every wid in FxManager.V2_LOOKS (quality L0-L2 only; L3+ always falls
# back to legacy); false = the legacy looks exactly as before. Read every frame / at spawn time, so the
# Debug Settings "Weapon FX v2 (A/B)" row shows on the next shot. Default false until Chris signs off
# batch 1 (plan Task 16 flips it and folds plasma_nova into it).
static var weapon_fx_v2 := false

render/quality_manager.gd, after juice_on:

static func weapon_fx_v2_on(l: int) -> bool: return l <= 2 # per-weapon v2 looks; L3+ = legacy looks (cheaper)

In _apply(), inside if is_instance_valid(fx_layer): after the reaction_cap line:

fx_layer.v2_quality_ok = weapon_fx_v2_on(level) # weapon FX v2 at L0-L2 only

ui/debug_settings_panel.gd. Add a match arm after "plasma_nova"::

"weapon_fx_v2":
VisualLook.weapon_fx_v2 = enabled # Weapon FX v2 looks vs legacy; shows on the next shot

Add after line 251:

_graphics_state["weapon_fx_v2"] = VisualLook.weapon_fx_v2

Add after line 321:

_rows.append(_graphics_row("Weapon FX v2 (A/B)", "weapon_fx_v2"))

fx/fx_manager.gd. Add after const PLASMA_NOVA_SHADER_PATH:

# Weapon FX v2: wid -> the look class that draws it when v2_active(). A wid NOT listed here keeps its
# legacy look even with v2 on. tests/test_weapon_fx_guards.gd requires every weapon id to be here or
# in its PENDING_V2_LOOKS (with the batch that owns it).
const V2_LOOKS := {}

After var reaction_cap := REACTION_CAP:

var v2_quality_ok := true # QualityManager: weapon_fx_v2_on(level) -- false at L3+
var _v2_layers: Array[Node] = []
func v2_active() -> bool:
return VisualLook.weapon_fx_v2 and v2_quality_ok
func v2_layer_count() -> int:
return _v2_layers.size()
var _v2_by_wid := {} # wid -> the look layer drawing it (event looks FxManager owns)
func _add_v2_layer(layer: Node, wids: Array) -> void:
add_child(layer)
_v2_layers.append(layer)
for w in wids:
_v2_by_wid[w] = layer
func v2_look(wid: String): # untyped on purpose: callers invoke the look's spawn_event()/spawn_impact()
return _v2_by_wid.get(wid)

At the end of advance(dt):

# Weapon FX v2 event looks (persistent looks live in main.gd and sync themselves)
for layer in _v2_layers:
layer.advance(dt)

tests/test_fx_manager.gd:50. Append + fx.v2_layer_count() to the expected child count, before the message string:

assert_eq(fx.get_child_count(), FxManager.POOL_SIZE + FxManager.RING_POOL_SIZE + FxManager.BEAM_POOL_SIZE + FxManager.BOLT_POOL_SIZE + FxManager.SLASH_POOL_SIZE + FxManager.LABEL_POOL_SIZE + FxManager.NOVA_POOL_SIZE + fx.v2_layer_count(), "pool size constant (reuse, no leak)")
  • Step 4: Run to verify passes

Run: -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_fx_manager, -gselect=test_debug_settings, -gselect=test_quality_manager (use the commands block above). Expected: all pass.

  • Step 5: Commit — feat(fx-v2): weapon_fx_v2 switch, L0-L2 quality tier, Debug row, look registry guard

Task 5: Move Plasma Nova out of FxManager + extract the shared noise include

Section titled “Task 5: Move Plasma Nova out of FxManager + extract the shared noise include”

This is a pure relocation with no visual change, and it gives later tasks a home. fx/weapons/ becomes the directory for every weapon look.

Files:

  • Move: fx/plasma_nova.gdshader → fx/weapons/plasma_nova.gdshader (git mv; the .uid travels with it)
  • Create: fx/weapons/fx_noise.gdshaderinc
  • Create: fx/weapons/plasma_nova_node.gd (the _PlasmaNovaNode + _EmberNode code, moved)
  • Modify: fx/fx_manager.gd:17 (path), :91-189 (delete the two inner classes), :417-418, :469, :725, :758 (type names)
  • Modify: tests/test_plasma_nova.gd:4 (comment path only)

Interfaces:

  • Consumes: —

  • Produces:

    • class_name PlasmaNovaNode extends Node2D with LIFE, EXTENT, _init(mat, tex, glow), reset(pos, radius, c, rng), progress(), tick(dt) -> bool. It keeps exactly the old API, so FxManager.active_novas() callers still work.
    • res://fx/weapons/fx_noise.gdshaderinc defines hash21(vec2), vnoise(vec2), fbm(vec2) behind an include guard.
    • FxManager.PLASMA_NOVA_SHADER_PATH == "res://fx/weapons/plasma_nova.gdshader".
  • Step 1: Capture the before image

Terminal window
NOVA_OUT=/tmp/fxv2_before godot --path . res://tools/nova_vfx/compare.tscn --rendering-method mobile
git diff --stat project.godot; git checkout -- project.godot

Expected: /tmp/fxv2_before/nova_compare.png exists. Use the scratchpad path if you have one.

  • Step 2: Move and extract
Terminal window
mkdir -p fx/weapons
git mv fx/plasma_nova.gdshader fx/weapons/plasma_nova.gdshader
git mv fx/plasma_nova.gdshader.uid fx/weapons/plasma_nova.gdshader.uid

Create fx/weapons/fx_noise.gdshaderinc. The function bodies are byte-for-byte what plasma_nova.gdshader had:

// Shared value noise for weapon FX v2 shaders (extracted verbatim from plasma_nova.gdshader).
#ifndef FX_NOISE_INC
#define FX_NOISE_INC
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float amp = 0.5;
for (int k = 0; k < 3; k++) {
v += amp * vnoise(p);
p = p * 2.03 + vec2(11.7, 3.1);
amp *= 0.5;
}
return v; // ~0..0.875
}
#endif

In fx/weapons/plasma_nova.gdshader, delete the three functions (hash21 through the end of fbm) and put this in their place, after the void vertex() block:

#include "res://fx/weapons/fx_noise.gdshaderinc"

Change the header comment’s FxManager._PlasmaNovaNode to PlasmaNovaNode (fx/weapons/plasma_nova_node.gd).

Create fx/weapons/plasma_nova_node.gd. This is the two inner classes moved verbatim. Only the class names and the ember type change:

class_name PlasmaNovaNode
extends Node2D
# Plasma Nova burst (2026-09-27; moved out of FxManager for weapon FX v2, 2026-09-29 -- unchanged).
# One shader quad (fx/weapons/plasma_nova.gdshader) + a child of ember sparks. All bursts share ONE
# material; per-burst tint + progress ride the draw colour. This is the v1 (legacy) Nova look.
const LIFE := 0.46
const EXTENT := 1.22 # must match the shader's EXTENT
var life: float = 0.0
var max_radius: float = 100.0
var col: Color = Color.WHITE
var active: bool = false
var embers: Embers
var quad_tex: Texture2D
func _init(mat: ShaderMaterial, tex: Texture2D, glow: Texture2D) -> void:
material = mat
quad_tex = tex
visible = false
embers = Embers.new(glow)
add_child(embers)
func reset(pos: Vector2, radius: float, c: Color, rng: RandomNumberGenerator) -> void:
position = pos
rotation = rng.randf() * TAU # vary the noise orientation per burst
max_radius = radius
col = c
life = 0.0
active = true
visible = true
embers.reset(radius, rng)
queue_redraw()
func progress() -> float:
return clampf(life / LIFE, 0.0, 1.0)
func tick(dt: float) -> bool: # returns true when expired
life += dt
if life >= LIFE:
active = false
visible = false
return true
embers.set_progress(progress())
queue_redraw()
return false
func _draw() -> void:
if not active:
return
var half := max_radius * EXTENT
draw_texture_rect(quad_tex, Rect2(-half, -half, half * 2.0, half * 2.0), false,
Color(col.r, col.g, col.b, progress()))
# Short-lived additive ember sparks thrown off the Plasma Nova front. Positions are a closed-form
# function of progress (no per-ember state to integrate), drawn with the shared soft GlowTexture.
class Embers extends Node2D:
const COUNT := 14
var glow: Texture2D
var t: float = 0.0
var radius: float = 100.0
var angles := PackedFloat32Array()
var speeds := PackedFloat32Array()
var sizes := PackedFloat32Array()
func _init(g: Texture2D) -> void:
glow = g
var mat := CanvasItemMaterial.new()
mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
material = mat
angles.resize(COUNT)
speeds.resize(COUNT)
sizes.resize(COUNT)
func reset(r: float, rng: RandomNumberGenerator) -> void:
radius = r
t = 0.0
for i in range(COUNT):
angles[i] = (float(i) + rng.randf() * 0.8) / float(COUNT) * TAU
speeds[i] = rng.randf_range(0.85, 1.35)
sizes[i] = rng.randf_range(3.0, 6.5)
queue_redraw()
func set_progress(p: float) -> void:
t = p
queue_redraw()
func _draw() -> void:
var front := 1.0 - pow(1.0 - t, 2.4)
var a := 1.0 - t
# Hot yellow-white while fresh, cooling to orange then magenta as they fly out.
var hot := Color(1.0, 0.85, 0.45).lerp(Color(1.0, 0.35, 0.1), clampf(t * 1.6, 0.0, 1.0))
var c := hot.lerp(Color(0.95, 0.2, 0.7), clampf(t * 1.6 - 0.6, 0.0, 1.0))
for i in range(COUNT):
var rr := radius * front * speeds[i]
var p := Vector2(cos(angles[i]), sin(angles[i])) * rr
var s := sizes[i] * (1.0 - 0.5 * t)
draw_texture_rect(glow, Rect2(p - Vector2(s, s) * 2.0, Vector2(s, s) * 4.0), false,
Color(c.r * 1.6, c.g * 1.6, c.b * 1.6, a))
draw_texture_rect(glow, Rect2(p - Vector2(s, s) * 0.6, Vector2(s, s) * 1.2), false,
Color(1.6, 1.5, 1.3, a))

Before deleting the originals in fx/fx_manager.gd, run diff <(sed -n '94,189p' fx/fx_manager.gd | sed 's/^\t//') <(sed -n '9,99p' fx/weapons/plasma_nova_node.gd). The only differences allowed are the class-name lines, _EmberNode→Embers, and the comment header. Then:

  • delete the _PlasmaNovaNode and _EmberNode inner classes (lines 91–189, including their leading comment);

  • set const PLASMA_NOVA_SHADER_PATH := "res://fx/weapons/plasma_nova.gdshader";

  • change # Array of _PlasmaNovaNode comments, _PlasmaNovaNode.new( and both var ...: _PlasmaNovaNode annotations to PlasmaNovaNode.

  • Step 3: Import, run the nova suites, capture after

Terminal window
godot --headless --path . --import 2>&1 | grep -iE "error|include" | head
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_plasma_nova -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_fx_manager -gexit
NOVA_OUT=/tmp/fxv2_after godot --path . res://tools/nova_vfx/compare.tscn --rendering-method mobile
git diff --stat project.godot; git checkout -- project.godot

Expected: no import/shader errors, and both suites pass. Read both PNGs with the Read tool. The shader uses TIME, so the flame noise differs frame to frame. The fireball’s size, colour ramp, shell/core structure and ember count must match. A black or missing burst means the include failed to resolve.

  • Step 4: Commit — refactor(fx): move Plasma Nova to fx/weapons/ + shared fx_noise include (no visual change)

Task 6: WeaponFxLayer — the one-draw-call base for every v2 look

Section titled “Task 6: WeaponFxLayer — the one-draw-call base for every v2 look”

Files:

  • Create: fx/weapons/weapon_fx_layer.gd
  • Test: tests/test_weapon_fx_layer.gd

Interfaces:

  • Consumes: —

  • Produces: class_name WeaponFxLayer extends MultiMeshInstance2D with

    • const STRIDE := 16
    • var capacity: int
    • _init(shader: Shader, cap: int)
    • ensure_capacity(n: int) -> void (grows to the next power of two ≥ n; never shrinks)
    • persistent looks: begin(), put(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4) -> bool (false when full), commit()
    • event looks: spawn(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4, life_s: float) -> bool (false when full: bounded drop), advance(dt: float) (ages, expires, rebuilds, commits; COLOR.a = life progress 0..1), active_count() -> int, clear()
    • debug_buffer() -> PackedFloat32Array (tests only; the headless dummy renderer can’t read MultiMesh buffers back)
    • material is a ShaderMaterial shared by every instance.
  • Step 1: Write the failing test

tests/test_weapon_fx_layer.gd:

extends GutTest
# WeaponFxLayer: every v2 look is ONE MultiMesh draw call. Per instance: TRANSFORM_2D (rotation, scale =
# half-extent px on a 2x2 quad) + COLOR + INSTANCE_CUSTOM, packed stride 16 -- the same layout the
# production ZoneRenderer web layer uses: (x.x, y.x, 0, origin.x, x.y, y.y, 0, origin.y, rgba, custom).
func _shader() -> Shader:
var s := Shader.new()
s.code = "shader_type canvas_item;\nvoid fragment() { COLOR = vec4(1.0); }"
return s
func _layer(cap: int = 4) -> WeaponFxLayer:
var l := WeaponFxLayer.new(_shader(), cap)
add_child_autofree(l)
return l
func test_multimesh_is_set_up_for_colors_and_custom_data() -> void:
var l := _layer()
assert_eq(l.multimesh.transform_format, MultiMesh.TRANSFORM_2D)
assert_true(l.multimesh.use_colors)
assert_true(l.multimesh.use_custom_data)
assert_true(l.material is ShaderMaterial)
assert_eq((l.multimesh.mesh as QuadMesh).size, Vector2(2, 2), "unit quad: scale == half-extent px")
func test_put_packs_stride_16() -> void:
var l := _layer()
l.begin()
l.put(Vector2(10, 20), PI * 0.5, Vector2(3, 5), Color(0.1, 0.2, 0.3, 0.4), Vector4(1, 2, 3, 4))
l.commit()
var b := l.debug_buffer()
var want := [0.0, -5.0, 0.0, 10.0, 3.0, 0.0, 0.0, 20.0, 0.1, 0.2, 0.3, 0.4, 1.0, 2.0, 3.0, 4.0]
for k in range(16):
assert_almost_eq(b[k], want[k], 1e-5, "float %d" % k)
assert_eq(l.multimesh.visible_instance_count, 1)
assert_true(l.visible)
func test_empty_commit_hides_the_layer() -> void:
var l := _layer()
l.begin()
l.commit()
assert_false(l.visible, "nothing to draw -> no draw call")
func test_put_refuses_past_capacity() -> void:
var l := _layer(2)
l.begin()
assert_true(l.put(Vector2.ZERO, 0.0, Vector2.ONE, Color.WHITE, Vector4.ZERO))
assert_true(l.put(Vector2.ZERO, 0.0, Vector2.ONE, Color.WHITE, Vector4.ZERO))
assert_false(l.put(Vector2.ZERO, 0.0, Vector2.ONE, Color.WHITE, Vector4.ZERO))
l.commit()
assert_eq(l.multimesh.visible_instance_count, 2)
func test_ensure_capacity_grows_to_pow2() -> void:
var l := _layer(4)
l.ensure_capacity(37)
assert_eq(l.capacity, 64)
assert_eq(l.multimesh.instance_count, 64)
l.ensure_capacity(10)
assert_eq(l.capacity, 64, "never shrinks")
func test_spawn_is_bounded_and_expires() -> void:
var l := _layer(3)
for _i in range(5):
l.spawn(Vector2.ZERO, 0.0, Vector2.ONE * 10.0, Color.RED, Vector4.ZERO, 0.2)
assert_eq(l.active_count(), 3, "bounded: extra spawns drop")
l.advance(0.1)
assert_eq(l.active_count(), 3)
assert_almost_eq(l.debug_buffer()[11], 0.5, 1e-4, "COLOR.a = life progress")
l.advance(0.15)
assert_eq(l.active_count(), 0, "expired items leave")
assert_false(l.visible)
  • Step 2: Run to verify it fails

Run: godot --headless --path . --import then -gselect=test_weapon_fx_layer. Expected: WeaponFxLayer not declared.

  • Step 3: Implement fx/weapons/weapon_fx_layer.gd
class_name WeaponFxLayer
extends MultiMeshInstance2D
# Weapon FX v2 base: ONE look = ONE of these = ONE draw call, however many instances. Every instance is
# a unit 2x2 quad whose transform scale is the look's half-extent in px, so the shader's
# p = (UV - 0.5) * 2.0 spans [-1, 1] over the quad. Per instance the shader gets COLOR (element tint rgb,
# a = look-defined -- life progress for event looks) and INSTANCE_CUSTOM (4 look-defined float32s; see
# the layout table in docs/superpowers/plans/2026-09-29-weapon-fx-v2-foundation-batch1.md).
# Buffer layout (stride 16) matches the production ZoneRenderer web layer:
# (x.x, y.x, 0, origin.x, x.y, y.y, 0, origin.y, r, g, b, a, cx, cy, cz, cw)
# Tint is packed raw (like SwarmRenderer.set_instance_color) -- HDR values > 1 are fine.
const STRIDE := 16
var capacity: int = 0
var _buf := PackedFloat32Array()
var _n: int = 0
# Event looks: [pos, rot, half_size, tint, custom, life, max_life] per item (arrays, no per-item nodes).
var _items: Array = []
func _init(shader: Shader, cap: int) -> void:
var quad := QuadMesh.new()
quad.size = Vector2(2.0, 2.0)
var mm := MultiMesh.new()
# Formats must be set while instance_count == 0.
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
mm.use_custom_data = true
mm.mesh = quad
multimesh = mm
var mat := ShaderMaterial.new()
mat.shader = shader
material = mat
visible = false
ensure_capacity(maxi(cap, 1))
func ensure_capacity(n: int) -> void:
if n <= capacity:
return
var c := 1
while c < n:
c <<= 1
capacity = c
multimesh.instance_count = capacity
_buf.resize(capacity * STRIDE)
func begin() -> void:
_n = 0
func put(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4) -> bool:
if _n >= capacity:
return false
var c := cos(rot)
var s := sin(rot)
var o := _n * STRIDE
_buf[o] = c * half_size.x
_buf[o + 1] = -s * half_size.y
_buf[o + 2] = 0.0
_buf[o + 3] = pos.x
_buf[o + 4] = s * half_size.x
_buf[o + 5] = c * half_size.y
_buf[o + 6] = 0.0
_buf[o + 7] = pos.y
_buf[o + 8] = tint.r
_buf[o + 9] = tint.g
_buf[o + 10] = tint.b
_buf[o + 11] = tint.a
_buf[o + 12] = custom.x
_buf[o + 13] = custom.y
_buf[o + 14] = custom.z
_buf[o + 15] = custom.w
_n += 1
return true
func commit() -> void:
if _n == 0:
multimesh.visible_instance_count = 0
visible = false
return
multimesh.buffer = _buf
multimesh.visible_instance_count = _n
visible = true
func spawn(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4, life_s: float) -> bool:
if _items.size() >= capacity:
return false # bounded: drop, like every FxManager pool
_items.append([pos, rot, half_size, tint, custom, 0.0, maxf(life_s, 0.001)])
_rebuild()
return true
func advance(dt: float) -> void:
if _items.is_empty() and not visible:
return
var i := _items.size() - 1
while i >= 0:
var it: Array = _items[i]
it[5] = float(it[5]) + dt
if float(it[5]) >= float(it[6]):
_items.remove_at(i)
i -= 1
_rebuild()
func _rebuild() -> void:
begin()
for it in _items:
var tint: Color = it[3]
tint.a = clampf(float(it[5]) / float(it[6]), 0.0, 1.0)
put(it[0], it[1], it[2], tint, it[4])
commit()
func active_count() -> int:
return _items.size()
func clear() -> void:
_items.clear()
begin()
commit()
func debug_buffer() -> PackedFloat32Array:
return _buf
  • Step 4: Run to verify it passes

Run: godot --headless --path . --import then -gselect=test_weapon_fx_layer. Expected: all tests in the script pass.

  • Step 5: Commit — feat(fx-v2): WeaponFxLayer one-draw-call MultiMesh base

Task 7: Crystal accent motifs — shader include, GDScript twin, sync guard, motif grid

Section titled “Task 7: Crystal accent motifs — shader include, GDScript twin, sync guard, motif grid”

Files:

  • Create: fx/weapons/element_accents.gdshaderinc
  • Create: fx/weapons/element_accents.gd
  • Create: tools/weapon_vfx/harness.gd, tools/weapon_vfx/motifs.gd, tools/weapon_vfx/motifs.tscn, tools/weapon_vfx/motif_probe.gdshader
  • Modify: .gitignore (add tools/weapon_vfx/out/)
  • Test: tests/test_weapon_fx_guards.gd (guard 6), tests/test_weapon_vfx.gd (code packing)

Interfaces:

  • Consumes: WeaponThresholds.rules_for(wid), fx_noise.gdshaderinc (Task 5), WeaponFxLayer (Task 6).

  • Produces:

    • ElementAccents.IDS: Dictionary (element → 1..10), ElementAccents.BUILT: Dictionary (elements with a real motif), ElementAccents.EVOLVE_SHIFT := 20
    • static ElementAccents.code(wid: String, rungs_mask: int, evolved: bool) -> float
    • static ElementAccents.evolve_element(wid: String) -> String
    • GLSL: vec4 accent_motif(int id, vec2 p, float mask, float edge, float s, float t, float seed, float now) (rgb is added; .a multiplies the host colour, which is how void darkens under additive blending); vec4 accents_all(float code, vec2 p, float mask, float edge, float t, float seed, float now); int evolve_accent(float code). The generic shape inputs (mask, edge) are what batch 6 reuses on enemies.
    • WeaponVfxHarness (static helpers used by every harness): make_stage(host: Node, size: Vector2i) -> Node2D, dots(root, centre), label(root, pos, text), save(root, file_name) -> String
  • Step 1: Write the failing tests

Append to tests/test_weapon_fx_guards.gd:

# ── Guard 6: accent ids agree between the shader include and its GDScript twin ──
const BATCH1_ELEMENTS := ["kinetic", "cold", "lightning", "blood", "fire", "void", "light"]
func test_accent_ids_match_the_shader_include() -> void:
var src := _read("res://fx/weapons/element_accents.gdshaderinc")
var re := RegEx.new()
re.compile("const int ACC_([A-Z]+) = (\\d+);")
var shader_ids := {}
for m in re.search_all(src):
shader_ids[m.get_string(1).to_lower()] = int(m.get_string(2))
assert_eq(shader_ids, ElementAccents.IDS, "fx/weapons/element_accents.gdshaderinc ACC_* == ElementAccents.IDS")
for el in ElementAccents.BUILT:
assert_true(src.contains("if (id == ACC_%s)" % el.to_upper()), "BUILT element %s has a motif branch" % el)
func test_every_threshold_element_has_an_accent_id() -> void:
for wid in WeaponThresholds.TABLE:
for r in WeaponThresholds.rules_for(wid):
assert_true(ElementAccents.IDS.has(String(r["element"])), "%s rung element %s needs an accent id" % [wid, r["element"]])
func test_batch1_elements_have_real_motifs() -> void:
for el in BATCH1_ELEMENTS:
assert_true(ElementAccents.BUILT.has(el), el + " motif is built (batch 1)")

Append to tests/test_weapon_vfx.gd:

func test_accent_code_packs_levels_and_evolve_element() -> void:
# orbit rungs: cold, lightning, cold, void-evolve
assert_eq(ElementAccents.code("orbit", 0, false), 0.0)
var c := int(ElementAccents.code("orbit", 0b0111, false))
assert_eq((c >> (2 * (ElementAccents.IDS["cold"] - 1))) & 3, 2, "two cold rungs")
assert_eq((c >> (2 * (ElementAccents.IDS["lightning"] - 1))) & 3, 1)
assert_eq((c >> ElementAccents.EVOLVE_SHIFT) & 15, 0, "not evolved")
var ce := int(ElementAccents.code("orbit", 0b1111, true))
assert_eq((ce >> ElementAccents.EVOLVE_SHIFT) & 15, ElementAccents.IDS["void"], "Event Horizon takes void")
func test_level_up_evolve_without_the_rung_still_takes_the_evolve_element() -> void:
var c := int(ElementAccents.code("nova", 0, true))
assert_eq((c >> ElementAccents.EVOLVE_SHIFT) & 15, ElementAccents.IDS["light"], "Supernova = light even via the level-up path")
func test_accent_code_is_exact_in_float32() -> void:
var f := PackedFloat32Array([ElementAccents.code("orbit", 0b1111, true)])
assert_eq(int(f[0]), int(ElementAccents.code("orbit", 0b1111, true)), "< 2^24 so float32 carries it exactly")
  • Step 2: Run to verify failures

Run: -gselect=test_weapon_fx_guards and -gselect=test_weapon_vfx. Expected: ElementAccents is not declared, and the include file is missing.

  • Step 3: Implement the GDScript twin fx/weapons/element_accents.gd
class_name ElementAccents
extends RefCounted
# Weapon FX v2 crystal accents (spec § Crystal accents): a weapon's look carries a motif for each
# element that upgraded it, and its evolved form takes the evolve rung's element. This is the GDScript
# twin of fx/weapons/element_accents.gdshaderinc -- the ids MUST match (tests/test_weapon_fx_guards.gd
# parses the include). Packing (a float, exact < 2^24): 2 bits of level (0..3) per id at bits
# 2*(id-1) (ids 1..10 -> bits 0..19); the evolve element's id at bits 20..23.
const IDS := {
"fire": 1, "cold": 2, "lightning": 3, "blood": 4, "void": 5,
"light": 6, "kinetic": 7, "poison": 8, "aether": 9, "time": 10,
}
# Elements with a real motif. The rest return a no-op until the batch that needs them.
const BUILT := {"fire": true, "cold": true, "lightning": true, "blood": true, "void": true, "light": true, "kinetic": true}
const EVOLVE_SHIFT := 20
static func evolve_element(wid: String) -> String:
for r in WeaponThresholds.rules_for(wid):
if String(r["kind"]) == "evolve":
return String(r["element"])
return ""
static func code(wid: String, rungs_mask: int, evolved: bool) -> float:
var levels := {}
var rules := WeaponThresholds.rules_for(wid)
for i in range(rules.size()):
var r: Dictionary = rules[i]
if String(r["kind"]) == "evolve":
continue
if (rungs_mask & (1 << i)) == 0:
continue
var id: int = IDS.get(String(r["element"]), 0)
if id == 0:
continue
levels[id] = mini(int(levels.get(id, 0)) + 1, 3)
var c := 0
for id in levels:
c |= int(levels[id]) << (2 * (int(id) - 1))
# Evolved via the crystal rung OR the level-up evolve path: either way the form takes the
# TABLE's evolve element (spec: "the evolved form takes the evolve rung's element").
if evolved:
var ev_id: int = IDS.get(evolve_element(wid), 0)
c |= ev_id << EVOLVE_SHIFT
return float(c)
  • Step 4: Implement the shader include fx/weapons/element_accents.gdshaderinc
fx/weapons/element_accents.gd
// -- ACC_* ids MUST equal ElementAccents.IDS (tests/test_weapon_fx_guards.gd parses this file).
// Written against a GENERIC shape so batch 6 can put the same motifs on enemies:
// p look-local coords (~[-1,1]); mask 0..1 = host shape coverage; edge 0..1 = closeness to the
// host's leading edge / rim; s = strength 0..1; t = host life progress; seed per instance; now = TIME.
// Returns rgb to ADD to the host and .a to MULTIPLY the host colour by (void < 1 bites it dark --
// additive blending can't draw black, so darkening = dimming the host's own glow).
#ifndef ELEMENT_ACCENTS_INC
#define ELEMENT_ACCENTS_INC
#include "res://fx/weapons/fx_noise.gdshaderinc"
const int ACC_FIRE = 1;
const int ACC_COLD = 2;
const int ACC_LIGHTNING = 3;
const int ACC_BLOOD = 4;
const int ACC_VOID = 5;
const int ACC_LIGHT = 6;
const int ACC_KINETIC = 7;
const int ACC_POISON = 8;
const int ACC_AETHER = 9;
const int ACC_TIME = 10;
vec4 accent_motif(int id, vec2 p, float mask, float edge, float s, float t, float seed, float now) {
if (s <= 0.0 || mask <= 0.0) {
return vec4(0.0, 0.0, 0.0, 1.0);
}
float tt = now + seed * 17.0;
if (id == ACC_FIRE) {
// embers rising off the edge
float n = fbm(p * 6.0 + vec2(0.0, -tt * 3.0));
float e = smoothstep(0.52, 0.78, n) * (0.35 + 0.65 * edge);
return vec4(vec3(1.0, 0.45, 0.08) * e * 2.2 * s * mask, 1.0);
}
if (id == ACC_COLD) {
// frost rime: crystalline facets along the rim, pale blue
vec2 q = p * 9.0;
float f = abs(fract(q.x + q.y * 0.5) - 0.5) + abs(fract(q.y - q.x * 0.5) - 0.5);
float rime = smoothstep(0.25, 0.05, f) * (0.3 + 0.7 * edge);
return vec4(vec3(0.55, 0.85, 1.0) * rime * 1.6 * s * mask, 1.0);
}
if (id == ACC_LIGHTNING) {
// crackle: thin filaments that re-strike ~14x/s, only on some strikes
float strike = floor(tt * 14.0);
float n = vnoise(p * 7.0 + vec2(strike * 3.1, seed * 5.0));
float arc = smoothstep(0.035, 0.0, abs(n - 0.5)) * step(0.35, hash21(vec2(strike, seed)));
return vec4(vec3(0.75, 0.85, 1.0) * arc * 2.6 * s * mask, 1.0);
}
if (id == ACC_BLOOD) {
// droplets: dark-red beads trailing the edge
vec2 cell = floor(p * 8.0);
vec2 f = fract(p * 8.0) - 0.5;
float r = hash21(cell + seed);
float drop = smoothstep(0.22, 0.12, length(f + vec2(0.0, (r - 0.5) * 0.4))) * step(0.7, r);
return vec4(vec3(0.9, 0.05, 0.12) * drop * 1.4 * s * mask * (0.4 + 0.6 * edge), 1.0);
}
if (id == ACC_VOID) {
// void bite: drifting dark holes eat the body, a thin violet rim around each hole
float n = fbm(p * 5.0 + vec2(tt * 0.6, -tt * 0.4));
float hole = smoothstep(0.5, 0.62, n) * (1.0 - edge * 0.5);
float rim = smoothstep(0.46, 0.5, n) * (1.0 - smoothstep(0.5, 0.54, n));
return vec4(vec3(0.55, 0.2, 1.0) * rim * 1.5 * s * mask, 1.0 - 0.85 * hole * s * mask);
}
if (id == ACC_LIGHT) {
// radiance: slow god-rays + a white bloom on the edge
float a = atan(p.y, p.x);
float rays = pow(0.5 + 0.5 * cos(a * 12.0 + tt * 1.5), 8.0);
return vec4(vec3(1.0, 0.97, 0.85) * (rays * 0.8 + edge * 0.6) * 1.5 * s * mask, 1.0);
}
if (id == ACC_KINETIC) {
// shock chevrons: bright bands sliding along +x (the travel / swing axis)
float band = pow(0.5 + 0.5 * cos(p.x * 10.0 - abs(p.y) * 6.0 - tt * 22.0), 10.0);
return vec4(vec3(1.0, 0.95, 0.8) * band * 1.2 * s * mask * (0.4 + 0.6 * edge), 1.0);
}
// ACC_POISON / ACC_AETHER / ACC_TIME: built with the batches that use them (ElementAccents.BUILT).
return vec4(0.0, 0.0, 0.0, 1.0);
}
float acc_strength(int lvl) {
return lvl == 1 ? 0.5 : (lvl == 2 ? 0.75 : 0.9);
}
vec4 accents_all(float code, vec2 p, float mask, float edge, float t, float seed, float now) {
int c = int(code + 0.5);
vec3 add = vec3(0.0);
float dim = 1.0;
for (int id = 1; id <= 10; id++) {
int lvl = (c >> (2 * (id - 1))) & 3;
if (lvl > 0) {
vec4 m = accent_motif(id, p, mask, edge, acc_strength(lvl), t, seed + float(id), now);
add += m.rgb;
dim *= m.a;
}
}
return vec4(add, dim);
}
int evolve_accent(float code) {
return (int(code + 0.5) >> 20) & 15;
}
#endif
  • Step 5: Shared harness helpers tools/weapon_vfx/harness.gd
class_name WeaponVfxHarness
extends RefCounted
# Shared bits for the weapon FX v2 harnesses (bh-vfx-iteration tier 1). Every harness renders into a
# fixed-size HDR SubViewport under a copy of main.gd's Metal WorldEnvironment -- NEVER judge a glow
# look without it. Run harnesses WINDOWED (headless can't read back shader/glow output), then
# `git checkout -- project.godot`.
static func make_stage(host: Node, size: Vector2i) -> Node2D:
var vp := SubViewport.new()
vp.size = size
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
vp.use_hdr_2d = true
host.add_child(vp)
var root := Node2D.new()
vp.add_child(root)
# Mirrors main.gd's HDR branch (grep `glow_hdr_threshold` in main.gd if these drift).
RenderingServer.set_default_clear_color(Color(0.01, 0.012, 0.02))
var env := Environment.new()
env.background_mode = Environment.BG_CANVAS
env.glow_enabled = true
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_ADDITIVE
env.glow_intensity = 1.15
env.glow_strength = 1.0
env.glow_bloom = 0.05
env.glow_hdr_threshold = 1.0
env.glow_hdr_scale = 1.5
env.adjustment_enabled = true
env.adjustment_brightness = 1.0
env.adjustment_contrast = 1.08
env.adjustment_saturation = 1.14
var we := WorldEnvironment.new()
we.environment = env
root.add_child(we)
return root
# A few neon enemy-sized dots so a look is judged against something, not a void.
static func dots(root: Node2D, centre: Vector2, spread: float = 60.0) -> void:
var cols := [Color(0.3, 0.9, 1.0), Color(1.0, 0.3, 0.8), Color(0.5, 1.0, 0.4)]
for i in range(9):
var s := Sprite2D.new()
s.texture = GlowTexture.shared()
var mat := CanvasItemMaterial.new()
mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
s.material = mat
var ang := float(i) * 2.39
s.position = centre + Vector2(cos(ang), sin(ang)) * (spread + 15.0 * float(i))
s.scale = Vector2(0.45, 0.45)
s.modulate = cols[i % 3] * 1.4
root.add_child(s)
static func label(root: Node2D, pos: Vector2, text: String) -> void:
var l := Label.new()
l.text = text
l.position = pos
l.add_theme_font_size_override("font_size", 16)
root.add_child(l)
# Waits for the glow to settle, saves $WEAPON_VFX_OUT/<file_name> (default tools/weapon_vfx/out/).
static func save(root: Node2D, file_name: String) -> String:
for _i in range(6):
await RenderingServer.frame_post_draw
var img := root.get_viewport().get_texture().get_image()
var dir := OS.get_environment("WEAPON_VFX_OUT")
if dir == "":
dir = ProjectSettings.globalize_path("res://tools/weapon_vfx/out")
DirAccess.make_dir_recursive_absolute(dir)
var path := "%s/%s" % [dir, file_name]
img.save_png(path)
print("[weapon_vfx] saved %s" % path)
return path
  • Step 6: Motif grid tools/weapon_vfx/motif_probe.gdshader, motifs.gd, motifs.tscn

tools/weapon_vfx/motif_probe.gdshader. This is a neutral host (a soft ring) that shows one motif per instance:

shader_type canvas_item;
render_mode blend_add, unshaded;
#include "res://fx/weapons/element_accents.gdshaderinc"
// Motif grid probe: a dim neutral ring host + ONE accent motif. custom = (id, strength, seed, 0).
varying vec4 v_cus;
void vertex() { v_cus = INSTANCE_CUSTOM; }
void fragment() {
vec2 p = (UV - 0.5) * 2.0;
float d = length(p);
float ring = exp(-pow((d - 0.7) / 0.16, 2.0));
float edge = exp(-pow((d - 0.82) / 0.05, 2.0));
vec3 host = vec3(0.35, 0.6, 1.0) * ring * 0.8;
vec4 m = accent_motif(int(v_cus.x + 0.5), p, ring, edge, v_cus.y, 0.5, v_cus.z, TIME);
COLOR = vec4(host * m.a + m.rgb, 1.0);
}

tools/weapon_vfx/motifs.gd:

extends Node
# Weapon FX v2 motif grid: columns = the 7 batch-1 accent motifs, rows = rung strength (1/2/3 rungs of
# that element). Judge each motif reads as its element at a glance, and that 1 rung is subtle but
# visible. Output: $WEAPON_VFX_OUT/motifs.png (default tools/weapon_vfx/out/)
# godot --path . res://tools/weapon_vfx/motifs.tscn --rendering-method mobile ; git checkout -- project.godot
const CELL := 220
const ORDER := ["kinetic", "cold", "lightning", "blood", "fire", "void", "light"]
const STRENGTHS := [0.5, 0.75, 0.9]
func _ready() -> void:
var root := WeaponVfxHarness.make_stage(self, Vector2i(CELL * ORDER.size(), CELL * STRENGTHS.size() + 30))
var layer := WeaponFxLayer.new(load("res://tools/weapon_vfx/motif_probe.gdshader") as Shader, 32)
root.add_child(layer)
layer.begin()
for col in range(ORDER.size()):
WeaponVfxHarness.label(root, Vector2(CELL * col + 8, 4), ORDER[col])
for row in range(STRENGTHS.size()):
var c := Vector2(CELL * col + CELL * 0.5, 30 + CELL * row + CELL * 0.5)
layer.put(c, 0.0, Vector2.ONE * CELL * 0.45, Color.WHITE,
Vector4(ElementAccents.IDS[ORDER[col]], STRENGTHS[row], float(col * 3 + row), 0.0))
layer.commit()
await WeaponVfxHarness.save(root, "motifs.png")
get_tree().quit()

tools/weapon_vfx/motifs.tscn:

[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/weapon_vfx/motifs.gd" id="1_mot"]
[node name="WeaponVfxMotifs" type="Node"]
script = ExtResource("1_mot")

Append to .gitignore:

# weapon-fx-v2 harness render output (regenerable)
tools/weapon_vfx/out/
  • Step 7: Run tests + render the grid
Terminal window
godot --headless --path . --import 2>&1 | grep -iE "error" | head
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_guards -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexit
godot --path . res://tools/weapon_vfx/motifs.tscn --rendering-method mobile
git diff --stat project.godot; git checkout -- project.godot

Expected: the tests pass and tools/weapon_vfx/out/motifs.png exists. Read it with the Read tool. All 7 columns should show a distinct motif over the ring, and the void column should visibly dim the ring. If a column is empty, check the Godot output for shader compile errors. This is the first shader to use int bit ops and nested includes, so errors surface here first. Don’t tune looks yet: Chris reviews the motifs together with the weapon grids in Task 16.

  • Step 8: Commit — feat(fx-v2): crystal accent motifs (7 batch-1 elements) + GDScript twin + motif grid

Task 8: Weapon comparison + in-game harnesses

Section titled “Task 8: Weapon comparison + in-game harnesses”

Files:

  • Create: tools/weapon_vfx/compare.gd, tools/weapon_vfx/compare.tscn, tools/weapon_vfx/ingame.gd, tools/weapon_vfx/ingame.tscn

Interfaces:

  • Consumes: WeaponVfxHarness (Task 7), ElementAccents.code, WeaponThresholds.rules_for, FxManager.consume / advance / v2_quality_ok, VisualLook.weapon_fx_v2, main.gd’s _on_mode_chosen, apply_dev_command, quality_manager, fx_layer, camera.

  • Produces: WEAPON=<wid> godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobile writes compare_<wid>.png. Rows: v1, p0 (power 0), one row per crystal rung in TABLE order (cumulative), evolved. Columns are frames through the effect’s life. WEAPON=<wid> V2=0|1 EVOLVE=0|1 LEVELS=<n> with ingame.tscn writes ingame_<wid>_<v1|v2>.png. Looks from Tasks 9–13 plug into the _cell() dispatch below. Until a look exists, its v2 rows render the legacy path, which is expected.

  • Step 1: Write tools/weapon_vfx/compare.gd

extends Node
# Weapon FX v2 comparison grid (bh-vfx-iteration tier 1). One PNG per weapon:
# rows = v1 (legacy) | p0 (power 0) | each crystal rung reached, in TABLE order (cumulative) | evolved
# columns = frames through the effect's life (event looks) or orbit phases / bullet ages (persistent)
# Everything goes through the REAL render classes with the real stamped event shapes. Chris eyeballs
# every grid before merge (spec § Verification).
# WEAPON=blade godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobile
# git checkout -- project.godot # output: $WEAPON_VFX_OUT/compare_<wid>.png
const CELL := 400
const TIMES := [0.04, 0.10, 0.18, 0.30] # seconds into an event look
const REACH := {"blade": 150.0, "scythe": 170.0, "nova": 180.0, "orbit": 120.0, "blaster": 0.0}
var _content: ContentDB
var _wid := "blade"
func _ready() -> void:
_wid = OS.get_environment("WEAPON") if OS.get_environment("WEAPON") != "" else "blade"
_content = ContentLoader.load_from_path("res://data/bible.json")
var rows := _rows()
var root := WeaponVfxHarness.make_stage(self, Vector2i(CELL * TIMES.size() + 120, CELL * rows.size()))
var was := VisualLook.weapon_fx_v2
for r in range(rows.size()):
var row: Dictionary = rows[r]
WeaponVfxHarness.label(root, Vector2(6, CELL * r + 6), row["name"])
for c in range(TIMES.size()):
var centre := Vector2(120 + CELL * c + CELL * 0.5, CELL * r + CELL * 0.5)
WeaponVfxHarness.dots(root, centre)
VisualLook.weapon_fx_v2 = row["v2"]
_cell(root, centre, row, c)
VisualLook.weapon_fx_v2 = was
await WeaponVfxHarness.save(root, "compare_%s.png" % _wid)
get_tree().quit()
# Rows: v1, p0, rung 1..k (cumulative mask, invested = rungs reached), evolved (all rungs + 3 picks).
func _rows() -> Array:
var out := [{"name": "v1", "v2": false, "power": 0.0, "rungs": 0, "evolved": false},
{"name": "p0", "v2": true, "power": 0.0, "rungs": 0, "evolved": false}]
var rules := WeaponThresholds.rules_for(_wid)
var mask := 0
var n := 0
for i in range(rules.size()):
if String(rules[i]["kind"]) == "evolve":
continue
mask |= 1 << i
n += 1
out.append({"name": "+%s" % rules[i]["element"], "v2": true,
"power": 1.0 - pow(WeaponVfx.POWER_BASE, float(n)), "rungs": mask, "evolved": false})
var all_mask := (1 << rules.size()) - 1
out.append({"name": "EVOLVED", "v2": true,
"power": 1.0 - pow(WeaponVfx.POWER_BASE, float(rules.size() + 3)), "rungs": all_mask, "evolved": true})
return out
func _element() -> int:
return _content.element_index(String(_content.weapon(_wid).get("element", "")))
func _stamp(ev: Dictionary, row: Dictionary) -> Dictionary:
ev["wid"] = _wid
ev["power"] = row["power"]
ev["evolved"] = row["evolved"]
ev["rungs"] = row["rungs"]
return ev
func _fx(root: Node2D) -> FxManager:
var fx := FxManager.new()
fx.setup(_content)
root.add_child(fx)
fx.v2_quality_ok = true
return fx
func _advance(fx: FxManager, t: float) -> void:
for _i in range(int(round(t * 60.0))):
fx.advance(1.0 / 60.0)
fx.set_process(false)
func _cell(root: Node2D, centre: Vector2, row: Dictionary, col: int) -> void:
var t: float = TIMES[col]
match _wid:
"blade", "scythe":
var fx := _fx(root)
var arc := 360.0 if row["evolved"] else 120.0
fx.consume([_stamp({"kind": "slash", "pos": centre, "dir": Vector2.RIGHT, "range": REACH[_wid],
"arc": arc, "element": _element()}, row)])
_advance(fx, t)
"nova":
var fx := _fx(root)
fx.consume([_stamp({"kind": "nova", "pos": centre, "radius": REACH["nova"], "element": _element()}, row)])
_advance(fx, t)
"orbit":
_orbit_cell(root, centre, row, col)
"blaster":
_blaster_cell(root, centre, row, col)
# Persistent looks: columns vary the orbit phase / bullet age instead of event time.
func _orbit_cell(root: Node2D, centre: Vector2, row: Dictionary, col: int) -> void:
var n := 6 if row["evolved"] else 3 + int(row["rungs"] & 1)
var phase := float(col) * 0.35
var positions: Array[Vector2] = []
for k in range(n):
var a := phase + float(k) * TAU / float(n)
positions.append(centre + Vector2(cos(a), sin(a)) * REACH["orbit"])
if not row["v2"]:
for p in positions:
var sh := Sprite2D.new()
sh.texture = GlowTexture.shared()
var m := CanvasItemMaterial.new()
m.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
sh.material = m
sh.modulate = ElementPalette.color_for(_content, _element())
sh.scale = Vector2(0.6, 0.6)
sh.position = p
root.add_child(sh)
return
var look := OrbitShardsLook.new()
root.add_child(look)
look.sync_entries([{"center": centre, "positions": positions, "radius": REACH["orbit"],
"spin_norm": 0.5, "tint": ElementPalette.color_for(_content, _element()),
"power": row["power"], "evolved": row["evolved"],
"code": ElementAccents.code("orbit", row["rungs"], row["evolved"])}])
func _blaster_cell(root: Node2D, centre: Vector2, row: Dictionary, col: int) -> void:
var pool := ProjPool.new(8)
var vfx: float = row["power"] + (1.0 if row["evolved"] else 0.0)
for k in range(3):
pool.add_proj(centre + Vector2(-90.0 + 70.0 * k, -40.0), Vector2(600, 0), 4.0, 0.2 + 0.25 * col, 10.0,
_element(), 0, 0, 0.0, 0.0, "blaster", vfx, row["rungs"])
var cols := PackedColorArray()
for _k in range(pool.count):
cols.append(ElementPalette.color_for(_content, _element()))
if not row["v2"]:
var sr := SwarmRenderer.new()
sr.configure(6.0, Color.WHITE)
sr.set_bullet_shape(18.0, 2.5 * SwarmRenderer.NOVA_BULLET_WIDTH_MULT)
root.add_child(sr)
sr.sync(pool, cols)
return
var br := PlayerBoltRenderer.new()
root.add_child(br)
br.sync_bolts(pool, cols, Rect2(-1e6, -1e6, 2e6, 2e6))
# muzzle flash (col 0) and Auto-Cannon impact sparks (evolved, later columns) in the same cell
var fx := _fx(root)
fx.enabled = true
var evs := [_stamp({"kind": "blaster_fire", "pos": centre + Vector2(-140, 40), "dir": Vector2.RIGHT, "element": _element()}, row)]
if row["evolved"]:
evs.append(_stamp({"kind": "bolt_impact", "pos": centre + Vector2(120, 40), "dir": Vector2.RIGHT, "element": _element()}, row))
fx.consume(evs)
_advance(fx, 0.02 + 0.03 * col)

OrbitShardsLook and PlayerBoltRenderer don’t exist until Tasks 12–13, so the script won’t parse until then. To keep the harness runnable for Tasks 9–11, create compare.gd in this task with the "orbit" and "blaster" arms of _cell() commented out. Add _orbit_cell and _blaster_cell in Tasks 12 and 13 respectively. The final text is exactly as above.

tools/weapon_vfx/compare.tscn:

[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/weapon_vfx/compare.gd" id="1_cmp"]
[node name="WeaponVfxCompare" type="Node"]
script = ExtResource("1_cmp")
  • Step 2: Write tools/weapon_vfx/ingame.gd
extends Node
# Weapon FX v2 in-game capture (bh-vfx-iteration tier 3): the REAL main.tscn, a busy survival horde,
# the chosen weapon forced active with the requested investment, captured after it has fired a while.
# WEAPON=blade V2=1 EVOLVE=0 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
# git checkout -- project.godot # output: $WEAPON_VFX_OUT/ingame_<wid>_<v1|v2><_evo>.png
const SIZE := Vector2i(1920, 1080)
var vp: SubViewport
var main
func _ready() -> void:
var wid := OS.get_environment("WEAPON") if OS.get_environment("WEAPON") != "" else "blade"
VisualLook.weapon_fx_v2 = OS.get_environment("V2") != "0"
var evolve := OS.get_environment("EVOLVE") == "1"
var levels := int(OS.get_environment("LEVELS")) if OS.get_environment("LEVELS") != "" else 3
vp = SubViewport.new()
vp.size = SIZE
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
vp.handle_input_locally = false
add_child(vp)
main = preload("res://main.tscn").instantiate()
vp.add_child(main)
await get_tree().process_frame
await get_tree().process_frame
main._on_mode_chosen("survival")
var s = main.sim
s.player.dev_invuln = true
s.dev_suppress_spawns = true
main.quality_manager.auto = false
main.quality_manager.set_level(0)
for k in main.codex_db.TYPE_KEYS:
if k != "":
main.meta.mark_seen(k)
var ars: PilotArsenal = s.player.arsenal
if not ars.active_weapon_ids.has(wid):
ars.active_weapon_ids.append(wid)
ars.weapon_levels[wid] = levels
var rules := WeaponThresholds.rules_for(wid)
for i in range(rules.size()):
if String(rules[i]["kind"]) != "evolve" or evolve:
ars.thresholds_done["%s:%d" % [wid, i]] = true
if evolve:
WeaponVfx.weapon(ars, wid).call("evolve")
main.apply_dev_command({"kind": "clear"})
for spec in [["swarmer", 70], ["tank", 8], ["shooter", 10]]:
main.apply_dev_command({"kind": "spawn_preset", "type": spec[0], "count": spec[1], "placement": "random"})
main.camera.zoom = Vector2(1.0, 1.0)
for _i in range(150):
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := vp.get_texture().get_image()
var dir := OS.get_environment("WEAPON_VFX_OUT")
if dir == "":
dir = ProjectSettings.globalize_path("res://tools/weapon_vfx/out")
DirAccess.make_dir_recursive_absolute(dir)
var path := "%s/ingame_%s_%s%s.png" % [dir, wid, "v2" if VisualLook.weapon_fx_v2 else "v1", "_evo" if evolve else ""]
img.save_png(path)
print("[weapon_vfx] saved %s" % path)
get_tree().quit()

This writes thresholds_done directly and skips the rung’s mechanical apply_mod. That is intentional: the harness shows the look for a given investment, and gameplay numbers are not under test. The rungs’ apply_mod would change reach/arc, which the evolve call already covers for the transform.

tools/weapon_vfx/ingame.tscn: the same 4-line scene shape as compare.tscn, with path="res://tools/weapon_vfx/ingame.gd", id="1_ing" and node name="WeaponVfxIngame".

  • Step 3: Smoke-run both on today’s (legacy) looks
Terminal window
godot --headless --path . --import
WEAPON=nova godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobile
WEAPON=blade V2=0 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
git diff --stat project.godot; git checkout -- project.godot

Expected: compare_nova.png has 5 rows (v1, p0, +fire, +fire, EVOLVED) × 4 columns. All rows currently show the Plasma Nova, because no v2 look exists yet. ingame_blade_v1.png shows the horde with the blade active. Read both PNGs.

  • Step 4: Commit — feat(fx-v2): weapon compare grid + in-game capture harnesses

Task 9: Blade look (fixes bug 1) + the FxManager v2 dispatch path

Section titled “Task 9: Blade look (fixes bug 1) + the FxManager v2 dispatch path”

Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).

Files:

  • Create: fx/weapons/blade_slash_look.gd, fx/weapons/blade_slash.gdshader
  • Modify: fx/fx_manager.gd: V2_LOOKS, _ready (register), consume "slash" arm (lines 522–528), _SlashNode (lines 299–345) + _spawn_slash (lines 685–690) for the legacy arc fix, advance (tick the v2 layers), a new active_slashes() accessor
  • Modify: tests/test_weapon_fx_guards.gd (delete "blade" from PENDING_V2_LOOKS)
  • Test: tests/test_weapon_fx_looks.gd

Interfaces:

  • Consumes: WeaponFxLayer (Task 6) incl. accent_slot(code) -> float / accent_reset() (Task 7 fix 804fa4e), ElementAccents.code (Task 7), the stamped slash event (Task 3), FxManager._add_v2_layer(layer, wids), v2_look(wid), v2_active() (Task 4).

  • Produces:

    • class_name BladeSlashLook extends WeaponFxLayer, with const EXTENT := 1.15, const CAP := 12, spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool. Every v2 event look implements this same spawn_event signature.
    • FxManager.active_slashes() -> Array
    • Legacy _SlashNode.reset(pos, dir, r, c, span_rad) now uses the event’s arc.
  • Step 1: Write the failing tests

Append to tests/test_weapon_fx_looks.gd:

func _slash_ev(wid: String, arc: float, evolved: bool = false, power: float = 0.5, rungs: int = 0) -> Dictionary:
return {"kind": "slash", "pos": Vector2(10, 0), "dir": Vector2.RIGHT, "range": 150.0, "arc": arc,
"element": 0, "wid": wid, "power": power, "evolved": evolved, "rungs": rungs}
func test_every_v2_look_is_registered_with_its_class() -> void:
var fx := _make()
var main_src := FileAccess.get_file_as_string("res://main.gd")
for wid in FxManager.V2_LOOKS:
var cls: String = FxManager.V2_LOOKS[wid]
if cls.begins_with("main:"):
assert_true(main_src.contains(cls.trim_prefix("main:")), "%s's look %s is wired in main.gd" % [wid, cls])
else:
var look = fx.v2_look(wid)
assert_not_null(look, wid + " look registered in FxManager")
assert_eq(look.get_script().get_global_name(), cls)
func test_blade_v2_draws_the_real_arc_bug1() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.consume([_slash_ev("blade", 360.0, true, 0.5, 0b011)])
var look: WeaponFxLayer = fx.v2_look("blade")
assert_eq(look.active_count(), 1)
assert_eq(fx.slash_active_count(), 0, "v2 on -> no legacy crescent")
var b := look.debug_buffer()
assert_almost_eq(b[12], 2.5, 1e-5, "custom.x = power + 2*evolved")
assert_eq(b[13], 1.0, "custom.y = accent SLOT (first code on a fresh layer -> slot 1), never the raw code")
assert_eq(look.accent_codes()[int(b[13])], ElementAccents.code("blade", 0b011, true), "the slot resolves to the code")
assert_almost_eq(b[14], TAU, 1e-4, "custom.z = the event's REAL arc -- 360 deg draws the full ring (bug 1)")
func test_blade_look_quad_matches_the_real_reach() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.consume([_slash_ev("blade", 120.0)])
var b: PackedFloat32Array = fx.v2_look("blade").debug_buffer()
assert_almost_eq(Vector2(b[0], b[4]).length(), 150.0 * BladeSlashLook.EXTENT, 1e-3, "half-extent = reach * EXTENT (d == 1 is the hit edge)")
func test_legacy_crescent_honours_arc_too() -> void:
VisualLook.weapon_fx_v2 = false
var fx := _make()
fx.consume([_slash_ev("blade", 360.0)])
assert_eq(fx.slash_active_count(), 1)
assert_almost_eq(float(fx.active_slashes()[0].span), TAU, 1e-4, "the L3+ fallback no longer draws a ~97 deg crescent")
func test_quality_fallback_uses_the_legacy_crescent() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.v2_quality_ok = false
fx.consume([_slash_ev("blade", 120.0)])
assert_eq(fx.slash_active_count(), 1)
assert_eq(fx.v2_look("blade").active_count(), 0)
func test_blade_look_is_bounded_and_expires() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
var many: Array = []
for _i in range(40):
many.append(_slash_ev("blade", 120.0))
fx.consume(many)
assert_lte(fx.v2_look("blade").active_count(), BladeSlashLook.CAP)
for _i in range(60):
fx.advance(1.0 / 60.0)
assert_eq(fx.v2_look("blade").active_count(), 0, "no leak")
func test_slash_is_weapon_feedback_even_at_l5() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.enabled = false
fx.consume([_slash_ev("blade", 120.0)])
assert_eq(fx.v2_look("blade").active_count(), 1)
  • Step 2: Run to verify failures

Run: -gselect=test_weapon_fx_looks. Expected: v2_look / BladeSlashLook not found.

  • Step 3: Write fx/weapons/blade_slash_look.gd
class_name BladeSlashLook
extends WeaponFxLayer
# Blade v2 (spec § Batch 1): a shader crescent swept across the event's REAL arc (bug 1: the legacy
# node hardcoded ~97 deg) with a white-hot leading edge and an element-coloured smear. Power: thicker,
# longer-lived smear, an afterimage crescent, more sparks (<= 1.5x). Evolved (Tempest): a full-360
# whirl of spinning blade echoes + wind streaks, carrying the evolve rung's element (void).
const SHADER_PATH := "res://fx/weapons/blade_slash.gdshader"
const CAP := 12
const EXTENT := 1.15 # quad half-size in reach units -- keep in sync with the shader's EXTENT
const LIFE := 0.20
const LIFE_GROW := 0.35 # the smear lingers up to 35% longer at power 1
const LIFE_EVOLVED := 0.34
func _init() -> void:
super(load(SHADER_PATH) as Shader, CAP)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool:
var power := float(ev.get("power", 0.0))
var evolved := bool(ev.get("evolved", false))
var dir: Vector2 = ev.get("dir", Vector2.RIGHT)
var reach := float(ev.get("range", 160.0))
var slot := accent_slot(ElementAccents.code("blade", int(ev.get("rungs", 0)), evolved)) # half-float safe (Task 7 fix)
var life := LIFE_EVOLVED if evolved else LIFE * (1.0 + LIFE_GROW * power)
return spawn(ev["pos"], dir.angle(), Vector2.ONE * reach * EXTENT, tint,
Vector4(power + (2.0 if evolved else 0.0), slot, deg_to_rad(float(ev.get("arc", 120.0))), seed), life)
  • Step 4: Write fx/weapons/blade_slash.gdshader
shader_type canvas_item;
render_mode blend_add, unshaded;
#include "res://fx/weapons/element_accents.gdshaderinc"
// Blade v2 (weapon FX v2 batch 1). One instance per swing on a 2x2 quad scaled to reach * EXTENT;
// local +x = swing direction, d == 1 is exactly the blade's reach (the hit edge).
// COLOR = (element tint, a = life t). INSTANCE_CUSTOM = (power + 2*evolved, accent slot, arc rad, seed).
// Power (<= 1.5x): thicker, longer smear, an afterimage crescent, more sparks. Evolved (Tempest): three
// blade echoes spinning round the full circle + wind streaks + the evolve element's motif.
// The interior stays clear (enemies inside the swing stay readable). Hot parts > 1.0 feed the HDR bloom.
const float EXTENT = 1.15; // keep in sync with BladeSlashLook.EXTENT
varying vec4 v_col;
varying vec4 v_cus;
void vertex() {
v_col = COLOR;
v_cus = INSTANCE_CUSTOM;
}
void fragment() {
float t = clamp(v_col.a, 0.0, 1.0);
bool evolved = v_cus.x >= 1.5;
float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0);
float half_arc = clamp(v_cus.z, 0.1, TAU) * 0.5;
float seed = v_cus.w;
vec2 p = (UV - 0.5) * 2.0 * EXTENT;
float d = length(p);
if (d > EXTENT) {
discard;
}
float a = atan(p.y, p.x); // 0 = swing direction
float in_arc = step(abs(a), half_arc); // half_arc >= PI (a 360 swing) covers every angle
// Sweep: the leading edge crosses the arc over the first 60% of life; the smear decays behind it.
float lead = mix(-half_arc, half_arc, smoothstep(0.0, 0.6, t));
float behind = lead - a; // > 0 = already swept
float trail = max(half_arc * mix(0.30, 0.45, power), 0.05);
float smear = in_arc * step(0.0, behind) * exp(-behind / trail);
float band_w = mix(0.16, 0.24, power); // 1.5x at full power
float band = exp(-pow((d - 0.86) / band_w, 2.0));
float clear_in = smoothstep(0.25, 0.55, d);
float body = smear * band * clear_in;
float lead_edge = in_arc * exp(-pow(behind / 0.07, 2.0)) * band;
vec3 col = v_col.rgb * body * 1.3 + vec3(1.0, 0.98, 0.92) * lead_edge * 2.2;
// Afterimage crescent (power): a fainter copy lagging behind, slightly inside the reach.
float ai = smoothstep(0.3, 0.8, power);
float behind2 = behind - half_arc * 0.35;
float echo = in_arc * step(0.0, behind2) * exp(-behind2 / 0.25) * exp(-pow((d - 0.72) / band_w, 2.0));
col += v_col.rgb * echo * 0.6 * ai;
// Sparks thrown off the edge as the lead passes (3 at power 0, up to 8).
float nsp = 3.0 + floor(power * 5.0);
for (int k = 0; k < 8; k++) {
if (float(k) >= nsp) {
break;
}
float r1 = hash21(vec2(float(k), seed));
float r2 = hash21(vec2(seed, float(k) + 7.0));
float sa = mix(-half_arc, half_arc, r1);
float born = (sa + half_arc) / (2.0 * half_arc) * 0.6;
float age = clamp((t - born) / 0.4, 0.0, 1.0);
if (age <= 0.0 || age >= 1.0) {
continue;
}
vec2 q = p - vec2(cos(sa), sin(sa)) * (0.86 + age * (0.15 + 0.2 * r2));
col += vec3(1.0, 0.9, 0.7) * exp(-dot(q, q) / 0.0009) * (1.0 - age) * 2.0;
}
// Tempest (evolved): three blade echoes spinning round the full circle + wind streaks.
if (evolved) {
float spin = t * 9.0;
for (int e = 0; e < 3; e++) {
float bh = mod(spin + float(e) * TAU / 3.0 - a, TAU); // angle behind echo e's tip
col += v_col.rgb * exp(-bh / 0.7) * band * clear_in * 0.9;
col += vec3(1.0) * exp(-pow(bh / 0.05, 2.0)) * band * 1.4;
}
float streak = smoothstep(0.72, 0.9, vnoise(vec2(a * 6.0 - t * 10.0, d * 3.0)));
streak *= smoothstep(0.4, 0.9, d) * (1.0 - smoothstep(1.0, 1.12, d));
col += vec3(0.8, 0.9, 1.0) * streak * 0.8;
}
// Crystal accents on the blade body; the evolved form adds its evolve element at full strength.
float mask = clamp(body + lead_edge, 0.0, 1.0);
float edge = clamp(lead_edge + (evolved ? band * 0.5 : 0.0), 0.0, 1.0);
int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code
vec4 acc = accents_all(acode, p, mask, edge, t, seed, TIME);
col = col * acc.a + acc.rgb;
if (evolved) {
vec4 em = accent_motif(evolve_accent(acode), p, clamp(mask + band * clear_in, 0.0, 1.0), edge, 1.0, t, seed + 3.0, TIME);
col = col * em.a + em.rgb;
}
COLOR = vec4(col * pow(1.0 - t, 1.4), 1.0);
}
  • Step 5: FxManager — registry, dispatch, legacy arc fix, tick

V2_LOOKS becomes:

const V2_LOOKS := {"blade": "BladeSlashLook"}

At the end of _ready():

# Weapon FX v2 looks (one MultiMesh draw call each; idle = hidden = zero draws).
_add_v2_layer(BladeSlashLook.new(), ["blade"])

Replace the "slash": arm in consume:

"slash":
# Melee swing. v2: the weapon's own look (real arc, grows with power, transforms when
# evolved). Legacy: the bright crescent node, now honouring the event's arc too (bug 1).
var slpos: Vector2 = ev["pos"]
var sldir: Vector2 = ev.get("dir", Vector2.RIGHT)
var slr: float = float(ev.get("range", 160.0))
var slcol := ElementPalette.color_for(_content, ev.get("element", -1))
var slwid := String(ev.get("wid", "blade"))
if v2_active() and V2_LOOKS.has(slwid):
v2_look(slwid).spawn_event(ev, slcol, _fx_rng.randf())
else:
_spawn_slash(slpos, sldir, slr, slcol, deg_to_rad(float(ev.get("arc", 97.4))))

Legacy node: change _SlashNode.reset’s signature to func reset(pos: Vector2, dir: Vector2, r: float, c: Color, span_rad: float) -> void: and add span = clampf(span_rad, 0.1, TAU) as its first line. Change _spawn_slash to func _spawn_slash(pos: Vector2, dir: Vector2, r: float, col: Color, span_rad: float = 1.7) -> void: with sl.reset(pos, dir, r, col, span_rad). Add:

func active_slashes() -> Array:
return _slash_active

tests/test_weapon_fx_guards.gd: delete the "blade": ... line from PENDING_V2_LOOKS.

Accent slot tables (Task 7 fix 804fa4e): no explicit accent_reset() call is needed for FxManager-owned event looks. main._new_run() frees and rebuilds fx_layer (it is not in the persistent-children list), and WeaponFxLayer._init calls accent_reset(), so every run starts with an empty table. Within one run an event look sees at most rungs + 2 distinct codes for its own wid (rungs only accumulate), well under the 15 free slots. Don’t add a mid-run reset: live effects would read code 0 until they expire.

  • Step 6: Run the tests

Run: godot --headless --path . --import, then -gselect=test_weapon_fx_looks, -gselect=test_fx_manager, -gselect=test_weapon_fx_guards, -gselect=test_plasma_nova. Expected: all pass. test_fx_manager’s child count now includes 1 v2 layer.

  • Step 7: Render the grid + in-game shots
Terminal window
WEAPON=blade godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobile
WEAPON=blade V2=1 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
WEAPON=blade V2=1 EVOLVE=1 LEVELS=5 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
git diff --stat project.godot; git checkout -- project.godot

Read each PNG and check these items. Iterate on shader constants until every one holds (the bh-vfx-iteration loop):

  • v1 row: legacy crescent. EVOLVED row: a full 360° whirl, not a crescent.

  • The sweep runs across the frames in each row, and the lead edge is white-hot.

  • p0 → +blood → +fire: visibly thicker/longer each step, but never more than ~1.5× p0. The blood droplets then fire embers appear.

  • The interior stays dark, with enemy dots readable through the swing.

  • If the sweep runs the wrong way or is mirrored, flip p.y (see the 2D gotcha at the top).

  • Step 8: Commit — feat(fx-v2): Blade look (real arc, grows with power, Tempest whirl) + v2 dispatch; legacy crescent honours arc


Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).

Files:

  • Create: fx/weapons/scythe_reap_look.gd, fx/weapons/scythe_reap.gdshader
  • Modify: fx/fx_manager.gd (V2_LOOKS, _ready), tests/test_weapon_fx_guards.gd (delete "scythe" from pending)
  • Test: tests/test_weapon_fx_looks.gd

Interfaces:

  • Consumes: as Task 9. The slash arm already routes by ev.wid, so no dispatch change is needed.

  • Produces: class_name ScytheReapLook extends WeaponFxLayer with EXTENT := 1.15, CAP := 8, spawn_event(ev, tint, seed) -> bool.

  • Step 1: Write the failing tests (append to tests/test_weapon_fx_looks.gd)

func test_scythe_routes_to_its_own_look() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.consume([_slash_ev("scythe", 360.0, true, 0.3, 0b011)])
assert_eq(fx.v2_look("scythe").active_count(), 1)
assert_eq(fx.v2_look("blade").active_count(), 0, "a scythe swing is not a blade swing")
var b: PackedFloat32Array = fx.v2_look("scythe").debug_buffer()
assert_almost_eq(b[14], TAU, 1e-4, "Reaping draws the full ring (bug 1)")
assert_eq(fx.v2_look("scythe").accent_codes()[int(b[13])], ElementAccents.code("scythe", 0b011, true), "custom.y = slot -> code")
func test_scythe_look_is_bounded_and_expires() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
var many: Array = []
for _i in range(30):
many.append(_slash_ev("scythe", 120.0))
fx.consume(many)
assert_lte(fx.v2_look("scythe").active_count(), ScytheReapLook.CAP)
for _i in range(90):
fx.advance(1.0 / 60.0)
assert_eq(fx.v2_look("scythe").active_count(), 0)
  • Step 2: Run -gselect=test_weapon_fx_looks. Expected: FAIL, because v2_look("scythe") is null.

  • Step 3: Write fx/weapons/scythe_reap_look.gd

class_name ScytheReapLook
extends WeaponFxLayer
# Scythe v2 (spec § Batch 1): a heavier violet sweep with a ghost-green razor edge; render-side wisps
# drift from the rim toward the pilot inside the swept sector (spec: acceptable for batch 1 even though
# they don't originate from real hits). Evolved (Reaping): an eclipse ring + spiral wisps falling
# inward, carrying the evolve rung's element (blood).
const SHADER_PATH := "res://fx/weapons/scythe_reap.gdshader"
const CAP := 8
const EXTENT := 1.15 # keep in sync with the shader's EXTENT
const LIFE := 0.30
const LIFE_GROW := 0.35
const LIFE_EVOLVED := 0.45
func _init() -> void:
super(load(SHADER_PATH) as Shader, CAP)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool:
var power := float(ev.get("power", 0.0))
var evolved := bool(ev.get("evolved", false))
var dir: Vector2 = ev.get("dir", Vector2.RIGHT)
var reach := float(ev.get("range", 160.0))
var slot := accent_slot(ElementAccents.code("scythe", int(ev.get("rungs", 0)), evolved)) # half-float safe (Task 7 fix)
var life := LIFE_EVOLVED if evolved else LIFE * (1.0 + LIFE_GROW * power)
return spawn(ev["pos"], dir.angle(), Vector2.ONE * reach * EXTENT, tint,
Vector4(power + (2.0 if evolved else 0.0), slot, deg_to_rad(float(ev.get("arc", 120.0))), seed), life)
  • Step 4: Write fx/weapons/scythe_reap.gdshader
shader_type canvas_item;
render_mode blend_add, unshaded;
#include "res://fx/weapons/element_accents.gdshaderinc"
// Scythe v2 (weapon FX v2 batch 1). Same instance contract as blade_slash.gdshader:
// COLOR = (element tint, a = life t), INSTANCE_CUSTOM = (power + 2*evolved, accent slot, arc rad, seed),
// quad = reach * EXTENT, +x = swing direction, d == 1 = the hit edge.
// Violet body (element tint blended in) + ghost-green razor edge + wisps drifting inward inside the sector.
// Evolved (Reaping): eclipse corona on the reach circle + three spiral arms falling inward.
const float EXTENT = 1.15; // keep in sync with ScytheReapLook.EXTENT
varying vec4 v_col;
varying vec4 v_cus;
void vertex() {
v_col = COLOR;
v_cus = INSTANCE_CUSTOM;
}
void fragment() {
float t = clamp(v_col.a, 0.0, 1.0);
bool evolved = v_cus.x >= 1.5;
float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0);
float half_arc = clamp(v_cus.z, 0.1, TAU) * 0.5;
float seed = v_cus.w;
vec2 p = (UV - 0.5) * 2.0 * EXTENT;
float d = length(p);
if (d > EXTENT) {
discard;
}
float a = atan(p.y, p.x);
float in_arc = step(abs(a), half_arc);
// Heavier, slower sweep than the blade.
float lead = mix(-half_arc, half_arc, smoothstep(0.0, 0.5, t));
float behind = lead - a;
float smear = in_arc * step(0.0, behind) * exp(-behind / max(half_arc * mix(0.45, 0.68, power), 0.05));
float band_w = mix(0.14, 0.21, power); // 1.5x at full power
float band = exp(-pow((d - 0.88) / band_w, 2.0));
float razor = exp(-pow((d - 0.98) / 0.03, 2.0));
float clear_in = smoothstep(0.2, 0.5, d);
vec3 violet = mix(vec3(0.45, 0.12, 0.85), v_col.rgb, 0.35);
vec3 ghost = vec3(0.45, 1.0, 0.7);
float body = smear * band * clear_in;
vec3 col = violet * body * 1.25 + ghost * smear * razor * 1.9;
// Wisps: soft ghost-green blobs drifting from the rim toward the pilot inside the swept sector.
float nw = 4.0 + floor(power * 4.0);
for (int k = 0; k < 8; k++) {
if (float(k) >= nw) {
break;
}
float r1 = hash21(vec2(float(k) * 1.7, seed));
float r2 = hash21(vec2(seed + 3.1, float(k)));
float wa = mix(-half_arc, half_arc, r1);
float age = clamp(t * 1.4 - r2 * 0.4, 0.0, 1.0);
if (age <= 0.0) {
continue;
}
vec2 q = p - vec2(cos(wa), sin(wa)) * mix(0.92, 0.35, age);
col += ghost * exp(-dot(q, q) / mix(0.004, 0.012, age)) * (1.0 - age) * 0.9;
}
float corona = 0.0;
if (evolved) {
// Reaping: an eclipse corona on the reach circle + three spiral arms falling inward.
corona = exp(-pow((d - 0.95) / 0.05, 2.0)) * (0.7 + 0.6 * fbm(vec2(a * 4.0, TIME * 1.5)));
col += mix(ghost, v_col.rgb, 0.3) * corona * 1.6 * (1.0 - t * 0.6);
float arm = pow(0.5 + 0.5 * cos(3.0 * a + 9.0 * d - t * 14.0), 12.0);
arm *= smoothstep(0.3, 0.9, d) * (1.0 - smoothstep(0.95, 1.05, d));
col += violet * arm * 1.1;
}
float mask = clamp(body + razor * smear + corona * 0.5, 0.0, 1.0);
float edge = clamp(razor * in_arc + corona, 0.0, 1.0);
int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code
vec4 acc = accents_all(acode, p, mask, edge, t, seed, TIME);
col = col * acc.a + acc.rgb;
if (evolved) {
vec4 em = accent_motif(evolve_accent(acode), p, clamp(mask + band * clear_in, 0.0, 1.0), edge, 1.0, t, seed + 3.0, TIME);
col = col * em.a + em.rgb;
}
COLOR = vec4(col * pow(1.0 - t, 1.2), 1.0);
}
  • Step 5: Register

V2_LOOKS gets "scythe": "ScytheReapLook". In _ready, add _add_v2_layer(ScytheReapLook.new(), ["scythe"]). In the guards file, delete "scythe" from PENDING_V2_LOOKS.

  • Step 6: Tests + grid

Run --import, then -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_fx_manager. Then run WEAPON=scythe compare and WEAPON=scythe V2=1 EVOLVE=1 LEVELS=5 ingame, and restore project.godot. Check against the grid:

  • The body reads violet with a ghost-green edge, and the tint is still visible.

  • Wisps move inward across the frames.

  • +blood adds droplets, and +void bites dark holes.

  • EVOLVED shows the eclipse ring plus spiral arms, covering 360°.

  • Step 7: Commit — feat(fx-v2): Scythe look (ghost edge, inward wisps, Reaping eclipse)


Task 11: Nova v2 look (+ Supernova; Scar Tissue discharge scaled by its own power)

Section titled “Task 11: Nova v2 look (+ Supernova; Scar Tissue discharge scaled by its own power)”

Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).

Files:

  • Create: fx/weapons/nova_burst_look.gd, fx/weapons/nova_burst.gdshader
  • Modify: fx/fx_manager.gd (V2_LOOKS, _ready, the "nova" arm at lines 529–534), tests/test_weapon_fx_guards.gd (delete "nova" from pending)
  • Test: tests/test_weapon_fx_looks.gd

Interfaces:

  • Consumes: fx_noise / accents includes; stamped nova events from both weapon_nova.gd (wid nova) and weapon_scar_tissue.gd (wid scar_tissue).

  • Produces: class_name NovaBurstLook extends WeaponFxLayer with EXTENT := 1.35, CAP := 8, LIFE := 0.46, LIFE_EVOLVED := 0.70, spawn_event(ev, tint, seed) -> bool. For wid scar_tissue it draws the Nova look at Scar Tissue’s own power, never evolved, with no accents (spec: “keeps the Nova look, scaled by its own power”).

  • Step 1: Write the failing tests (append)

func _nova_ev(wid: String, power: float, evolved: bool, rungs: int = 0) -> Dictionary:
return {"kind": "nova", "pos": Vector2(40, -20), "radius": 180.0, "element": 0,
"wid": wid, "power": power, "evolved": evolved, "rungs": rungs}
func test_nova_v2_replaces_the_plasma_node() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.consume([_nova_ev("nova", 0.6, true, 0b011)])
assert_eq(fx.v2_look("nova").active_count(), 1)
assert_eq(fx.nova_active_count(), 0, "no v1 plasma node")
assert_eq(fx.ring_active_count(), 0, "no legacy ring")
var b: PackedFloat32Array = fx.v2_look("nova").debug_buffer()
assert_almost_eq(b[12], 2.6, 1e-5)
assert_eq(fx.v2_look("nova").accent_codes()[int(b[13])], ElementAccents.code("nova", 0b011, true), "custom.y = slot -> code")
func test_scar_tissue_discharge_is_nova_look_at_its_own_power() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.consume([_nova_ev("scar_tissue", 0.4, true, 0b111)])
var b: PackedFloat32Array = fx.v2_look("nova").debug_buffer()
assert_almost_eq(b[12], 0.4, 1e-5, "Scar Tissue's own power, never the Supernova transform")
assert_almost_eq(b[13], 0.0, 1e-5, "slot 0: no Nova crystal accents on Scar Tissue's discharge")
func test_nova_v2_off_keeps_the_plasma_node() -> void:
VisualLook.weapon_fx_v2 = false
VisualLook.plasma_nova = true
var fx := _make()
fx.consume([_nova_ev("nova", 0.6, false)])
assert_eq(fx.nova_active_count(), 1)
assert_eq(fx.v2_look("nova").active_count(), 0)
func test_nova_v2_is_essential_and_expires() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.enabled = false
fx.consume([_nova_ev("nova", 0.0, true)])
assert_eq(fx.v2_look("nova").active_count(), 1, "weapon feedback survives L5")
for _i in range(60):
fx.advance(1.0 / 60.0)
assert_eq(fx.v2_look("nova").active_count(), 0, "even the lingering Supernova expires within 1s")
  • Step 2: Run -gselect=test_weapon_fx_looks. Expected: FAIL.

  • Step 3: Write fx/weapons/nova_burst_look.gd

class_name NovaBurstLook
extends WeaponFxLayer
# Nova v2 (spec § Batch 1): the Plasma Nova fireball, grown by power -- denser body, more embers,
# longer flame tongues (<= 1.5x) -- and on evolution a Supernova: blue-white core, a second delayed
# shock ring and a lingering corona, carrying the evolve rung's element (light).
# Scar Tissue's discharge reuses the "nova" event: it gets this look at Scar Tissue's OWN power, never
# evolved and without Nova's crystal accents, until batch 5 gives it its own look.
const SHADER_PATH := "res://fx/weapons/nova_burst.gdshader"
const CAP := 8
const EXTENT := 1.35 # quad half-size in AoE radii -- keep in sync with the shader's EXTENT
const LIFE := 0.46 # == PlasmaNovaNode.LIFE
const LIFE_EVOLVED := 0.70 # the Supernova corona lingers
func _init() -> void:
super(load(SHADER_PATH) as Shader, CAP)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool:
var own := String(ev.get("wid", "nova")) == "nova"
var power := float(ev.get("power", 0.0))
var evolved := own and bool(ev.get("evolved", false))
var slot: float = accent_slot(ElementAccents.code("nova", int(ev.get("rungs", 0)), evolved)) if own else 0.0 # slot 0 = no accents
var r := float(ev.get("radius", 100.0))
return spawn(ev["pos"], seed * TAU, Vector2.ONE * r * EXTENT, tint,
Vector4(power + (2.0 if evolved else 0.0), slot, seed, 0.0), LIFE_EVOLVED if evolved else LIFE)
  • Step 4: Write fx/weapons/nova_burst.gdshader
shader_type canvas_item;
render_mode blend_add, unshaded;
#include "res://fx/weapons/element_accents.gdshaderinc"
// Nova v2 (weapon FX v2 batch 1). The Plasma Nova recipe (fx/weapons/plasma_nova.gdshader) with power
// growth and a Supernova transform, as ONE MultiMesh instance per burst (embers drawn in-shader).
// COLOR = (element tint, a = life t). INSTANCE_CUSTOM = (power + 2*evolved, accent slot, seed, 0).
// Quad = radius * EXTENT, so d == 1 is exactly the damage edge. Rotation is random per burst (seed).
const float EXTENT = 1.35; // keep in sync with NovaBurstLook.EXTENT
varying vec4 v_col;
varying vec4 v_cus;
void vertex() {
v_col = COLOR;
v_cus = INSTANCE_CUSTOM;
}
// Heat 0..1 -> violet (tinted by element) .. magenta .. orange .. yellow .. white (== plasma_nova).
vec3 heat_ramp(float h, vec3 tint) {
vec3 violet = mix(vec3(0.42, 0.10, 1.0), tint, 0.30);
vec3 c = mix(violet, vec3(1.0, 0.14, 0.62), smoothstep(0.02, 0.30, h));
c = mix(c, vec3(1.0, 0.40, 0.06), smoothstep(0.30, 0.55, h));
c = mix(c, vec3(1.0, 0.80, 0.28), smoothstep(0.55, 0.80, h));
return mix(c, vec3(1.0, 0.97, 0.88), smoothstep(0.80, 1.0, h));
}
// Supernova: deep blue (tinted) .. blue .. cyan-white .. white.
vec3 super_ramp(float h, vec3 tint) {
vec3 deep = mix(vec3(0.20, 0.25, 1.0), tint, 0.25);
vec3 c = mix(deep, vec3(0.35, 0.65, 1.0), smoothstep(0.02, 0.35, h));
c = mix(c, vec3(0.70, 0.92, 1.0), smoothstep(0.35, 0.70, h));
return mix(c, vec3(1.0), smoothstep(0.70, 1.0, h));
}
void fragment() {
float t = clamp(v_col.a, 0.0, 1.0);
bool evolved = v_cus.x >= 1.5;
float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0);
float seed = v_cus.z;
vec2 p = (UV - 0.5) * 2.0 * EXTENT;
float d = length(p);
if (d > EXTENT) {
discard;
}
vec2 dir = p / max(d, 1e-4);
float front = 1.0 - pow(1.0 - t, 2.4);
vec2 flow = vec2(TIME * 1.9, -TIME * 1.4) + seed * 11.0;
float n_edge = fbm(dir * 5.5 + flow);
float n_body = fbm(p * 4.2 - dir * TIME * 2.6 + vec2(0.0, TIME * 0.7) + seed * 7.0);
float edge = front * (0.94 + (n_edge - 0.44) * 0.20);
float x = d - edge;
float n_tongue = fbm(dir * 9.0 + vec2(-x * 4.0 + TIME * 2.5, TIME * 0.9) + seed * 3.0);
float shell_w = mix(0.07, 0.20, t);
float tongue_w = 0.012 + mix(0.20, 0.30, power) * pow(n_tongue, 2.2); // tongues 1.5x at power 1
float dens = x < 0.0 ? exp(x / shell_w) : exp(-x / tongue_w);
dens *= 0.25 + 1.5 * smoothstep(mix(0.15, 0.08, power), 0.75, n_body); // denser body with power
float core_t = 1.0 - smoothstep(0.0, 0.45, t);
float core = exp(-d * d / (0.02 + 0.22 * t)) * core_t * core_t;
float cool = 1.0 - 0.85 * t;
float inside = x < 0.0 ? 1.0 : 0.35;
float h = clamp(max(core * 1.1, dens * cool * inside * (0.75 + 0.35 * n_body)), 0.0, 1.0);
vec3 ramp = evolved ? super_ramp(h, v_col.rgb) : heat_ramp(h, v_col.rgb);
float fade = pow(1.0 - t, 1.3);
vec3 col = ramp * (dens * fade * 1.25 + core * 1.6) * (0.85 + 0.9 * h);
// Embers: 14 at power 0 (== the v1 Embers node), up to 24 at power 1. Closed-form in progress.
float n_emb = 14.0 + floor(10.0 * power);
vec3 hot = mix(vec3(1.0, 0.85, 0.45), vec3(1.0, 0.35, 0.1), clamp(t * 1.6, 0.0, 1.0));
vec3 ember_c = evolved ? vec3(0.75, 0.9, 1.0) : mix(hot, vec3(0.95, 0.2, 0.7), clamp(t * 1.6 - 0.6, 0.0, 1.0));
for (int k = 0; k < 24; k++) {
if (float(k) >= n_emb) {
break;
}
float ang = (float(k) + hash21(vec2(float(k), seed)) * 0.8) / n_emb * TAU;
float spd = mix(0.85, 1.35, hash21(vec2(seed, float(k) + 3.0)));
float sz = mix(0.018, 0.036, hash21(vec2(float(k) + 9.0, seed))) * (1.0 - 0.5 * t);
vec2 q = p - vec2(cos(ang), sin(ang)) * front * spd;
float r2 = dot(q, q);
col += (ember_c * 1.6 * exp(-r2 / (sz * sz)) + vec3(1.6, 1.5, 1.3) * exp(-r2 / (sz * sz * 0.09))) * (1.0 - t);
}
float corona = 0.0;
if (evolved) {
// Supernova: a second, delayed shock ring + a corona that lingers on the damage edge.
float t2 = clamp((t - 0.22) / 0.78, 0.0, 1.0);
float front2 = (1.0 - pow(1.0 - t2, 2.4)) * 1.05;
col += vec3(0.8, 0.92, 1.0) * exp(-pow((d - front2) / 0.035, 2.0)) * step(0.001, t2) * (1.0 - t2) * 2.0;
corona = exp(-pow((d - 1.0) / 0.09, 2.0)) * smoothstep(0.25, 0.55, t) * (1.0 - t) * (0.6 + 0.6 * n_edge);
col += mix(vec3(0.6, 0.8, 1.0), v_col.rgb, 0.3) * corona * 1.4;
}
float mask = clamp(dens * fade + corona, 0.0, 1.0);
float rim = clamp(exp(-x * x / 0.004), 0.0, 1.0);
int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code
vec4 acc = accents_all(acode, p, mask, rim, t, seed, TIME);
col = col * acc.a + acc.rgb;
if (evolved) {
vec4 em = accent_motif(evolve_accent(acode), p, mask, max(rim, corona), 1.0, t, seed + 3.0, TIME);
col = col * em.a + em.rgb;
}
COLOR = vec4(col, 1.0);
}
  • Step 5: Dispatch + register

Replace the "nova": arm:

"nova":
var ncol := ElementPalette.color_for(_content, ev.get("element", -1))
if v2_active() and V2_LOOKS.has("nova"):
v2_look("nova").spawn_event(ev, ncol, _fx_rng.randf()) # Nova + Scar Tissue discharge
elif VisualLook.plasma_nova:
_spawn_plasma_nova(ev["pos"], float(ev.get("radius", 100.0)), ncol)
else:
_spawn_ring(ev["pos"], float(ev.get("radius", 100.0)), ncol)

V2_LOOKS gets "nova": "NovaBurstLook". _ready: _add_v2_layer(NovaBurstLook.new(), ["nova"]). Delete "nova" from PENDING_V2_LOOKS. Scar Tissue stays pending for batch 5: its own look is still to come.

  • Step 6: Tests + grid

Run the --import / test_weapon_fx_looks / test_plasma_nova / test_fx_manager / test_weapon_fx_guards suites. Then run WEAPON=nova compare, WEAPON=nova V2=1 EVOLVE=1 LEVELS=5 ingame and WEAPON=nova V2=0 ingame, and restore project.godot. Check the grid:

  • p0 ≈ the v1 Plasma Nova. It must not look worse; this is the look Chris approved on 2026-09-27.

  • The +fire rows get denser with longer tongues and more embers, within 1.5×.

  • EVOLVED is blue-white with a visible second ring and a corona that is still lit in the last column.

  • The interior clears fast, and enemy dots inside stay readable.

  • Step 7: Commit — feat(fx-v2): Nova look (power-grown fireball, Supernova); Scar Tissue discharge at its own power


Task 12: Orbit Shards look (all pilots) + main.gd wiring

Section titled “Task 12: Orbit Shards look (all pilots) + main.gd wiring”

Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).

Files:

  • Create: fx/weapons/orbit_shards_look.gd, fx/weapons/orbit_shards.gdshader
  • Modify: main.gd (var decl near line 242, setup after the orbit-shard block at ~1095–1109, sync block at ~1810–1816), fx/fx_manager.gd (V2_LOOKS), tests/test_weapon_fx_guards.gd (delete "orbit" from pending), tools/weapon_vfx/compare.gd (enable the "orbit" arm + _orbit_cell from Task 8)
  • Test: tests/test_weapon_fx_looks.gd

Interfaces:

  • Consumes: WeaponOrbit.positions: Array[Vector2], .orbit_radius, .spin_deg; PilotArsenal.orbit, .orbit_element_idx; Sim.pilots, Sim.content; WeaponVfx.power / rungs / is_evolved; ElementAccents.code; WeaponFxLayer.begin/put/commit/clear/ensure_capacity/accent_slot/accent_reset.

  • Produces:

    • class_name OrbitShardsLook extends WeaponFxLayer with RING_EXTENT := 1.3, SHARD_HALF := 14.0, SHARD_GROW := 0.5, EVOLVED_HALF := 20.0, SPIN_REF := 270.0
    • static entries_for_sim(sim: Sim) -> Array of {center, positions, radius, spin_norm, tint, power, evolved, code} (code = the raw int accent code; sync_entries turns it into a slot)
    • sync_entries(entries: Array) -> void
    • FxManager.V2_LOOKS["orbit"] == "main:OrbitShardsLook"
  • Step 1: Write the failing tests (append)

func _orbit_sim() -> Sim:
var sim := Sim.new(1, ContentLoader.load_from_path("res://data/bible.json"))
sim.player.pos = Vector2(100, 100)
sim.player.arsenal.orbit.update(sim, sim.player, Sim_Const.DT)
return sim
func test_orbit_entries_follow_the_real_weapon() -> void:
var sim := _orbit_sim()
var es := OrbitShardsLook.entries_for_sim(sim)
assert_eq(es.size(), 1)
assert_eq((es[0]["positions"] as Array).size(), sim.player.arsenal.orbit.positions.size())
assert_almost_eq(float(es[0]["radius"]), sim.player.arsenal.orbit.orbit_radius, 1e-4)
assert_false(es[0]["evolved"])
func test_orbit_entries_cover_every_pilot() -> void:
var sim := _orbit_sim()
var p2 := sim.add_pilot()
p2.arsenal.orbit.update(sim, p2, Sim_Const.DT)
assert_eq(OrbitShardsLook.entries_for_sim(sim).size(), 2, "co-op pilots get their own ring (the legacy sprites only drew P1)")
func test_orbit_look_packs_ring_then_one_instance_per_shard() -> void:
var sim := Sim.new(1, ContentLoader.load_from_path("res://data/bible.json"))
sim.player.arsenal.orbit.evolve()
sim.player.arsenal.orbit.update(sim, sim.player, Sim_Const.DT)
var look := OrbitShardsLook.new()
add_child_autofree(look)
look.sync_entries(OrbitShardsLook.entries_for_sim(sim))
var n := sim.player.arsenal.orbit.positions.size()
assert_eq(look.multimesh.visible_instance_count, 1 + n)
var b := look.debug_buffer()
assert_almost_eq(b[15], float(n), 1e-5, "ring: custom.w = shard count")
assert_almost_eq(b[WeaponFxLayer.STRIDE + 15], -1.0, 1e-5, "shard: custom.w = -1")
assert_gte(b[12], 2.0, "evolved -> power + 2")
var code := int(OrbitShardsLook.entries_for_sim(sim)[0]["code"])
assert_eq(look.accent_codes()[int(b[13])], code, "ring custom.y = slot -> the orbit accent code")
assert_eq(b[WeaponFxLayer.STRIDE + 13], b[13], "shards share the ring's slot")
func test_orbit_look_idle_draws_nothing() -> void:
var look := OrbitShardsLook.new()
add_child_autofree(look)
look.sync_entries([])
assert_false(look.visible)
  • Step 2: Run -gselect=test_weapon_fx_looks. Expected: FAIL (OrbitShardsLook not declared).

  • Step 3: Write fx/weapons/orbit_shards_look.gd

class_name OrbitShardsLook
extends WeaponFxLayer
# Orbit Shards v2 (spec § Batch 1): faceted crystals with a glint + a motion-blur arc trail behind
# each shard; a faint link ring appears with power; Event Horizon (evolved) = singularities + a lensing
# ring, carrying the evolve rung's element (void). Persistent (not an fx_event look): main.gd calls
# sync_entries(entries_for_sim(sim)) every frame while v2 is active. ONE layer for every pilot: per
# pilot one RING instance (trails/link/lens) + one SHARD instance per shard -- one draw call total.
const SHADER_PATH := "res://fx/weapons/orbit_shards.gdshader"
const RING_EXTENT := 1.3 # ring quad half-size in orbit radii -- keep in sync with the shader
const SHARD_HALF := 14.0 # px at power 0 (~ the legacy GlowTexture sprite at scale 0.6)
const SHARD_GROW := 0.5 # 1.5x at power 1 (grow budget)
const EVOLVED_HALF := 20.0
const SPIN_REF := 270.0 # spin_deg that reads as "full speed" for the trail length
func _init() -> void:
super(load(SHADER_PATH) as Shader, 16)
static func entries_for_sim(sim: Sim) -> Array:
var out := []
for pilot in sim.pilots:
var ars: PilotArsenal = pilot.arsenal
var o: WeaponOrbit = ars.orbit
if o == null or o.positions.is_empty():
continue
var evolved := WeaponVfx.is_evolved(ars, "orbit")
out.append({"center": pilot.pos, "positions": o.positions, "radius": o.orbit_radius,
"spin_norm": clampf(o.spin_deg / SPIN_REF, 0.0, 1.0),
"tint": ElementPalette.color_for(sim.content, ars.orbit_element_idx),
"power": WeaponVfx.power(ars, "orbit"), "evolved": evolved,
"code": ElementAccents.code("orbit", WeaponVfx.rungs(ars, "orbit"), evolved)})
return out
func sync_entries(entries: Array) -> void:
var need := 0
for e in entries:
need += 1 + (e["positions"] as Array).size()
ensure_capacity(need)
accent_reset() # persistent look: every instance is re-put each frame, so re-slotting per sync never overflows
begin()
for e in entries:
var c: Vector2 = e["center"]
var pos: Array = e["positions"]
var evolved: bool = e["evolved"]
var power: float = e["power"]
var pe := power + (2.0 if evolved else 0.0)
var tint: Color = e["tint"]
var slot := accent_slot(int(e["code"]))
var phase := 0.0 if pos.is_empty() else ((pos[0] as Vector2) - c).angle()
put(c, 0.0, Vector2.ONE * float(e["radius"]) * RING_EXTENT,
Color(tint.r, tint.g, tint.b, float(e["spin_norm"])), Vector4(pe, slot, phase, float(pos.size())))
var half := EVOLVED_HALF if evolved else SHARD_HALF * (1.0 + SHARD_GROW * power)
for k in range(pos.size()):
var sp: Vector2 = pos[k]
put(sp, (sp - c).angle() + PI * 0.5, Vector2.ONE * half,
Color(tint.r, tint.g, tint.b, 1.0), Vector4(pe, slot, float(k), -1.0))
commit()
  • Step 4: Write fx/weapons/orbit_shards.gdshader
shader_type canvas_item;
render_mode blend_add, unshaded;
#include "res://fx/weapons/element_accents.gdshaderinc"
// Orbit Shards v2 (weapon FX v2 batch 1). ONE layer, two instance kinds (INSTANCE_CUSTOM.w):
// RING (w = shard count >= 1): one per pilot, quad = orbit_radius * RING_EXTENT, so d == 1 is the
// orbit; COLOR.a = spin_norm; custom = (power + 2*evolved, accent slot, phase of shard 0, n).
// Draws each shard's motion-blur arc trail, the power link ring, and Event Horizon's lens.
// SHARD (w = -1): quad = shard half-size, rotated so +x is the orbit tangent; custom.z = shard index.
// Faceted crystal + glint; evolved = a singularity (dark core, hot accretion rim).
const float RING_EXTENT = 1.3; // keep in sync with OrbitShardsLook.RING_EXTENT
varying vec4 v_col;
varying vec4 v_cus;
void vertex() {
v_col = COLOR;
v_cus = INSTANCE_CUSTOM;
}
void fragment() {
bool evolved = v_cus.x >= 1.5;
float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0);
vec3 col = vec3(0.0);
if (v_cus.w >= 0.5) {
// ── RING ──
vec2 p = (UV - 0.5) * 2.0 * RING_EXTENT;
float d = length(p);
if (d > RING_EXTENT) {
discard;
}
float a = atan(p.y, p.x);
float n = v_cus.w;
float radial = exp(-pow((d - 1.0) / mix(0.05, 0.075, power), 2.0)); // 1.5x at power 1
float trail_len = mix(0.35, 0.55, power) * (0.6 + 0.8 * v_col.a);
float tr = 0.0;
for (int k = 0; k < 6; k++) {
if (float(k) >= n) {
break;
}
float bh = mod(v_cus.z + float(k) * TAU / n - a, TAU); // angle behind shard k
tr += exp(-bh / trail_len);
}
col = v_col.rgb * tr * radial * 1.1;
col += v_col.rgb * smoothstep(0.35, 0.8, power) * exp(-pow((d - 1.0) / 0.015, 2.0)) * 0.6; // link ring
float mask = clamp(tr * radial, 0.0, 1.0);
if (evolved) {
// Event Horizon: a shimmering lensing band on the orbit
float lens = exp(-pow((d - 1.0) / 0.14, 2.0));
float shimmer = 0.3 + 0.7 * fbm(vec2(a * 6.0 - TIME * 2.0, d * 4.0));
col += mix(vec3(0.55, 0.2, 1.0), v_col.rgb, 0.3) * lens * shimmer * 0.7;
mask = clamp(mask + lens * 0.5, 0.0, 1.0);
}
int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code
vec4 acc = accents_all(acode, p, mask, radial, 0.5, 0.0, TIME);
col = col * acc.a + acc.rgb;
if (evolved) {
vec4 em = accent_motif(evolve_accent(acode), p, mask, radial, 1.0, 0.5, 1.0, TIME);
col = col * em.a + em.rgb;
}
} else {
// ── SHARD ──
vec2 p = (UV - 0.5) * 2.0;
float seed = v_cus.z;
float mask;
if (!evolved) {
float dia = abs(p.x) + abs(p.y) * 1.6; // diamond, long axis along the orbit tangent
float body = smoothstep(1.0, 0.85, dia);
float facet = 0.55 + 0.35 * step(0.0, p.x * 0.7 + p.y) + 0.25 * step(0.0, p.y);
float core = exp(-dot(p, p) / 0.05);
float glint = pow(max(0.0, 1.0 - length(p - vec2(-0.25, -0.2)) * 4.0), 3.0) * (0.5 + 0.5 * sin(TIME * 6.0 + seed * 10.0));
float halo = exp(-dot(p, p) / 0.35) * 0.35;
col = v_col.rgb * (body * facet * 1.3 + halo) + vec3(1.0) * (core * 1.2 + glint * 2.0) * body;
mask = body;
} else {
// Event Horizon singularity: no core fill (reads dark) ringed by a hot accretion rim
float r = length(p);
float rim = exp(-pow((r - 0.5) / 0.08, 2.0));
float swirl = 0.5 + 0.5 * sin(atan(p.y, p.x) * 3.0 - TIME * 8.0 + r * 10.0);
float disk = exp(-pow((r - 0.72) / 0.12, 2.0)) * swirl;
col = vec3(1.0, 0.95, 1.0) * rim * 1.8 + mix(vec3(0.55, 0.2, 1.0), v_col.rgb, 0.4) * disk * 1.2;
mask = clamp(rim + disk, 0.0, 1.0);
}
int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code
vec4 acc = accents_all(acode, p, mask, mask, 0.5, seed, TIME);
col = col * acc.a + acc.rgb;
}
COLOR = vec4(col, 1.0);
}
  • Step 5: Wire main.gd

Var, after var orbit_shards: Array[Sprite2D] = []:

var orbit_look: OrbitShardsLook # weapon FX v2 orbit look (all pilots); swapped against orbit_shards per frame

Setup, right after the orbit_shards creation block (after its closing orbit_shards.append(sh) loop):

# Weapon FX v2 orbit look: every pilot's shards + trails in ONE draw call. Drawn instead of the
# orbit_shards sprites while fx_layer.v2_active() (see the sync block).
orbit_look = OrbitShardsLook.new()
add_child(orbit_look)

Sync. Replace the if sim.orbit: shard block with:

var orbit_v2 := fx_layer.v2_active() and FxManager.V2_LOOKS.has("orbit")
if orbit_v2:
orbit_look.sync_entries(OrbitShardsLook.entries_for_sim(sim))
else:
orbit_look.clear()
if sim.orbit:
for k in range(orbit_shards.size()):
if not orbit_v2 and k < sim.orbit.positions.size():
orbit_shards[k].position = sim.orbit.positions[k]
orbit_shards[k].visible = true
else:
orbit_shards[k].visible = false

fx/fx_manager.gd: V2_LOOKS gets "orbit": "main:OrbitShardsLook". Delete "orbit" from PENDING_V2_LOOKS. Enable the "orbit" arm and _orbit_cell in tools/weapon_vfx/compare.gd (text as in Task 8).

  • Step 6: Tests + grid

Run --import, -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_main, -gselect=test_weapon_orbit. Then:

Terminal window
WEAPON=orbit godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobile
WEAPON=orbit V2=1 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
WEAPON=orbit V2=1 EVOLVE=1 LEVELS=5 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
git diff --stat project.godot; git checkout -- project.godot

Check:

  • Shards read as crystals, not blobs.

  • Each trail runs behind its shard. If the trails lead, the spin direction is flipped: negate a in the ring branch.

  • The link ring appears from about +lightning onward, and cold rime shows on the +cold rows.

  • EVOLVED shows 6 dark-cored singularities and a shimmering lens band.

  • In-game, the shards sit exactly on the real hit positions, compared against the v1 sprite shot.

  • Step 7: Commit — feat(fx-v2): Orbit Shards look (crystals, trails, link ring, Event Horizon) for every pilot


Task 13: Player bullets (PlayerBoltRenderer), Blaster muzzle flash, Auto-Cannon impact sparks

Section titled “Task 13: Player bullets (PlayerBoltRenderer), Blaster muzzle flash, Auto-Cannon impact sparks”

Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).

Files:

  • Create: fx/weapons/player_bolt_renderer.gd, fx/weapons/player_bolt.gdshader, fx/weapons/blaster_flash_look.gd, fx/weapons/blaster_flash.gdshader
  • Modify: main.gd (var decl near line 35, setup after line 1068, sync at line 1806), fx/fx_manager.gd (ESSENTIAL_KINDS, V2_LOOKS, _ready, two new consume arms), tests/test_weapon_fx_guards.gd (delete "blaster" from pending), tools/weapon_vfx/compare.gd (enable the "blaster" arm + _blaster_cell)
  • Test: tests/test_weapon_fx_looks.gd

Interfaces:

  • Consumes: ProjPool.vfx/rungs/kind/context/data/pos/vel (Task 2), the blaster_fire (+dir, element) and bolt_impact events (Task 3), main._proj_colors() -> PackedColorArray, main._visible_world_rect() -> Rect2.

  • Produces:

    • class_name PlayerBoltRenderer extends WeaponFxLayer with BASE_LEN := 18.0, BASE_WID := 2.5 * SwarmRenderer.NOVA_BULLET_WIDTH_MULT, LEN_GROW := 0.5, PAD := 3.0, sync_bolts(pool: ProjPool, colors: PackedColorArray, cull: Rect2) -> void
    • class_name BlasterFlashLook extends WeaponFxLayer with spawn_event(ev, tint, seed) -> bool (muzzle) and spawn_impact(ev, tint, seed) -> bool
    • FxManager.V2_LOOKS["blaster"] == "BlasterFlashLook". The bullets are main-drawn, pinned by the test below.
  • Step 1: Write the failing tests (append)

func _one_bolt_pool(vfx: float, rungs: int, ctx: String = "blaster") -> ProjPool:
var pool := ProjPool.new(8)
pool.add_proj(Vector2(5, 5), Vector2(300, 0), 4.0, 0.7, 1.0, 0, 0, 0, 0.0, 0.0, ctx, vfx, rungs)
return pool
func _bolts() -> PlayerBoltRenderer:
var br := PlayerBoltRenderer.new()
add_child_autofree(br)
return br
const BIG := Rect2(-1.0e5, -1.0e5, 2.0e5, 2.0e5)
func test_bolts_pack_vfx_code_kind_and_life() -> void:
var br := _bolts()
br.sync_bolts(_one_bolt_pool(1.3, 0b01), PackedColorArray([Color.RED]), BIG)
var b := br.debug_buffer()
assert_almost_eq(b[12], 1.3, 1e-5)
assert_eq(br.accent_codes()[int(b[13])], ElementAccents.code("blaster", 0b01, true), "custom.y = slot -> code")
assert_eq(int(b[14]), ProjPool.KIND_BLASTER)
assert_almost_eq(b[15], 0.7, 1e-4, "remaining life mod 1 (noise seed, half-float safe)")
func test_bolts_cull_offscreen() -> void:
var pool := _one_bolt_pool(0.0, 0)
pool.add_proj(Vector2(5000, 5000), Vector2.RIGHT, 4.0, 1.0, 1.0)
var br := _bolts()
br.sync_bolts(pool, PackedColorArray([Color.RED, Color.RED]), Rect2(0, 0, 100, 100))
assert_eq(br.multimesh.visible_instance_count, 1)
func test_bolt_growth_stays_inside_the_budget() -> void:
var br := _bolts()
br.sync_bolts(_one_bolt_pool(0.0, 0), PackedColorArray([Color.RED]), BIG)
var hx0 := br.debug_buffer()[0] - PlayerBoltRenderer.PAD
br.sync_bolts(_one_bolt_pool(0.999, 0), PackedColorArray([Color.RED]), BIG)
var hx1 := br.debug_buffer()[0] - PlayerBoltRenderer.PAD
assert_gt(hx1, hx0, "grows with power")
assert_lte(hx1 / hx0, 1.5 + 1e-4, "<= 1.5x grow budget")
func test_bolt_capacity_grows_with_the_pool() -> void:
var pool := ProjPool.new(512)
var cols := PackedColorArray()
for i in range(300):
pool.add_proj(Vector2(i, 0), Vector2.RIGHT, 4.0, 1.0, 1.0)
cols.append(Color.WHITE)
var br := _bolts()
br.sync_bolts(pool, cols, BIG)
assert_gte(br.capacity, 300)
assert_eq(br.multimesh.visible_instance_count, 300)
func test_main_draws_bullets_with_the_bolt_renderer() -> void:
var src := FileAccess.get_file_as_string("res://main.gd")
assert_true(src.contains("PlayerBoltRenderer.new()"), "main.gd builds the v2 bullet renderer")
assert_true(src.contains("bolt_renderer.sync_bolts("), "and syncs it")
func _blaster_ev(kind: String, evolved: bool) -> Dictionary:
return {"kind": kind, "pos": Vector2.ZERO, "dir": Vector2.RIGHT, "element": 0,
"wid": "blaster", "power": 0.5, "evolved": evolved, "rungs": 0}
func test_muzzle_flash_is_essential_weapon_feedback() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.enabled = false
fx.consume([_blaster_ev("blaster_fire", false)])
assert_eq(fx.v2_look("blaster").active_count(), 1)
assert_almost_eq(fx.v2_look("blaster").debug_buffer()[15], 0.0, 1e-6, "mode 0 = muzzle")
func test_impact_sparks_are_juice() -> void:
VisualLook.weapon_fx_v2 = true
var fx := _make()
fx.enabled = false
fx.consume([_blaster_ev("bolt_impact", true)])
assert_eq(fx.v2_look("blaster").active_count(), 0, "L5 drops impact sparks")
fx.enabled = true
fx.consume([_blaster_ev("bolt_impact", true)])
assert_eq(fx.v2_look("blaster").active_count(), 1)
assert_almost_eq(fx.v2_look("blaster").debug_buffer()[15], 1.0, 1e-6, "mode 1 = impact")
func test_v2_off_blaster_events_draw_nothing_new() -> void:
VisualLook.weapon_fx_v2 = false
var fx := _make()
fx.consume([_blaster_ev("blaster_fire", true), _blaster_ev("bolt_impact", true)])
assert_eq(fx.v2_look("blaster").active_count(), 0, "legacy had no muzzle flash / impact sparks")
  • Step 2: Run -gselect=test_weapon_fx_looks. Expected: FAIL.

  • Step 3: Write fx/weapons/player_bolt_renderer.gd

class_name PlayerBoltRenderer
extends WeaponFxLayer
# Player bullets v2 (spec § Batch 1, "Blaster (and all bullets)"): every player bullet as a tapered
# plasma bolt that grows with ITS OWN weapon's power (ProjPool.vfx, captured at add_proj), with the
# firing weapon's crystal accents; evolved -> Auto-Cannon twin spiralling cores. Replaces proj_renderer
# while fx_layer.v2_active() (main.gd swaps them per frame). Per-kind shapes for scatter/turret/
# momentum/aim come in batch 4 -- kind rides in custom.z already (aim keeps a plain dot under the lance).
const SHADER_PATH := "res://fx/weapons/player_bolt.gdshader"
const BASE_LEN := 18.0 # == main.PLAYER_BULLET_LEN
const BASE_WID := 2.5 * SwarmRenderer.NOVA_BULLET_WIDTH_MULT # == main's Nova-look bullet width
const LEN_GROW := 0.5 # tail 1.5x at power 1 (grow budget)
const PAD := 3.0 # quad room for the glow sheath
const EVOLVED_WID := 1.6 # twin cores need a wider quad
var _codes := {} # ctx -> {rungs * 2 + evolved: accent code}; a handful of entries, reused every frame
# (the code -> SLOT mapping is rebuilt every sync: accent_reset() then accent_slot(), see sync_bolts)
func _init() -> void:
super(load(SHADER_PATH) as Shader, 256)
func sync_bolts(pool: ProjPool, colors: PackedColorArray, cull: Rect2) -> void:
ensure_capacity(pool.count)
accent_reset() # persistent look: every bolt is re-put each frame, so slots only cover live codes
begin()
for i in range(pool.count):
var pos: Vector2 = pool.pos[i]
if not cull.has_point(pos):
continue
var v: Vector2 = pool.vel[i]
var vfx: float = pool.vfx[i]
var evolved := vfx >= 1.0
var power := vfx - 1.0 if evolved else vfx
var half := Vector2(BASE_LEN * 0.5 * (1.0 + LEN_GROW * power) + PAD,
BASE_WID * (EVOLVED_WID if evolved else 1.0) + PAD)
put(pos, v.angle() if v.length_squared() > 0.0001 else 0.0, half, colors[i],
Vector4(vfx, _slot(pool.context[i], pool.rungs[i], evolved), float(pool.kind[i]), fposmod(pool.data[i], 1.0)))
commit()
func _slot(ctx: String, rungs_v: int, evolved: bool) -> float:
if not _codes.has(ctx):
_codes[ctx] = {}
var per: Dictionary = _codes[ctx]
var key := rungs_v * 2 + (1 if evolved else 0)
if not per.has(key):
per[key] = ElementAccents.code(ctx, rungs_v, evolved)
return accent_slot(int(per[key]))
  • Step 4: Write fx/weapons/player_bolt.gdshader
shader_type canvas_item;
render_mode blend_add, unshaded;
#include "res://fx/weapons/element_accents.gdshaderinc"
// Player bullets v2 (weapon FX v2 batch 1). One instance per bullet, +x = travel, quad half-size =
// (length/2 grown by power + PAD, width + PAD), so p spans the quad in [-1, 1] on both axes.
// COLOR = element tint (raw, a unused). INSTANCE_CUSTOM = (vfx = power + 1 if evolved, accent slot,
// ProjPool kind, remaining life mod 1 -> per-bullet noise seed).
// Tapered plasma bolt: white-hot head, element sheath, tail thinning out. Evolved (Auto-Cannon): twin
// cores spiralling round the travel axis. Kind 5 (Rift Bolt / aim) stays a plain round dot -- the aim
// lance overlay draws on top of it (batch 4 owns the Rift Bolt look).
varying vec4 v_col;
varying vec4 v_cus;
void vertex() {
v_col = COLOR;
v_cus = INSTANCE_CUSTOM;
}
void fragment() {
vec2 p = (UV - 0.5) * 2.0;
vec3 tint = v_col.rgb;
int kind = int(v_cus.z + 0.5);
if (kind == 5) {
float r = length(p);
vec3 dot_col = mix(tint, vec3(1.0), 0.6) * smoothstep(0.55, 0.2, r) * 1.4 + tint * exp(-r * r / 0.3) * 0.5;
COLOR = vec4(dot_col, 1.0);
} else {
bool evolved = v_cus.x >= 1.0;
float seed = v_cus.w * 7.3;
float head = 0.62;
float tail = -0.95;
float along = clamp((head - p.x) / (head - tail), 0.0, 1.0); // 0 at the head, 1 at the tail
float taper = mix(0.55, 0.05, along);
float dy = abs(p.y);
float inside_x = step(tail, p.x) * smoothstep(head + 0.3, head, p.x);
float core = smoothstep(taper * 0.55, 0.0, dy) * inside_x;
float sheath = exp(-dy * dy / (taper * taper * 1.2 + 0.01)) * inside_x;
float head_glow = exp(-(pow(p.x - head, 2.0) / 0.03 + dy * dy / 0.2));
vec3 col = mix(tint, vec3(1.0), 0.75) * core * 1.7 + tint * sheath * 0.9 + vec3(1.0, 0.97, 0.9) * head_glow * 1.3;
float mask = clamp(core + sheath * 0.5, 0.0, 1.0);
if (evolved) {
float off = 0.38 * sin(p.x * 9.0 - TIME * 26.0 + seed);
float c2 = (smoothstep(0.16, 0.0, abs(p.y - off)) + smoothstep(0.16, 0.0, abs(p.y + off))) * inside_x;
col += mix(tint, vec3(1.0), 0.5) * c2 * 1.3 * (1.0 - along * 0.6);
mask = clamp(mask + c2 * 0.5, 0.0, 1.0);
}
int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code
vec4 acc = accents_all(acode, p, mask, clamp(core, 0.0, 1.0), 0.5, seed, TIME);
col = col * acc.a + acc.rgb;
if (evolved) {
vec4 em = accent_motif(evolve_accent(acode), p, mask, clamp(core, 0.0, 1.0), 1.0, 0.5, seed + 3.0, TIME);
col = col * em.a + em.rgb;
}
COLOR = vec4(col, 1.0);
}
}
  • Step 5: Write fx/weapons/blaster_flash_look.gd and blaster_flash.gdshader
class_name BlasterFlashLook
extends WeaponFxLayer
# Blaster v2 event looks (spec § Batch 1): a muzzle flash on every "blaster_fire" (essential weapon
# feedback) and, for the evolved Auto-Cannon, impact sparks on every "bolt_impact" (juice). One layer,
# mode in custom.w (0 muzzle, 1 impact). Grows with power (<= 1.5x); evolved = twin-barrel flash.
const SHADER_PATH := "res://fx/weapons/blaster_flash.gdshader"
const CAP := 32
const MUZZLE_HALF := 16.0
const MUZZLE_GROW := 0.5
const MUZZLE_LIFE := 0.07
const MUZZLE_OFFSET := 12.0 # px ahead of the pilot centre (the ship's nose)
const IMPACT_HALF := 26.0
const IMPACT_LIFE := 0.18
func _init() -> void:
super(load(SHADER_PATH) as Shader, CAP)
func _custom(ev: Dictionary, seed: float, mode: float) -> Vector4:
var evolved := bool(ev.get("evolved", false))
return Vector4(float(ev.get("power", 0.0)) + (2.0 if evolved else 0.0),
accent_slot(ElementAccents.code("blaster", int(ev.get("rungs", 0)), evolved)), seed, mode) # half-float safe (Task 7 fix)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool:
var dir: Vector2 = ev.get("dir", Vector2.RIGHT)
var power := float(ev.get("power", 0.0))
return spawn(ev["pos"] + dir * MUZZLE_OFFSET, dir.angle(), Vector2.ONE * MUZZLE_HALF * (1.0 + MUZZLE_GROW * power),
tint, _custom(ev, seed, 0.0), MUZZLE_LIFE)
func spawn_impact(ev: Dictionary, tint: Color, seed: float) -> bool:
var dir: Vector2 = ev.get("dir", Vector2.RIGHT)
return spawn(ev["pos"], dir.angle(), Vector2.ONE * IMPACT_HALF, tint, _custom(ev, seed, 1.0), IMPACT_LIFE)
shader_type canvas_item;
render_mode blend_add, unshaded;
#include "res://fx/weapons/element_accents.gdshaderinc"
// Blaster v2 flashes (weapon FX v2 batch 1). COLOR = (tint, a = life t). INSTANCE_CUSTOM =
// (power + 2*evolved, accent slot, seed, mode). +x = the shot direction.
// mode 0 muzzle: a short hot cone from the nose (twin cones when evolved).
// mode 1 impact (Auto-Cannon only): 6 spark streaks fanning +-60 deg along the shot + a hit flash.
varying vec4 v_col;
varying vec4 v_cus;
void vertex() {
v_col = COLOR;
v_cus = INSTANCE_CUSTOM;
}
float seg_dist(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0);
return length(pa - ba * h);
}
void fragment() {
float t = clamp(v_col.a, 0.0, 1.0);
bool evolved = v_cus.x >= 1.5;
float seed = v_cus.z;
vec2 p = (UV - 0.5) * 2.0;
vec3 tint = v_col.rgb;
vec3 col = vec3(0.0);
float mask = 0.0;
if (v_cus.w < 0.5) {
vec2 q = vec2(p.x + 0.7, p.y); // cone origin near the back of the quad
float off = evolved ? 0.3 : 0.0;
for (int b = 0; b < 2; b++) {
if (b == 1 && !evolved) {
break;
}
vec2 qb = vec2(q.x, q.y - off * (b == 0 ? 1.0 : -1.0));
float cone = step(0.0, qb.x) * smoothstep(1.4, 0.0, qb.x) * smoothstep(0.1 + qb.x * 0.45, 0.0, abs(qb.y));
col += mix(tint, vec3(1.0), 0.5) * cone * 1.6;
mask += cone;
}
col += vec3(1.0, 0.97, 0.9) * exp(-dot(q, q) / 0.04) * 2.4;
col *= 1.0 - t;
} else {
for (int k = 0; k < 6; k++) {
float h1 = hash21(vec2(float(k), seed));
float ang = mix(-1.05, 1.05, (float(k) + h1 * 0.6) / 6.0);
float spd = mix(0.6, 1.0, hash21(vec2(seed, float(k) + 5.0)));
vec2 dir = vec2(cos(ang), sin(ang));
float r0 = t * spd;
float dist = seg_dist(p, dir * max(r0 - 0.25, 0.0), dir * r0);
float s = exp(-dist * dist / 0.002);
col += mix(tint, vec3(1.0), 0.6) * s * 2.0 * (1.0 - t);
mask += s;
}
col += vec3(1.0, 0.95, 0.85) * exp(-dot(p, p) / 0.03) * (1.0 - smoothstep(0.0, 0.3, t)) * 2.0;
}
mask = clamp(mask, 0.0, 1.0);
int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code
vec4 acc = accents_all(acode, p, mask, mask, t, seed, TIME);
col = col * acc.a + acc.rgb;
COLOR = vec4(col, 1.0);
}
  • Step 6: FxManager + main.gd wiring

fx/fx_manager.gd:

  • Add "blaster_fire": true to ESSENTIAL_KINDS. The muzzle flash is weapon feedback. With v2 off, blaster_fire has no visual arm, so this changes nothing there.
  • V2_LOOKS gets "blaster": "BlasterFlashLook".
  • _ready: _add_v2_layer(BlasterFlashLook.new(), ["blaster"]).
  • New consume arms, next to "burst":
"blaster_fire":
# v2 muzzle flash (weapon feedback). Legacy had none -- the event was audio-only.
if v2_active() and V2_LOOKS.has("blaster"):
v2_look("blaster").spawn_event(ev, ElementPalette.color_for(_content, ev.get("element", -1)), _fx_rng.randf())
"bolt_impact":
# v2 Auto-Cannon impact sparks -- disposable juice (off at L5, counts against the frame cap).
if enabled and v2_active() and V2_LOOKS.has("blaster"):
v2_look("blaster").spawn_impact(ev, ElementPalette.color_for(_content, ev.get("element", -1)), _fx_rng.randf())

main.gd. Var, after var proj_renderer: SwarmRenderer:

var bolt_renderer: PlayerBoltRenderer # weapon FX v2 player bullets; swapped against proj_renderer per frame

Setup, right after add_child(proj_renderer) (line 1068) and before the aim-lance overlay, so the lance still draws on top:

# Weapon FX v2: every player bullet as a plasma bolt grown by its own weapon's power (Auto-Cannon
# twin cores when evolved). Drawn instead of proj_renderer while fx_layer.v2_active().
bolt_renderer = PlayerBoltRenderer.new()
add_child(bolt_renderer)

Sync. Replace proj_renderer.sync(sim.projectiles, _proj_colors(), null, cull) with:

var bolts_v2 := fx_layer.v2_active() and FxManager.V2_LOOKS.has("blaster")
proj_renderer.visible = not bolts_v2 # hides its halo/hot-core children too (visible cascades)
if bolts_v2:
bolt_renderer.sync_bolts(sim.projectiles, _proj_colors(), cull)
else:
bolt_renderer.clear()
proj_renderer.sync(sim.projectiles, _proj_colors(), null, cull)

Check that nothing else sets proj_renderer.visible: /usr/bin/grep -n "proj_renderer.visible" main.gd render/*.gd should list only the new line. Then delete "blaster" from PENDING_V2_LOOKS and enable the "blaster" arm and _blaster_cell in tools/weapon_vfx/compare.gd.

  • Step 7: Tests, determinism, grid

Run --import, -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_fx_manager, -gselect=test_main, -gselect=test_swarm_renderer, -gselect=test_determinism. Then:

Terminal window
WEAPON=blaster godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobile
WEAPON=blaster V2=1 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
WEAPON=blaster V2=1 EVOLVE=1 LEVELS=5 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
WEAPON=blaster V2=0 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn
git diff --stat project.godot; git checkout -- project.godot

Check:

  • p0 bolts read at least as clearly as v1 at gameplay zoom.

  • Tails lengthen with each +kinetic row, and kinetic chevrons appear.

  • EVOLVED shows two cores twisting, void holes, impact sparks at the hit side, and a twin muzzle flash.

  • In-game with a busy horde, bullets never read as enemy shots (Chris’s recurring readability concern).

  • The Rift Bolt lance still sits on its shots (WEAPON=blaster with the aim stick isn’t driven by the harness; check on device in Task 16).

  • Step 8: Commit — feat(fx-v2): player bolt renderer (grows per weapon, Auto-Cannon twin cores), muzzle flash, impact sparks


Task 14: Perf comparison (v1 vs v2, all batch-1 weapons evolved, L0) + the L3 fallback

Section titled “Task 14: Perf comparison (v1 vs v2, all batch-1 weapons evolved, L0) + the L3 fallback”

Files:

  • Modify: tools/perf_probe/perf_probe.gd (flags + GPU/CPU timing)
  • Create: docs/research/2026-09-29-weapon-fx-v2-batch1-perf.md (results; needs a title: frontmatter line for the docs site)

Interfaces:

  • Consumes: VisualLook.weapon_fx_v2, WeaponVfx.weapon, main.quality_manager, main.sim.player.arsenal.

  • Produces: new perf_probe flags --weapons=a,b,c --evolve --v2=0|1 --level=N --seed=N, printing [perf_probe] ... TOTAL draws=… gpu_ms=… cpu_ms=….

  • Step 1: Add the flags

In _ready(), add to the var block: var weapons: PackedStringArray = [], var evolve := false, var v2 := -1, var level := -1, var run_seed := -1. Then add to the arg loop:

elif a.begins_with("--weapons="):
weapons = a.substr(10).split(",", false)
elif a == "--evolve":
evolve = true
elif a.begins_with("--v2="):
v2 = int(a.substr(5))
elif a.begins_with("--level="):
level = int(a.substr(8))
elif a.begins_with("--seed="):
run_seed = int(a.substr(7))

Right before main._on_mode_chosen(_mode):

if v2 >= 0:
VisualLook.weapon_fx_v2 = v2 == 1
if run_seed >= 0:
seed(run_seed) # main picks run_seed = randi(): seeding the global RNG pins the run (same spawns v1 vs v2)

Right after main.sim.player.dev_invuln = true:

if level >= 0:
main.quality_manager.auto = false
main.quality_manager.set_level(level)
var ars: PilotArsenal = main.sim.player.arsenal
for wid in weapons:
if WeaponVfx.weapon(ars, wid) == null:
push_error("[perf_probe] unknown weapon %s" % wid)
continue
if not ars.active_weapon_ids.has(wid):
ars.active_weapon_ids.append(wid)
ars.weapon_levels[wid] = 5
if evolve:
WeaponVfx.weapon(ars, wid).call("evolve")
RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true)

Add a timing sampler, and print it after the existing TOTAL line:

func _sample_times() -> Vector2:
var rid := get_viewport().get_viewport_rid()
var cpu := 0.0
var gpu := 0.0
for i in SAMPLE_FRAMES:
await get_tree().process_frame
cpu += RenderingServer.viewport_get_measured_render_time_cpu(rid)
gpu += RenderingServer.viewport_get_measured_render_time_gpu(rid)
return Vector2(cpu, gpu) / float(SAMPLE_FRAMES)
var times := await _sample_times()
print("[perf_probe] v2=%s level=%d weapons=%s evolve=%s gpu_ms=%.2f cpu_ms=%.2f" % [VisualLook.weapon_fx_v2,
main.quality_manager.level, ",".join(weapons), evolve, times.y, times.x])

Also update the header comment’s flag list.

  • Step 2: Run the matrix (windowed, foreground, 3 runs each; compare medians)
Terminal window
W=--weapons=blaster,orbit,blade,scythe,nova
for v in 0 1; do for i in 1 2 3; do
godot --path . --rendering-method mobile --always-on-top res://tools/perf_probe/perf_probe.tscn -- \
--mode=survival $W --evolve --v2=$v --level=0 --seed=1234 --no-breakdown 2>&1 | grep "\[perf_probe\]"
done; done
# L3 fallback: v2 on, but the tier must hand back the legacy looks
godot --path . --rendering-method mobile --always-on-top res://tools/perf_probe/perf_probe.tscn -- \
--mode=survival $W --evolve --v2=1 --level=3 --seed=1234 --no-breakdown --shot=reports/fxv2/l3.png 2>&1 | grep "\[perf_probe\]"
git diff --quiet project.godot || git checkout -- project.godot

Pass criteria (spec § Verification: “no regression beyond noise vs v1 on the same seed”):

  • Draws: v2 ≤ v1 + 5. Each v2 look is one draw call, and idle looks are hidden.
  • gpu_ms: v2 median ≤ v1 median × 1.10, or within the run-to-run spread of the v1 runs, whichever is larger.
  • L3: the draw count and l3.png match a v1 run. There are no v2 bolts, and the orbit crystals are sprites. Read the PNG.

If gpu_ms regresses, cut the cost in this order, re-measuring after each step, and record which step fixed it:

  1. player_bolt.gdshader accents (skip accents_all when v_cus.y == 0). This is the most-instanced look.
  2. The ember loop count in nova_burst.gdshader.
  3. The fbm in orbit_shards.gdshader’s ring branch.
  • Step 3: Write the results doc

docs/research/2026-09-29-weapon-fx-v2-batch1-perf.md:

---
title: Weapon FX v2 batch 1 — perf comparison
---
# Weapon FX v2 batch 1 — perf comparison (2026-09-29)
Setup: survival, seed 1234, blaster+orbit+blade+scythe+nova all evolved, quality L0, MacBook Pro M5,
`--rendering-method mobile`, perf_probe `--no-breakdown`, 3 runs each (median reported).
| Config | draws | gpu_ms | cpu_ms |
|---|---|---|---|
| v1 (legacy looks) | <fill from run> | <fill> | <fill> |
| v2 | <fill> | <fill> | <fill> |
| v2 at L3 (fallback) | <fill> | <fill> | <fill> |
Verdict: <pass/fail against the plan's criteria + any cost cut applied>.

Every <fill…> above is a slot for measured output from Step 2, not a plan placeholder. Commit the doc only once every slot holds a real number.

  • Step 4: Commit — perf(fx-v2): probe flags + batch-1 v1/v2/L3 comparison

Task 15: Docs, release queue, bh-add-content skill, memory

Section titled “Task 15: Docs, release queue, bh-add-content skill, memory”

Files:

  • Modify: docs/architecture/rendering-and-performance.md (§ Plasma Nova at lines 75–81 → a new § Weapon FX v2)

  • Modify: docs/architecture/weapons-and-buildcraft.md (a “Visuals” note in the weapon-wiring section)

  • Modify: docs/RELEASE-1.0.1.md §3b (line 61)

  • Modify: .claude/skills/bh-add-content/SKILL.md (gitignored, so edit in place; it is not committed)

  • Modify: .claude/skills/bh-vfx-iteration/SKILL.md (harness paths)

  • Modify: memory bullet-heaven-weapon-fx-v2.md

  • Step 1: rendering-and-performance.md

Replace the Plasma Nova section with a “Weapon FX v2” section covering these points (it’s the current source of truth, not a changelog):

  • Data. WeaponVfx stamps wid/power/evolved/rungs on every weapon fx_event. power = 1 - 0.72^(level picks + crystal rungs). Bullets carry ProjPool.vfx/rungs/kind, which are render-only and never checksummed.

  • Render. Each look is a WeaponFxLayer (one MultiMesh, one draw call, stride-16 buffer). The per-look COLOR/INSTANCE_CUSTOM layouts are in the plan’s table; copy that table in.

  • Dispatch. FxManager.V2_LOOKS maps wid → look. v2_active() = VisualLook.weapon_fx_v2 and QualityManager.weapon_fx_v2_on(level) (L0–L2). The persistent looks (OrbitShardsLook, PlayerBoltRenderer) live in main.gd and swap per frame.

  • Accents. fx/weapons/element_accents.gdshaderinc and the ElementAccents twin, with the packing and the generic (p, mask, edge, s, t, seed, now) contract that batch 6 reuses for enemies.

  • Plasma Nova. PlasmaNovaNode in fx/weapons/ is the v1 / L3+ nova.

  • Harnesses. tools/weapon_vfx/{compare,ingame,motifs}.

  • Guards. tests/test_weapon_fx_guards.gd checks guards 1–6. Say what each guard catches.

  • Gotchas. The QuadMesh UV-y flip note. Use : source_color for colour uniforms. Nested #include needs #ifndef guards.

  • Step 2: weapons-and-buildcraft.md

Add under the weapon-wiring notes:

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. The gate fails otherwise. See docs/architecture/rendering-and-performance.md § Weapon FX v2.

  • Step 3: docs/RELEASE-1.0.1.md §3b line 61

Tick the “Weapon FX v2 — foundation + batch 1” entry with ✅ DONE (<merge sha>) only in Task 16 after merge. In this task, append — plan docs/superpowers/plans/2026-09-29-weapon-fx-v2-foundation-batch1.md, implementation on branch <branch>.

  • Step 4: Skills (local, gitignored)

In .claude/skills/bh-add-content/SKILL.md, add a checklist step in the weapon section:

“Stamp every fx_event: sim.fx_events.append(WeaponVfx.stamp({...}, pilot.arsenal, "<wid>")), one line. Bullets pass WeaponVfx.bullet_vfx(pilot.arsenal, "<wid>"), WeaponVfx.rungs(pilot.arsenal, "<wid>") after the ctx arg of add_proj. Add the wid to PENDING_V2_LOOKS in tests/test_weapon_fx_guards.gd with the batch that will give it a look, or build its look (FxManager.V2_LOOKS). Any weapon-local Array[Dictionary] zone needs a renderer. tests/test_weapon_fx_guards.gd enforces all of this.”

In .claude/skills/bh-vfx-iteration/SKILL.md, point the tier-1/3 examples at tools/weapon_vfx/compare.gd (WEAPON=<wid>) and ingame.gd. Keep the existing gotchas.

  • Step 5: Memory

Update /Users/chris/.claude/projects/-Users-chris-Claude-bullet-heaven/memory/bullet-heaven-weapon-fx-v2.md:

  • Next step becomes “awaiting Chris grid/device sign-off (plan Task 16)”. Once Task 16 is done, it becomes “batch 2 design pass (Beam + Railgun)”.

  • Record: “Enemy effects stay visual only — Chris, 2026-09-29 (resolves the open question)”.

  • Update the MEMORY.md pointer line hook to match.

  • Step 6: Commit (the docs only; skills and memory are outside git) — docs(fx-v2): architecture, buildcraft wiring rule, release queue note


Task 16: ⛔ STOP — Chris signs off, then flip the default, fold plasma_nova, gate, merge, push

Section titled “Task 16: ⛔ STOP — Chris signs off, then flip the default, fold plasma_nova, gate, merge, push”

Files:

  • Modify: render/visual_look.gd, fx/fx_manager.gd (nova arm), ui/debug_settings_panel.gd, tests/test_plasma_nova.gd, tests/test_fx_manager.gd, docs/RELEASE-1.0.1.md
  • Delete: tools/nova_vfx/ (superseded by tools/weapon_vfx/ WEAPON=nova, whose v1 row is the Plasma Nova)

Interfaces:

  • Consumes: everything above.

  • Produces: VisualLook.weapon_fx_v2 := true. VisualLook.plasma_nova no longer exists. With v2 off (or at L3+), nova = PlasmaNovaNode, and the legacy nova ring is gone from the nova arm (the ring primitive stays for burst/reactions).

  • Step 1: ⛔ STOP — present for sign-off and wait

Send Chris the PNGs with SendUserFile:

  • the five compare_<wid>.png grids;
  • motifs.png;
  • the ingame_<wid>_{v1,v2,v2_evo}.png sets;
  • the Task 14 perf table.

For each weapon, say in one line what changed per row. Ask him to toggle Debug Settings → “Weapon FX v2 (A/B)” in the Dev app (scripts/dev/install-dev.sh). Device installs are on Chris’s own devices, so get his same-day OK before installing. Do not continue past this step until Chris approves each weapon. A weapon he rejects goes back to its task’s shader iteration loop. A weapon he defers leaves V2_LOOKS and returns to PENDING_V2_LOOKS, and the rest proceeds.

  • Step 2: Flip the default + fold plasma_nova (after approval)

render/visual_look.gd: delete the plasma_nova var and its comment block. Set static var weapon_fx_v2 := true, and change its comment’s last sentence to:

Default true since Chris signed off batch 1 on ; false (and quality L3+) = the legacy looks, where the Nova is the Plasma Nova fireball (PlasmaNovaNode).

fx/fx_manager.gd, "nova" arm:

"nova":
var ncol := ElementPalette.color_for(_content, ev.get("element", -1))
if v2_active() and V2_LOOKS.has("nova"):
v2_look("nova").spawn_event(ev, ncol, _fx_rng.randf()) # Nova + Scar Tissue discharge
else:
_spawn_plasma_nova(ev["pos"], float(ev.get("radius", 100.0)), ncol) # v1 / L3+ look

ui/debug_settings_panel.gd: delete the "plasma_nova" match arm, _graphics_state["plasma_nova"] and the "Plasma Nova (A/B)" row.

tests/test_plasma_nova.gd:

  • It now tests the v1/L3+ nova. Change _was to save and restore VisualLook.weapon_fx_v2, and in before_each set VisualLook.weapon_fx_v2 = false.
  • Delete test_plasma_nova_is_the_default_look, the plasma-off→ring test (line ~39), and test_debug_row_flips_plasma_nova_switch.
  • In the remaining tests, replace every VisualLook.plasma_nova = true line with nothing.
  • Add:
func test_weapon_fx_v2_is_the_default() -> void:
var src := FileAccess.get_file_as_string("res://render/visual_look.gd")
assert_string_contains(src, "static var weapon_fx_v2 := true", "v2 ships as default after sign-off")
assert_false(src.contains("static var plasma_nova"), "plasma_nova folded into weapon_fx_v2")

tests/test_fx_manager.gd: it’s the legacy-look suite, so save and restore VisualLook.weapon_fx_v2 and set it false in before_each, replacing _plasma_was. The four legacy nova-ring tests become plasma-node tests:

  • test_nova_event_activates_one_ring → assert fx.nova_active_count() == 1;
  • test_nova_ring_expires_after_max_life → advance 0.5s (> PlasmaNovaNode.LIFE 0.46) and assert nova_active_count() == 0;
  • test_nova_ring_pool_does_not_grow → assert nova_active_count() <= FxManager.NOVA_POOL_SIZE;
  • test_disabled_still_spawns_nova_ring → assert nova_active_count() == 1.

Rename each test to say “plasma” instead of “ring”, and delete the VisualLook.plasma_nova = false lines.

Delete tools/nova_vfx/ with git rm -r tools/nova_vfx. Then update every reference:

Terminal window
/usr/bin/grep -rn "nova_vfx\|VisualLook.plasma_nova\|\"plasma_nova\"\|Plasma Nova (A/B)" --include='*.gd' --include='*.md' --include='*.tscn' . | grep -v "^./docs/superpowers/\|^./docs/research/\|^./docs/RELEASE-0.1\|^./.godot/"

This must return nothing. The historical specs, plans, research docs and closed release notes keep their text. Fix docs/architecture/rendering-and-performance.md and the bh-vfx-iteration skill if they appear.

  • Step 3: Full gate
Terminal window
git fetch origin && git log HEAD..origin/main --oneline # rebase first if anything landed
scripts/gate.sh

Expected: the gate is green, the test-count guard reflects the 5 new test files, and test_determinism_survival passes unchanged.

Now that the default is v2, a test that asserts on a legacy renderer (proj_renderer, the orbit sprites, slash_active_count, ring_active_count for nova) will fail. The fix is always the same: save VisualLook.weapon_fx_v2 in that test’s before_each, set it false, and restore it in after_each. Never loosen the assertion.

  • Step 4: Device check

With Chris’s same-day OK, run scripts/dev/install-dev.sh to the Dev app. Chris plays 2 runs on Apple TV and iPhone.

  • Confirm the Rift Bolt lance still sits on its shots.
  • Confirm co-op isn’t broken by the orbit look. Co-op is gated off; P1 only is the check.
  • Confirm the L3 fallback: drop the tier in Debug Settings and the looks swap to v1.

Record any issue in docs/RELEASE-1.0.1.md before fixing it (session-start rule).

  • Step 5: Tick the queue, commit, merge, push

In docs/RELEASE-1.0.1.md §3b line 61, mark “Weapon FX v2 — foundation + batch 1” ✅ DONE (<sha>). Commit with feat(fx-v2): weapon FX v2 default on; plasma_nova folded in (batch 1 signed off by Chris). Merge to main, then git push origin main immediately after the merge. Finally, update memory bullet-heaven-weapon-fx-v2 to “batch 1 shipped; next: batch 2 design pass (Beam + Railgun, bug 2)”.


Spec requirement Task
WeaponVfx.invested/power/stamp, levels + thresholds counted, curve 1
every weapon event carries wid/power/evolved(/rungs) 3 (guards 1–2)
ProjPool vfx/rungs/kind, trailing add_proj arg, splits inherit, swap-remove aligned 2
rungs bitmask matches thresholds_done per pilot 1 (test_rungs_are_per_pilot)
looks under fx/weapons/, one class + shader each; FxManager stays a dispatcher; _PlasmaNovaNode moved first 5, 9–13
PlayerBoltRenderer replaces the player SwarmRenderer when v2 is on 13
grow ≤ 1.5×, interiors clear, tint everywhere shaders 9–13 (bolt budget pinned by test in 13); grids
quality fallback: v2 at L0–L2, legacy at L3+ 4 (tier + test), 14 (L3 check)
VisualLook.weapon_fx_v2 + Debug row; plasma_nova folds in 4, 16
crystal accents include + GDScript twin; batch-1 motifs; evolved takes the evolve element; generic shape mask for batch 6 7
Blaster / Orbit / Blade (bug 1) / Scythe / Nova + Scar Tissue at its own power 13 / 12 / 9 / 10 / 11
slash look honours arc (360° full ring) 9 (v2 + legacy fallback)
pools bounded, no leaks; v2 off = legacy node types 9–11, 13
determinism unchanged 2, 3, 13, 16
compare grid (rows p0 / each rung / evolved + v1, frames, real HDR env); ingame harness 8 (+ arms in 12, 13)
Chris eyeballs every grid before merge; Dev-app device check 16 (STOP)
perf soak all evolved at L0 vs v1; L3 fallback 14
gate green 16
memory guard tests: stamped events / evolved differs / zone arrays rendered (so bh-add-content fails the gate if invisible or non-scaling) 3 (guards 1–4), 4 (guard 5 look registry), 7 (guard 6 accent sync), 15 (skill text)
out of scope: sound, sim/balance, enemy/boss FX (visual only, Chris 2026-09-29), evolution moment Global Constraints

User decisions (Chris, 2026-09-29):

  • The spec docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md is approved as written.
  • Enemy effects stay visual only. Crystal accents on enemies (batch 6) never change sim or balance. This resolves the spec’s open question.
  • Scope is the foundation plus batch 1 (Blaster, Orbit, Blade, Scythe, Nova + Scar Tissue). Batches 2–6 each get their own plan later.

Authorised:

  • All code, test and doc work in Tasks 1–15 on a feature branch or worktree.
  • Commits.
  • Windowed Godot harness runs on the Mac. Afterwards, run git checkout -- project.godot.

Needs a same-day OK from Chris:

  • The Task 16 STOP (grid and device sign-off).
  • Any scripts/dev/install-dev.sh device install.
  • Anything that touches the store or TestFlight. That is out of scope; don’t run release.sh.
  • Pushes to main follow the project rule: git push origin main after every local merge. The merge itself only happens after the Task 16 sign-off.

Done outside this plan: nothing. Only the spec and this plan are committed.

Open questions: none blocking. Per-weapon aesthetic calls are Chris’s at the Task 16 STOP. A rejected look loops back to its task. A deferred look moves back to PENDING_V2_LOOKS.

Env gotchas:

  • Run godot --headless --path . --import after any new class_name, or GUT reports “not declared”.
  • Windowed runs rewrite project.godot. scripts/gate.sh runs git checkout -- project.godot, so commit any intentional edit first.
  • Bash grep is ugrep and honours .gitignore. Use /usr/bin/grep for the guard-list checks.
  • .claude/skills is gitignored. Task 15’s skill edits happen in the main checkout, not in a worktree that gets deleted.
  • Code-writing subagents: use bh-implementer with model: "opus". Check their claims yourself (memory bullet-heaven-subagent-model-haiku).
  • Toby may push to main. Before merging, run git fetch and rebase.

Exact next action: create a worktree or branch weapon-fx-v2-batch1 from main, then start Task 1 (sim/weapon_vfx.gd + tests/test_weapon_vfx.gd).