Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save jamonholmgren/d2475eace50288af23cfe1563f0736ca to your computer and use it in GitHub Desktop.

Select an option

Save jamonholmgren/d2475eace50288af23cfe1563f0736ca to your computer and use it in GitHub Desktop.

Worksheet: Combat-Frame Perf — NPC AI / GunController / ScanBase

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.

Screenshot read (2026-06-28, M2 Air, tester 3856695864)

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.

Combat hot scripts (window column; #/f = calls/frame, µs/f = per frame)

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

Key insight

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.

Candidate targets (ranked)

  1. 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?
  2. 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.
  3. GunController (~400µs/f) — scales with projectiles. Per-projectile physics = classic batch-update or native target (the LOS playbook).
  4. 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++.

Investigation log

Prior-art search (2026-06-28) — YES, diagnosed in v060

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 monolithic NPCCombatAI.gd (1029 lines) into 6 modules (Geometry/Weapons/Targeting/Breakoff/Autopilot/Fire).
  • worksheet-v060-custom-perf-monitors.md_try_combat_actions measured 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_engagement avg 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 to NPCCombatAI.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_weaponweapon_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.

What's changed since v060

  • 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.

select_weapon analysis (2026-06-28) — INHERENT speedups before caching

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

  1. dist = target.dist_to(h) computed TWICE — once in select_weapon:22, again in weapon_priority_slots:46. Compute once, pass down.
  2. ~4–5 Array[int] allocations per tickweapon_priority_slots builds an empty array + 3 helper-returned arrays + append_array copies, just to iterate once and return the first viable slot. The materialized priority list is never reused.
  3. 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 by weapon StringName.
  4. DEAD BRANCH (correctness smell): weapon_priority_slots:59-64 — the if is_armored: ... else: ... blocks are IDENTICAL (both append rockets then chainguns). is_armored only matters via hellfire_worthy; the if/else does nothing. Collapse it.
  5. 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.)

Proposed inherent rewrite (single allocation-free 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_hellfire rule (only call _crewmate_has_hellfire_ammo lazily 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.

BASELINE measured (2026-06-28, M2 Air, headless, TestNPCSelectWeapon)

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/call
  • weapon_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.)

Plan order (per Jamon, 2026-06-28)

  1. Profile baseline select_weapon µs/call (microbench fixture — building).
  2. Brute-force oracle test: original vs rewrite return identical slot index across many loadout × target × distance configs (incl. salvo-gate, defer, no-ammo).
  3. Implement single-pass allocation-free rewrite; benchmark vs baseline.
  4. Layer per-loadout caching of static role/slot data; benchmark again.
  5. Cross-review (AGENT_REVIEW.md), update NPC_COMBAT_AI_SYSTEM.md.

INHERENT-REWRITE RESULTS (2026-06-28) — the array was NOT the bottleneck

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.

Caching design (next — task 4), needs a decision

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 the Tick/Cache idiom 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 RESULTS (2026-06-28) — per-tick only_surviving memo. DONE.

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.

Inherent vs cache contribution

  • 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.

PRE-EXISTING test failures (NOT caused by this work)

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.

Files touched

  • 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).

Remaining (not done)

  • 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).

Open questions

  • 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?

Next step

Prior art folded in. Two clear leads:

  1. select_weapon / weapon_priority_slots caching — 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.
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment