Status: DRAFT (2026-06-28) — investigation just started. No implementation yet.
Slug: combat-perf-npc-gun-scan
Task source: Interactive human request (Jamon, 2026-06-28). After the tree-LOS native win (~90× faster query), hunt for the next perf opportunities. Jamon profiled a live combat mission with the F4 Perf panel and shared screenshots; this worksheet captures the read and the investigation.
Three F4 Perf screenshots: one menu/briefing (world paused — ignore, dominated by
InGameDebugPanel / BriefingMapOverlay self-draw), two in live combat
(projectiles 535–594, ~4 helis + ~100 ground/air units).
Both combat frames flag (D) danger:
- avg process ~2100µs/f, avg physics ~2100µs/f → ~4.2ms scripts/frame, ~half the 60fps budget (16.67ms) before rendering.
- top process ~4.3–4.5ms, top physics ~3.8–4.2ms.
Physics process:
- GunController — ~101 calls/f, 381–413 µs/f. Scales with projectile count.
- ScanBase — ~104 calls/f, 395–396 µs/f. Radar/detection per contact.
- VehicleAI — ~97 calls/f, 199–223 µs/f (also on main-process list).
- AutoPilot — ~4 calls/f but 214–230 µs/f (~50µs/call).
- Heli3DStation — ~8 calls/f, 119–142 µs/f.
Main process:
- VehicleAI — ~100 calls/f, up to 417 µs/f; 3837µs all-time spike.
- NPCPilotActions._try_combat_actions — ~6 calls/f, 249–301 µs/f (~40µs/call — expensive per-call combat reasoning).
- Heli3D — 2265µs all-time spike (4 calls); confirm not per-frame.
- MFDMapOverlay — 5951µs all-time spike (redraw hitch, spiky not sustained).
The expensive items are not slow per call — they run ~100×/frame, scaling with unit/projectile population. Same shape as the tree-LOS win: leverage is in cutting work-per-entity (or tick frequency) or going native on a hot inner loop.
- VehicleAI — runs every NPC on BOTH process and physics every frame (~100 calls each). Biggest single target. First lever: round-robin / frame-gate (FRAME_TIMING.md) — does every vehicle need a full tick every physics frame?
- ScanBase (~400µs/f) — LOS inner cost already cut by the native tree work; remaining cost is the detection loop structure. Profile whether it's O(contacts²) / per-contact allocation. Possible native/algorithmic candidate.
- GunController (~400µs/f) — scales with
projectiles. Per-projectile physics = classic batch-update or native target (the LOS playbook). - NPCPilotActions._try_combat_actions — ~40µs/call, only ~6/f, but expensive. Check what history says (see below) before assuming it's new.
Cheapest high-leverage first move: frequency reduction (round-robin ticks) across VehicleAI / NPCPilotActions / ScanBase before reaching for C++.
This is NOT new. The v060 perf push profiled it in depth. Key worksheets:
worksheet-v060-combat-perf-diagnosis.md— the comprehensive doc (Phases 1–6).worksheet-v060-npc-combat-ai-refactor.md— split monolithicNPCCombatAI.gd(1029 lines) into 6 modules (Geometry/Weapons/Targeting/Breakoff/Autopilot/Fire).worksheet-v060-custom-perf-monitors.md—_try_combat_actionsmeasured as the #1 spike (11–20 ms max single call under high enemy count).worksheet-v060-npc-pilot-actions-instrumentation.md— timing helpers added.
Diagnosis already on record:
- Two separate clocks: physics-side (
_physics_process, 60·N Hz, sets the time-compression ceiling) vs process-side (_process, render-rate, 1X fps drag). NPC combat-AI target selection is process-side — fps drag, not the compression ceiling. - Cost is O(N) per active unit (N≈740–1000 in stress), NOT O(N²). "Death by a thousand cuts." Distance sim-LOD measured ineffective (enemies cluster in the combat zone); out-of-combat units already fully idle.
- Engagement chain decomposition:
_continue_engagementavg 199 µs/call (the real hotspot, 4×select_target's 48µs); spikes to 2770µs. Within it:update_combat_lead~80µs (load-bearing per-tick aiming, NOT safely gateable),select_weapon~64µs (pure decision logic, safely optimizable).
Source:
_try_combat_actions(p)—Scripts/NPCPilotActions.gd:168, dispatches toNPCCombatAI.try_combat()/force_disengage()+_try_targeting().try_combat(p)—Scripts/NPCCombatAI.gd:38; engagement state machine.
Already SHIPPED in v060: MissilePod._process gated to ~20Hz aim recompute
(was every frame) — saved ~150µs/frame (~58%/call).
IDENTIFIED BUT NOT SHIPPED (the lead candidate):
NPCCombatWeapons.select_weapon → weapon_priority_slots recomputes the
role-filtered slot membership from scratch every tick, though it rarely
changes. Proposed fix: cache the slot-membership list per NPC, apply only live
ammo/range gates per tick. Was scoped in v060 diagnosis but never implemented.
- Tree-LOS went native (~90× faster query). So
ScanBase's residual ~400µs/f is now mostly the detection-loop structure + raycasts, not tree LOS — re-baseline before assuming the old 2.7µs/call × 740 figure still holds.
Source: Scripts/NPCCombatWeapons.gd. select_weapon (line 18) calls
weapon_priority_slots (line 42), which calls three slot-finder helpers, one of
which (_find_rocket_slots_for_role) calls _has_other_weapon_for_role. Per
engaged tick the chain does, with N = weapon_slots (~4–8):
Inherent inefficiencies found (fixable WITHOUT caching):
dist = target.dist_to(h)computed TWICE — once inselect_weapon:22, again inweapon_priority_slots:46. Compute once, pass down.- ~4–5
Array[int]allocations per tick —weapon_priority_slotsbuilds an empty array + 3 helper-returned arrays +append_arraycopies, just to iterate once and return the first viable slot. The materialized priority list is never reused. - 3–4 separate full passes over
weapon_slots— hellfire finder, rocket finder, chaingun finder, plus_has_other_weapon_for_role's own pass. All classify the same N slots byweaponStringName. - DEAD BRANCH (correctness smell):
weapon_priority_slots:59-64— theif is_armored: ... else: ...blocks are IDENTICAL (both append rockets then chainguns).is_armoredonly matters viahellfire_worthy; the if/else does nothing. Collapse it. - Role flags (
is_missile_shooter/is_rocket_shooter/is_gun_shooter) and the weapon-type of each slot are static per loadout — recomputed every tick. (These are the CACHE candidates for the second pass.)
select_weapon only needs the FIRST viable slot in priority order
(hellfire → rocket → chaingun; within a class, lowest slot index). Fold
weapon_priority_slots into select_weapon as ONE pass over weapon_slots:
- Compute
dist,hellfire_worthy, and the three role flags once up front. - Single loop: for each slot, classify by weapon type; if ammo>0 AND dist<=weapon_range AND role-appropriate AND class gate (hellfire_worthy / rocket-salvo), record the lowest-index candidate for that class. Track "other role weapon has ammo" inline for the salvo gate (decide at loop end).
- Return hellfire candidate else rocket else chaingun, honoring the
defer_to_crewmate_hellfirerule (only call_crewmate_has_hellfire_ammolazily when rocket-shooter + hellfire_worthy, as today).
Result: ZERO per-tick allocations, ONE slot pass (down from 3–4 + nested),
dist once. This is the inherent win. THEN layer caching of the per-loadout
static bits (role flags + slot weapon-type classification) on top.
Caveat: weapon_priority_slots and has_appropriate_weapon_for are also called
elsewhere (has_appropriate_weapon_for from _continue_engagement on the no-slot
path). Keep weapon_priority_slots as a public API (or refactor its callers); the
correctness oracle must cover select_weapon AND has_appropriate_weapon_for.
Fixture: quick_mission, wingman crew (NPC-controlled), 15 hostile targets (4 types × 5 distances; some unit types failed to spawn — 15 of 20). 300k calls.
select_weapon: 35.5 µs/callweapon_priority_slots: 29.3 µs/call — the priority-array build is 83% of select_weapon's cost. Confirms the inherent waste (allocations + 3–4 passes).- Oracle: 120 configs (4 loadouts × 2 NPCs × 15 targets), live == frozen legacy. (Lower than v060's ~64µs — different machine/state + native-LOS landed since; same order of magnitude, same structure.)
- Profile baseline
select_weaponµs/call (microbench fixture — building). - Brute-force oracle test: original vs rewrite return identical slot index across many loadout × target × distance configs (incl. salvo-gate, defer, no-ammo).
- Implement single-pass allocation-free rewrite; benchmark vs baseline.
- Layer per-loadout caching of static role/slot data; benchmark again.
- Cross-review (AGENT_REVIEW.md), update NPC_COMBAT_AI_SYSTEM.md.
Surprise: the single-pass allocation-free rewrite alone moved select_weapon only
35.5 → 33.3 µs (~6%). Component breakdown (TestNPCSelectWeapon _time_component):
- role flags ×3: 18.6 µs/call (~56% of cost) — THE bottleneck.
- only_surviving (1×): 3.3 · get_station (1×): 1.9 · unit_category ×2: 2.3 · is_high_value: 2.2 · get_ammo all slots: 2.7 · dist_to: 1.0.
Each is_*_shooter() runs only_surviving() (loops h.stations, 3.3µs) +
get_station() (mission lookup). And is_missile_shooter() == is_gun_shooter() == is_npc_crew_gunner() — the SAME computation was being done twice.
Inherent win applied (no caching, no drift): call is_npc_crew_gunner() once
(covers missile+gun) + is_npc_crew_pilot() once → 3 role evals down to 2.
35.5 → 27.2 µs/call (~23%). Oracle green throughout (120 configs).
Remaining cost is still dominated by the role-flag chain: only_surviving() is
called by BOTH is_npc_crew_gunner and is_npc_crew_pilot (2× ~3.3µs), plus
their get_station() calls. Deduping that within-call requires replicating the
gunner/pilot derivation (drift risk). The principled fix is caching — these
flags are constant per NPC except on crew death/incapacitation/seat-swap, so
memoizing benefits ALL callers (ScanBase, targeting, etc.), not just select_weapon.
Candidates for the invalidation boundary:
- Per-tick memo (simplest, safe): cache
only_surviving()+ station role keyed by sim tick; recompute when tick advances. Crew state can't change mid-tick. No manual invalidation. Likely theTick/Cacheidiom already in the codebase. - Event-invalidated: cache on the NPC, clear on death/incapacitation/seat-swap. Fastest steady-state but more wiring + more ways to get stale.
- Higher-level: compute role once per NPC tick in NPCPilotActions and thread it down. Bigger refactor. Leaning per-tick memo on the Character. Confirm with Jamon before implementing.
Cache strategy (Jamon's call): per-tick memo. First attempt memoized
surviving + station index + has_human_crew together — FAILED TestWingmanEngagement
because _test_air_defense_weapon_priority flips has_human_crew=false and
synchronously calls select_weapon in the same tick (stale memo). Fix is also more
correct: memo ONLY only_surviving() (the expensive station loop) keyed by
Tick.run_tick; read the cheap, staleness-sensitive bits live —
has_human_crewmates() (just h.has_human_crew) and seat index via the
already-validity-cached get_station(). NPCPilotCharacter._only_surviving_memo().
Final (TestNPCSelectWeapon, M2 Air headless, 300k calls):
select_weapon: 35.5 → 13.3 µs/call (~2.7×)weapon_priority_slots: 29.3 → 13.2 µs/call (shares the cached role flags)- role flags ×3: 18.6 → 1.35 µs (memo-hit). The memo benefits ALL role-flag callers (targeting, ScanBase target eval, etc.), not just select_weapon.
- Oracle: 120 configs green throughout (live == frozen legacy).
Note on the number: the perf loop runs within one frozen tick so it shows the
memo-hit cost. In-game each NPC pays one only_surviving() (~2.3µs) per tick on
the first role query; every later role query that tick (and the whole combat-AI
chain makes several) is free. So in-game select_weapon ≈ 13–15µs incl. the
once-per-tick refresh, down from ~35.
- Single-pass allocation-free rewrite alone: 35.5 → 33.3 (~6%) — the array was NOT the bottleneck.
-
- collapse 3 role evals → 2 (is_npc_crew_gunner once): → 27.2 (~23% cumulative).
-
- per-tick only_surviving memo: → 13.3 (~2.7× cumulative). The cache was the decisive lever, exactly because the cost was repeated constant-per-tick crew-role work, not the per-call shape.
TestWingmanEngagement (KamAZ-AA valid-weapon) and TestNPCWinchester (close-at-max-
speed) FAIL on untouched main (verified by stashing both source edits — identical
CHECK FAILED messages). Likely tied to the Scene is not a Unit3D spawn errors /
in-flight tree-LOS WIP. Flag separately; out of scope here. The select_weapon
change is proven behavior-preserving by the diff-oracle.
- Scripts/NPCCombatWeapons.gd — select_weapon single-pass; dead is_armored branch collapsed in weapon_priority_slots.
- Scripts/NPCPilotCharacter.gd —
_only_surviving_memo(); is_npc_crew_pilot/gunner route through it. - Tests/TestNPCSelectWeapon.gd — new oracle + perf harness (frozen legacy copy).
- get_ammo() per-slot
ammo.reduce(Cs.sumi, 0)Callable (~2µs/call) and double unit_category (~1µs) are the next small inherent targets if more is wanted. - ScanBase / GunController / VehicleAI re-baseline (the other combat hogs).
- Do these per-unit ticks need every-frame cadence, or can they round-robin?
- Is ScanBase cost detection-loop or residual LOS?
- Is GunController cost per-projectile integration or per-gun aiming?
- Is the Heli3D / MFDMapOverlay all-time spike a one-off load hitch or recurring?
Prior art folded in. Two clear leads:
select_weapon/weapon_priority_slotscaching — already scoped in v060, never shipped. Lowest-risk, well-understood (~64µs/call decision logic that recomputes static slot membership every tick). Likely the fastest win.- Re-baseline ScanBase / VehicleAI post-native-LOS — the old numbers predate the tree-LOS native rewrite; ScanBase's residual cost needs fresh measurement.
Recommend starting with (1): read NPCCombatWeapons.select_weapon +
weapon_priority_slots, confirm the per-tick recompute, microbench, then cache.
Cross-review the plan per AGENT_REVIEW.md before writing code.