Skip to content

Bullet Heaven — Godot gotchas

Extracted from CLAUDE.md on 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. See CLAUDE.md § “Subsystem architecture — read on demand”. Entries below are dated and describe what was found on whichever Godot version was current at the time (see each entry’s trailing date) — most engine-behavior findings carry forward across versions, but treat anything explicitly version-pinned as unverified on a newer engine until re-confirmed.

  • GUT watch_signals on several nodes freed inside ONE test can hang the run forever (no error, no summary, 100% CPU: an await never resumes). Reproduced headless with GUT 9.7.1: a loop that builds a FeedbackPanel or SettingsPanel, watch_signals it, closes it (queue_free) and repeats hung on most runs at 4+ iterations; one watched panel per test is fine. Count signals with a plain connect + counter instead (tests/test_settings_panel.gd _closes). (2026-10-14, Settings panel.)
  • var x: Array[String] = a if cond else ["lit", ...] is a RUNTIME error, not a parse error, when the untyped literal side is taken (“Trying to assign an array of type Array to a variable of type Array[String]”). The function aborts at that line. Build the typed default in its own statement (var d: Array[String] = [...]). It shipped in main._save_replay_if_recording (2026-10-13 to 10-14): no device replay was ever saved, while both tests passed because they set the _replay_dirs override and only ran the typed branch. A test seam that overrides a default must come with a test of the default itself. tvOS Godot print/push_error output does not reach devicectl ... --console (only NSLog does), so reproduce device-only script errors in a windowed Mac Dev run (godot --path . --rendering-method mobile, flag set in the Mac user://dev_flags.json) and grep its stdout.
  • BackBufferCopy.rect (COPY_MODE_RECT) is read as raw RENDER-TARGET PIXELS, not canvas units. Both canvas renderers (RD renderer_canvas_render_rd.cpp, GLES3) copy CopyBackBuffer.rect; the cull step computes a transformed screen_rect that nothing reads. With stretch/mode="canvas_items" the logical 1280x720 canvas renders at 1.5x (ATV 1080p), 3x (4K, iPhone) etc., so a logical rect copies only a top-left fraction and every screen-texture sample outside it reads a STALE back buffer (BUILD 315: the Supernova warp painted an old background over 3/4 of the ATV screen). Convert with the viewport’s final transform: SpacetimeWarpLayer.back_buffer_px(rect, logical_vp, get_viewport().get_final_transform()). Harnesses that render into a 1280x720 SubViewport are 1:1 and can never show this; only a stretched window or a device does.
  • The Godot 4.8 fork editor (~/Claude/godot-tvos/godot-48) segfaults on quit after EVERY export, and a 4.8 export rewrites ~160-180 tracked *.import files. The crash is signal 11 in RenderingDeviceDriverMetal::~RenderingDeviceDriverMetal() during NSApplication terminate, logged AFTER [ DONE ] savepack (pack) / [ DONE ] export (full), and the process exits 134. [ DONE ] <task> is NOT a success marker: EditorProgress’s destructor prints it on every exit from the task scope, so a FAILED export prints it too, then ERROR: Project export for preset "X" failed., then hits the same teardown crash. It hits tvOS and iOS presets, and the pre-patch-0001 editor too; a plain windowed run or --editor --quit-after exits 0. No upstream fix as of 2026-10-11: upstream drivers/metal has nothing newer than the fork base, and the cache data race fix (godot#120545) is already in. The import rewrite adds 4.8 keys (compress/high_quality_mode, mipmaps/preserve_alpha_test_coverage, mipmaps/alpha_test_threshold). Both are handled by scripts/lib/godot-export.sh‘s godot_export <pack|full> <log> <cmd...>, which every Apple export call site uses (install-dev.sh, build-ios.sh, build-tvos.sh). It fails any run whose log says Project export for preset ... failed (even on exit 0) or whose output (the .pck, or <name>.xcodeproj/project.pbxproj for full) is missing, empty or not newer than the run’s start; it accepts a non-zero exit ONLY when, on top of that, the end-of-task marker precedes handle_crash:, no ERROR: line sits between them, and the backtrace names RenderingDeviceDriverMetal. It reverts only the *.import files the export dirtied (pre-existing edits are kept), also on Ctrl-C/kill (EXIT/INT/TERM trap in a subshell, so callers’ traps are untouched). Tested by scripts/test/godot-export.test.mjs. Drop the tolerance once the fork stops crashing; never widen it to other backtraces. (2026-10-11, HDR P3 Task 2.)
  • Device-only boot paths need the device-boot smoke; the debug VM soft-errors where the release VM segfaults. Code gated on a live exported Dev build (e.g. ControlClient auto-arm in main._ready, which posts a boot /status straight away) never runs in a plain godot --headless --path . --quit-after 600. 2026-10-09: that boot /status called debug_settings.graphics_report() before debug_settings existed; the editor (debug) VM logs a SCRIPT ERROR and carries on, the export (release) VM crashed on launch on ATV + iPhone. scripts/gate.sh step “device-boot smoke” runs -- --device-boot-smoke (ControlClient.device_boot_smoke(), inert in any exported build), which arms that path with ControlClient io off and must print DEVICE_BOOT_SMOKE armed=true ... http_requests=0. A new device-only boot branch should hang off the same flag.
  • GDScript 4.7 has no powf()/expf() globals, and pow()/exp() return an untyped value. var x := powf(a, b) is a parse error, and var x := pow(a, b) fails inference (“Cannot infer the type”); write var x: float = pow(a, b). A parse error in a class_name script fails every dependent script’s compile, and a headless -s tool that depends on it then hangs silently instead of erroring — run godot --headless --path . --check-only -s res://<file>.gd to see the real error (2026-10-07, NebulaCloudTextures).
  • The iOS shader bake is INERT while the template’s engine commit != the exporting editor’s (found 2026-10-22; BUILD 335: 4.8.dev/ios.zip at a8d5c0bb62, editor at 284e9804c4). ShaderRD appends GODOT_VERSION_HASH to base_sha256, which becomes the group_sha256 directory in .godot/shader_cache/<Class>/<group>/<sha1>.metal.cache (shader_rd.cpp ~:180 and :217), so a pack baked by one commit is never found by a runtime of another: every lookup is a cache miss and the shader compiles at runtime, like an unbaked build. baked_canvas_check only counts cache entries in the pack and shell_engine_check compares the version STEM, printing only a NOTE for a hash mismatch, so both pass. scripts/lib/shader-bake-gate.sh (inside build-ios/tvos/macos.sh BEFORE archive_export_upload, report-only in the dev installs and release.sh) decodes the containers in the pack (scripts/lib/shader_bake_scan.py) and judges them against the device floor (Apple5 for iOS/clip/tvOS, Apple7 macOS): a LIVE bake with a container above the floor, an argument-buffer flag below Apple6, or the wrong platform fails; the same on an INERT bake only warns with both commits; an unreadable layout exits 2; unknown liveness counts as LIVE. Why the 4.8 floor matters: upstream #119123’s loader keeps a NULL shader when the driver rejects a baked container (nothing recompiles; stock 4.7.x did), so a rejected bake is a black frame (527 rejections on a Mac faking Apple5/6). Exposure matrix once a bake is live: iOS main bake flat (Apple7, min iOS 15), App Clip bake argument-buffer (min iOS 17), tvOS bake flat at Apple5; flat-baked shaders beside runtime argument-buffer shaders on A14+/M1+ is the godot#124088 mix. Never rebuild only the iOS template: that arms both at once. Fix is staged for 1.2 (platform/engine-patches/README.md, 0005-0007).
  • On a shader-baked iOS export, a runtime-created CanvasItemMaterial breaks OTHER items’ draws on A17+ iPhones (upstream godot#124088; NOT A17-specific: reproduced 2026-10-22 on the M5 Mac on stock 4.7.1 and on the 4.8 fork. It hits any Metal 3 + argument-buffer tier 2 device that mixes flat-baked and runtime argument-buffer shaders. Two triggers: a runtime-compiled shader absent from the bake, and a stale argument-buffer entry in user://shader_cache beside a bake-only key. MTL_DEBUG_LAYER=1 turns the silent vanish into an assert. Unfixed in 4.7.x: Metal canvas_data buffer-size mismatch, failed draw calls, no Godot error). Symptom 2026-10-03: the player hull and every Button label invisible on an iPhone 15 Pro Max, while Labels in the same font and the iPad M1 were fine. Baker OFF fixed it; so did replacing all CanvasItemMaterial.new() with the shared saved res://fx/additive_mat.tres (baker ON, BUILD 254). Runtime .duplicate() of saved ShaderMaterials was harmless. tests/test_baked_shader_materials.gd bans CanvasItemMaterial.new() in shipped dirs. Before bisecting a repeat report, UNINSTALL the Dev app (xcrun devicectl device uninstall app --device <udid> digital.lumara.bulletheaven.dev) and install the same .app again (devicectl device capture screenshot works without Chris, but check the game is foregrounded: it captures whatever app is open). A rebuild installed over the top is NOT enough: on 2026-10-13 (BUILD 311/312, iPhone 15 Pro Max) main, main minus its only new shader, and the 10-12 commit that had passed this check all showed blank Button labels over the top, and an uninstall + reinstall of the identical .app drew everything. Afterwards a different build (312) installed over that clean app also drew fine, so it is stale state left by some earlier install, not every update (which one is unknown). 2026-10-10 (BUILD 293) was the same shape. Uninstalling wipes the Dev app’s save data. Also note the baker’s step count (200-750) tracks the bake cache state, not shader coverage. Repeated 2026-10-13 (BUILD 319, main + alpha-fix): blank labels over the top again; a bisect with clean installs drew for main alone, alpha-fix alone AND the same 319 .app, so it was stale state a third time. Reading device screenshots: wait ~30 s after devicectl device process launch (a ~12 s capture of a fresh install caught a lone blank button that looked like the bug), and an all-black capture with the process running means the display is asleep, not a crash.
  • A canvas item whose COMBINED modulate alpha is below 0.007 is culled, not drawn faintly. RendererCanvasCull::_cull_canvas_item (servers/rendering/renderer_canvas_cull.cpp, fork ~line 329) computes ci->modulate * parent_modulate and returns if .a < 0.007 – nothing is submitted, so no Metal pipeline is linked either (self_modulate is not part of the test; a CanvasLayer or SubViewport resets the chain). “Alpha 0.001 = invisible but drawn” is therefore false: PrewarmPass ran that way until 2026-10-02 and warmed nothing outside SubViewports (BUILD 248 ATV: six 0.5-1.1 s Warden first-link stalls). To draw something invisibly, draw it at full alpha under an opaque layer (the prewarm sits under StartMenu’s backdrop); PrewarmPass.CANVAS_CULL_ALPHA mirrors the constant and tests/test_prewarm_cull_alpha.gd guards it.
  • Headless cannot read back MultiMesh per-instance transforms/colors. Under --headless the dummy RenderingServer returns (0,0)/black for get_instance_transform_2d/get_instance_color even though instance_count is correct. So MultiMesh render tests can only assert instance_count (+ resync behavior) headlessly; pixel placement is verified by playtest. (Probe to confirm: a SceneTree script that sets then gets an instance transform.)
  • run/main_scene pointing at a not-yet-existent scene makes godot --import HARD-FAIL (Cannot open file 'res://main.tscn'), which leaves the global class cache stale so newly-added class_name scripts aren’t registered — and GUT then silently drops their tests from a -gdir scan. A green suite can be running fewer tests than you think. Always verify the test COUNT, not just “all passed.” Keep main.tscn present (or don’t reference a missing main_scene). .godot/ is gitignored, so CI rebuilds the cache from scratch — a CI --import step that fails on error + an expected-test-count assert would guard this.
  • SwarmRenderer.configure(color) sets modulate AND sync() sets per-instance color → passing the same color to both renders it squared (darker). Configure with Color.WHITE, let per-instance sync color drive (preserves neon brightness).
  • Determinism trace caveat: snapshot_string() captures aggregate counts + player state, not per-entity positions — so two runs could diverge in entity positions while the string stays equal. Sim.state_checksum() (pinned in tests/test_determinism_survival.gd and tests/test_determinism_coop.gd) closes that gap: it hashes per-entity positions + data + aura columns (enemies/projectiles/gems) plus player state, for Phase-3 desync hunting. Use the checksum, not the string, when chasing a position-level desync.
  • Don’t hand-author [input] actions in project.godot. Godot serializes key events as Object(InputEventKey,"physical_keycode":87,...), NOT a plain dict {"type":"InputEventKey","keycode":87}. The plain-dict form parses into an action with zero events (InputMap.has_action true but action_get_events().size()==0), so Input.get_vector silently returns zero and nothing responds — symptom: “can’t move,” no error. Register input actions in CODE instead: InputRouter.ensure_actions() binds physical_keycode WASD via InputMap.add_action/action_add_event (layout-independent, idempotent, unit-testable). Probe to diagnose any input issue: a SceneTree script printing InputMap.action_get_events(a).size().
  • GUT 9.6 FAILS any test in which an un-asserted push_error fires (addons/gut/error_tracker.gd, default treat_push_error_as = FAILURE; gut.gd should_test_fail_from_errors). So a function that push_errors on bad input (e.g. ContentLoader.load_from_dict on invalid data, the fail-loud problem list) will trip GUT even when its return value is correct. Two ways to handle: (a) test the non-erroring seam — e.g. assert against ContentLoader.validate(raw)’s returned problem array, which does NOT push_error; (b) when you must exercise the erroring path, consume the expected error with assert_push_error("substr") / assert_push_error_count(n) (case-insensitive; marks it handled so it no longer fails the test). Keep the production push_error (it’s the boot-time fail-loud); just consume it in the one test that triggers it. Corollary: pure /sim helpers that “should never” hit a branch (e.g. StatEffects.apply on an unknown effect) take a silent no-op rather than push_error, since the bad path is upstream-guarded by validation and a push_error there only trips tests. (OS.is_debug_build() is NOT an escape hatch — OS is an Engine API, forbidden in /sim.)
  • GUT 9.7.1 (bumped from 9.6.0 2026-07-19 alongside the Godot 4.7.1 upgrade below) is the release matching this project’s engine version. Vendored under addons/gut/ (committed dependency, not authored — don’t review/edit it). 9.7.0’s one breaking change (stricter typed Double/stub return values under 4.7) doesn’t affect this project — no test file uses GUT’s double()/stub() mocking.
  • CanvasLayer has no theme property — it’s a Node, not a Control. Setting .theme on a CanvasLayer root silently does nothing. Apply NeonTheme.get_theme() to the first child Control node (e.g. center.theme = … on a CenterContainer), from which it propagates to all descendant Controls. Confirmed in NeonTheme implementation (cycle 6).
  • GDScript Variant arithmetic requires explicit float() casts. var t := 1.0 - a["life"] / a["max_life"] is rejected by Godot 4.6 type inference when a is a Dictionary (values are Variants). Write var t: float = 1.0 - float(a["life"]) / float(a["max_life"]). Affects any pooled-FX advance loop or similar per-frame Dict math.
  • := type inference fails across or with a narrowed-event property → a SILENT-HANG class-compile failure. var ok := a or (event is InputEventX and event.pressed) — GDScript does NOT narrow event to InputEventX inside an or, so event.pressed/.button_index read as Variant and the := “cannot infer type” Parse Errors. That fails the WHOLE script’s compile → MyClass.new() returns null (“Nonexistent function ‘new’ in base ‘GDScript’”) → a node that calls into it silently no-ops and the game freezes with no visible error. Fix: narrow inside an if event is InputEventX: block or annotate var ok: bool =. Always boot-check for SCRIPT ERROR before trusting a build (note: timeout is not on macOS PATH — it’s gtimeout — so a timeout-wrapped smoke check silently no-ops and hides this). (2026-06-24, tvOS port.)
  • A GUT assertion typo (e.g. assert_le — the real method is assert_lte) is a Parse Error that silently DROPS the whole test file from a -gdir run — the suite still reports “All tests passed” with one FEWER script. The scripts/check-test-count.sh guard catches it (ran fewer scripts than test_*.gd files); always trust the COUNT, not just “passed”. GUT methods: assert_lte/assert_gte, not assert_le/assert_ge. (2026-06-24.)
  • Under warnings-as-errors, := also fails on weakref() results — annotate them var w: WeakRef = weakref(x). Same class as the Dictionary.get() trap: the inferred type is not what the later .get_ref() as T call expects, so the parse error cascades into unrelated dependent test files rather than failing where you wrote it. (2026-10-04.)
  • MAX_ENEMY_RADIUS (the projectile broad-phase bound in _resolve_collisions) MUST cover the largest enemy or projectiles tunnel straight through it. A big enemy (the 70px boss) needs MAX_ENEMY_RADIUS >= its radius; with the old 26 (elite), projectiles missed the boss entirely. Widening it is determinism-safe ONLY because the baseline run has no player projectiles (melee-blade start) — verify before changing if that ever changes.
  • Render-side type→colour LUTs must be sized by MAX type id, not entry count. Enemy TYPE_* ids are non-contiguous (boss=6 has no bible.json entry; skirmisher=7), so _enemy_type_colors.resize(names.size()) then arr[tid]=… indexes out of bounds for tid 7. Size to TYPE_SKIRMISHER + 1 and special-case ids without a bible colour (boss/skirmisher) in _enemy_colors. (2026-06-24.)
  • A GDScript inner-class member CANNOT be named ready — it collides with Node’s built-in ready signal → Parse Error: Member "ready" redefined (original in native class 'Control'), which fails the WHOLE script compile → .new() returns null → a silent freeze (the boot-check grep "SCRIPT ERROR" catches it). Use another name (e.g. crackling). Same class as the :=-across-or silent-compile trap. (2026-06-25, hud.gd DecoyCrackle.)
  • Reactions do NOT fire in the blade-only determinism baseline (seed 1234, 600 ticks), so tuning the reaction constants (REACTION_DAMAGE_SCALE, burst magnitude, ZONE_DPS) is determinism-SAFE. Likewise the decoy/boss/synergy/telemetry counters are all inactive-or-excluded in the baseline. Always re-run the determinism test after a sim const change, but don’t assume a balance tweak moved the baseline — most don’t. (2026-06-25.) Note: _vary_stats is called every spawn and DOES move the checksum (it draws rng values per spawn, shifting entity positions). The task-10 re-pin moved state_checksum from 1267954985 → 2325839371; snapshot_string().hash() remained 4152236597 (aggregates unaffected by per-entity stat variation).
  • A render-side effect that DECAYS / restores state each frame MUST be advanced ABOVE the _process early-returns (if sim.game_over: return, _paused_for_levelup, _warping, _paused_for_menu), or it FREEZES on the death / level-up / pause / area-warp screens — and an overlay frozen on a high CanvasLayer (warp streaks=19, control hint=18) then draws OVER the results panel (layer 10). Caught 2026-06-30: the warp world-dim (main._drive_warp) + ControlHint.advance sat below those returns, so a warp fired in the i-frame-gap-before-death (DASH i-frames 0.18s < WARP_S 0.30s) left the world stuck at ~45% brightness + the streak overlay frozen above the death screen until the next run. Fix: hoist all such decay/restore drivers to the top of _process (right after if sim == null: return) and force-finish them on game_over. The _physics_process sim-tick freeze gates are correct where they are; this rule is ONLY for render-side restore-to-default effects. (Anything that modulate-darkens shared render nodes especially — it must un-dim on every exit path.)
  • ArchetypeRenderer.sync(enemies, colors, ...) takes a PER-ENEMY colour array (main._enemy_colors(), length == enemies.count) indexed by enemy SLOT i — NOT a per-type LUT indexed by type_id. (It read colors[tid] for a while, silently ignoring every per-enemy aura/boss tint; fixed 2026-06-30.) When adding an enemy colour source, vivify it at the SOURCE (ElementPalette.color_for + _build_enemy_type_colors + the boss overrides in _enemy_colors all run through ElementPalette.vivid()); vivid() ramps saturation but no-ops on hueless colours (so the white-cyan GEM is left alone).
  • A per-enemy-TYPE display name is NOT the same as its bible/internal id — data/bestiary.json (player-facing name/desc/counter, separate file from data/bible.json) and EnemyPool.TYPE_NAMES (telemetry attribution) can diverge from the content id: EnemyPool.TYPE_ELITE (bible id "elite", behavior=dash) displays as “Charger”; EnemyPool.TYPE_BOSS2 displays as “Sentinel”; EnemyPool.TYPE_SPIDER (bible id "spider") displays as “Weaver” (renamed 2026-07-02 — “spider” doesn’t fit the space theme; the internal id/class/TYPE_NAMES telemetry label are unchanged, only bible.json/bestiary.json name fields moved). When a request names an enemy by its in-game/display name, grep bestiary.json + EnemyPool.TYPE_NAMES first. (2026-07-01.)
  • A baked player-ship hull sprite (render/ship_sprites/ship3d_*.png, shown by PlayerRenderer when USE_BAKED_HULL) MUST be a flat top-down orthographic view — nose pointing straight up, bilaterally symmetric, soft/even centerline lighting (NOT a one-sided key light), alpha-isolated on transparent. PlayerRenderer just rotates the whole Sprite2D via Node2D.rotation to match facing; there’s no per-frame relighting shader on the baked path, so a dramatic 3/4-angle “hero shot” with directional shadows looks wrong the instant the ship turns. Confirmed a NEW sprite source works alongside the existing 3D-model bake (tools/ship_preview SHIP_VARIANT=bake): an AI image generator prompted explicitly for “flat top-down orthographic, nose-up, symmetric, no perspective, isolated on plain background” (matching the exact spec above) produces a usable hull after processing — isolate alpha via flood-fill FROM THE IMAGE CORNERS (not a global white-colour threshold, which would also wipe out enclosed bright details like an engine-glow stripe), corner-blank any generator watermark, resize to 512×512. Always verify a new candidate through tools/ship_preview SHIP_VARIANT=current (windowed, the REAL PlayerRenderer, all 5 level tiers) before committing — only that shows it with the halo/thruster/accent-orb overlay at true in-game scale. (2026-07-01, aurum/cobalt/prism.)
  • enemies.xp_val (per-enemy column, set at spawn from bible xp_value) is VESTIGIAL — never read. Actual kill XP is 100% biomass-driven via Sim._xp_worth()/enemies.biomass. A feature that reads/writes xp_val expecting it to affect reward is a no-op. (2026-07-01.)
  • GUT’s -gtest=res://tests/<file>.gd did NOT isolate a single file in this project’s run (still executed the full ~172-script suite). Don’t rely on it to speed up iteration — the full -gdir run is only ~4s anyway. (2026-07-01.)
  • Transform2D’s 4-argument constructor is (rotation, scale, SKEW, position) — the third arg is skew, NOT a rotation repeat. Transform2D(rot, size, rot, pos) silently shears the transform by rot radians: a 45°-rotated quad becomes a giant parallelogram (15× the additive fill cost on device — BUILD 226’s web-capsule regression, found via telemetry not the eye). The trap is invisible to tests that assert get_rotation()/get_scale(): those DECOMPOSE the basis and pass on a sheared transform. Build rotated+scaled quads explicitly (Transform2D(Vector2(sx*cs, sx*sn), Vector2(-sy*sn, sy*cs), pos)), and pin the RAW basis axes (t.x, t.y) in tests. Circle/angle-0 cases hide the bug entirely. (2026-10-01.)
  • An archived .gd file with a class_name declaration is NOT inert — Godot’s global class-name scanner walks the ENTIRE project tree, _archive/-style “documentation only” directories included (the repo’s own _archive/ was deleted 2026-09-29; if you park reference code in-tree again, give the folder a .gdignore). Moving ui/level_up_panel.gd (class_name LevelUpPanel) into _archive/reactions-mode/ and later reusing that exact class name for the renamed live panel caused a real, non-deterministic collision — the engine’s class cache resolved LevelUpPanel.new() to whichever same-named script it scanned last, surfacing as intermittent “Nonexistent function ‘show_for’” test failures that only appeared after a fresh import, not immediately at the rename commit. Fix: strip class_name from any .gd file moved into an archive location that still lives inside the Godot project tree — the code stays fully readable as reference, it just can’t register as a live global class. Applies to any future “archive but keep the file” move. (2026-07-09.)
  • A windowed preview harness that fits a whole large /sim space (e.g. the 4000×4000 arena) into a small window can render sparse/small elements as functionally invisible. tools/bg_preview/ (galaxy backdrop verification) first used a Camera2D.zoom chosen to fit the whole arena in a 900px window — individual 1-3 world-unit star radii shrank below a screen pixel, so the “proper looking galaxy” the generation code drew was there but unreadable. Fix: pick a preview zoom that favors legible individual-element size over fitting the whole space in frame (a tighter crop is fine — the goal is judging the LOOK, not framing everything at once), and for any sparse-particle/star-based look, consider adding a soft additive glow anchor (reusing GlowTexture, the pattern render/arena_background.gd’s nebula variant already uses) so it reads as “there’s something bright here” even before individual points are legible. (2026-07-02.)
  • A new additive/glow child node on ArenaBackground (or any render node with a set_low(v) adaptive-quality toggle) MUST be wired into that toggle, the same way existing glow children are, or it silently ignores the adaptive-quality system. QualityManager calls set_low(true) at low quality tiers specifically to shed additive full-quad overdraw on weaker hardware (e.g. the Apple TV) — a new glow sprite not covered by the SAME meta-tag check set_low already loops over (e.g. c.has_meta("nebula")) stays visible and costs overdraw at every quality tier regardless. This was only caught by a reviewer manually checking whether the new tag appeared in set_low’s loop, not by a failing test — add a regression test asserting set_low(true) hides the new node. (2026-07-02, VARIANT_GALAXY’s core glow sprite.)
  • Generalizing a shared pure function (e.g. AreaDefs.other(), a 2-way toggle → N-way cycle) can silently break a test in a DIFFERENT file than the one you’re editing. Only running the targeted test file for the function’s own tests (tests/test_areas.gd) missed that a second test file, tests/test_wormhole.gd (since deleted with the retired teaser wormhole; historical example), also encoded the old 2-way assumption — caught later by a full-suite run. The live test exercising AreaDefs.other() is tests/test_areas.gd. Before considering a shared-function change done, grep -rl "<function_name>(" tests/ for other callers, and always run the FULL suite at least once, not just the file you wrote tests in. (2026-07-02.)
  • Godot’s RPC system requires the CALLING node’s own script to locally declare (with a matching @rpc annotation) any method it invokes via rpc_id(), even though that method never actually runs there — rpc_id() looks up the RPC config on the LOCAL script to encode the outbound call, and fails with “Unable to get the RPC configuration for the function” if the method doesn’t exist locally, even when it’s correctly declared on the RECEIVING peer’s script. Confirmed via godotengine/godot#57869. Any two-sided RPC pair (host calls a method only the client implements, and vice versa) needs a “mirror stub” — the real implementation on the receiver, plus a same-name/same-annotation no-op stub on the caller (see net/mp_host.gd/net/mp_client.gd’s receive_snapshot/receive_input pairs for the pattern). This is invisible to any test that never opens a real ENet connection. (2026-07-02, M-B multiplayer.)
  • A real ENet unreliable packet that exceeds the MTU (1392 bytes default) risks losing its ENTIRE payload on a real network, not just “some degradation” — IP fragmentation reassembly requires every fragment to arrive; losing even one silently discards the whole datagram, and "unreliable_ordered" never retries. A LOOPBACK/localhost test does NOT surface this (no real fragmentation loss on 127.0.0.1), so a headless dual-instance smoke test on one Mac can pass cleanly while the same code fails completely on real WiFi between two physical devices. Confirmed live: a client that joined successfully still saw zero enemies (an oversized snapshot never survived), while the player’s own locally-predicted ship moved fine (no network needed). Fix for an oversized payload that must arrive intact: switch that RPC to "reliable" (ENet handles fragmentation/retransmission properly) rather than "unreliable_ordered". Any new large periodic broadcast should be checked against the MTU before assuming loopback testing proves it works over real WiFi. (2026-07-02, M-B multiplayer.)
  • iOS/tvOS 14+ require NSLocalNetworkUsageDescription in Info.plist for ANY local-network socket traffic — including raw UDP/ENet, not just Bonjour/mDNS discovery. Without it, local-network access is silently blocked (no error surfaced to the app) rather than prompting the user, which looks exactly like “the code is broken” rather than “the OS blocked it.” Godot’s export templates do NOT add this key automatically. Fix: PlistBuddy -c "Add :NSLocalNetworkUsageDescription string '...'" on the Xcode project’s SOURCE Info.plist template (build/BulletHeaven/BulletHeaven-Info.plist for tvOS, build-ios/BulletHeaven/BulletHeaven-Info.plist for iOS — NOT the DerivedData build output, which gets regenerated) — this persists across --export-pack re-exports exactly like the existing signing-identity pbxproj fix, since --export-pack only touches the .pck, never the Xcode project files. (2026-07-02, M-B multiplayer.)
  • ANY new custom UI overlay on tvOS MUST use this project’s explicit debounced-nav pattern (dpad via MenuNav.is_down/is_up + JOY_BUTTON_A confirm, _buttons/_sel/_focus_sel/_move(), see ui/pause_menu.gd) — plain default-focus Godot Buttons do NOT reliably respond to the Siri Remote. Confirmed as a real, live bug: a new lobby screen built with ordinary Buttons (relying on default Godot Control focus/click) silently did nothing when pressed on a real Apple TV, even though grab_focus() was called on the initial button. Every other tvOS-safe menu in this project (PauseMenu, LevelUpPanel, StartMenu) already reimplements this explicit pattern — that consistency is load-bearing, not a style preference. When adding a new menu/overlay, copy the pattern from the start; don’t assume default Button behavior works on tvOS just because it works in the editor/on Mac/on touch. (2026-07-02, ui/mp_lobby.gd.)
  • A custom _input() override doing global keypress handling (e.g. Enter-key confirm for a menu) runs BEFORE Godot’s own Control gui-input dispatch, so it can silently intercept a keypress meant for a focused LineEdit before the LineEdit’s own text_submitted mechanism ever sees it. Confirmed live: a screen with both a custom debounced-nav _input() (Enter confirms _buttons[_sel]) and a LineEdit for text entry — pressing Return on the on-screen keyboard while typing into the LineEdit always confirmed the currently-selected MENU button (which defaults to whatever _sel was, since tapping into a LineEdit via touch never moves it), not the LineEdit’s own submit action; the keyboard also had no way to dismiss. Fix: in the custom _input(), check line_edit.has_focus() for the Enter-key case specifically and handle it as “submit this field” (call the intended action directly + line_edit.release_focus() to dismiss the keyboard) BEFORE falling through to the generic menu-confirm logic. (2026-07-02, ui/mp_lobby.gd.)
  • EnterWorktree’s default base can be badly stale in this repo — always verify before building on it. Chris works almost entirely locally and rarely pushes (local main routinely 40-90+ commits ahead of origin/main), but EnterWorktree’s default fresh mode branches from origin/<default-branch> — so a fresh worktree can silently start from a base that’s missing dozens of recent local commits (hit 3 times in one session: the shop-carousel-hologram, debug-settings-panel, and hud-elegance-pass worktrees all branched stale). Symptom: files/commits you just made on main (e.g. a plan doc written minutes earlier) are simply missing in the new worktree. Fix: immediately after EnterWorktree, run git log <new-branch> --not main --oneline — if empty (no unique commits), it’s safe to git reset --hard main to bring it current. Always re-verify with godot --headless --path . --import + the test-count guard after any reset, since a fresh worktree’s .godot/ cache needs rebuilding regardless. (2026-07-03.)
  • A subagent’s Edit/Write/Bash call can silently target the WRONG absolute path (the main repo checkout instead of the worktree it was explicitly told to use, or vice versa) even when given the exact correct path in its prompt — happened at least twice in one session (an implementer’s commit landed on main instead of its worktree branch; a controller-level plan-doc edit did the same). It’s usually invisible until you explicitly diff git log <worktree-branch> --oneline against main’s own log after every dispatch. Cheap mitigation that measurably helped: tell every subagent to run pwd && git rev-parse --show-toplevel as its literal first action AND again immediately before its final commit, and independently re-verify (git log --oneline -3 in both the worktree and main) after every dispatch before generating a review package — don’t just trust a reported commit SHA. (2026-07-03.) This isn’t subagent-exclusive — the main session hit the identical failure directly, TWICE in one later session, editing files on main’s checkout instead of the active worktree’s; see memory bullet-heaven-worktree-tooling-gotchas for the full writeup + the broadened mitigation (2026-07-04).
  • Control.set_anchors_preset(FULL_RECT) called on self from INSIDE _ready() (i.e. after the node is already parented, still at its default (0,0) size) silently collapses the node to a permanent zero-size rect. The preset’s default resize_mode (PRESET_MODE_MINSIZE) “preserves” whatever rect the Control CURRENTLY has by computing offsets relative to the parent’s size at that exact moment — with a fresh (0,0) size and a real parent, that bakes in NEGATIVE offsets (offset_right/offset_bottom = -parent_size), permanently pinning the final rect to zero forever (offsets never get recomputed later). Everything nested inside inherits the collapse. The fix: call set_anchors_preset from _init() instead — before the node has ANY parent — with no parent to “preserve a rect” against, Godot lands on zero offsets (a true, dynamically-filling full rect), matching how every OTHER full-rect dim/backdrop in this codebase already does it (creating the Control, setting its anchors, THEN calling add_child on it — see dialogue_box.gd/level_up_panel.gd/ship_config_panel.gd). Confirmed as a real, live bug: NeonBackdrop (the shop panel’s dimming backdrop) rendered at a genuinely zero-size rect in production — Chris saw the live battlefield at full brightness behind the shop UI, because the “opaque” backdrop meant to hide it was silently 0×0. Caught via a temp debug harness printing get_rect() (not visible from a screenshot alone, since a collapsed Control just renders nothing rather than erroring). New regression tests assert the resolved offset_left/top/right/bottom are all 0.0, not just structural things like child count. (2026-07-04, ui/neon_backdrop.gd.)
  • A per-frame “authority” that unconditionally reasserts a Control’s .visible will silently override an explicit one-shot toggle set by other code — this has now recurred 3 times. Pattern: some main._process()-driven toggle (e.g. _toggle_tactical_hud()) sets some_node.visible = false once, but a DIFFERENT function ALSO runs every frame and does some_node.visible = <some condition that's basically always true> — stomping the toggle back to visible on the very next frame, with no error, no test failure (since most tests check the toggle function’s IMMEDIATE effect, not “does it survive the next update() call”). Confirmed instances: marketing/capture/shoot.gd’s own comment about auto_label.visible=_auto fighting a hide attempt; DroneDock.update_dock(sim) doing visible = total > 0 every frame (total is essentially always > 0) and stomping main._toggle_tactical_hud()’s hide (2026-07-04) — the sibling WeaponPanel never had this bug because its own update_panel() never touches .visible at all, leaving toggle ownership exclusively with main.gd. Rule for any new per-frame update()-style method: if some OTHER code owns turning a node visible/invisible via an explicit toggle, the update() method must only ever force it OFF (a genuine “there’s nothing to show” case), never force it back ON — turning it on stays the toggle’s job alone. Add a regression test asserting update() doesn’t override an already-hidden state.
  • A custom InputMap action registered lazily (only when some later system first spins up) creates a dead window where only Godot’s BUILT-IN actions work — and the failure looks like “nothing happens,” not an obvious bug. InputRouter.ensure_actions() (binds WASD to move_*/aim_*) only ran inside _new_run()’s InputRouter.new() constructor, so at the COLD-BOOT start menu — before any run has ever started this session — WASD did nothing; arrow keys/D-pad worked fine (Godot’s built-in ui_* actions don’t need registering). MenuNav.is_down/up/left/right’s event.is_action_pressed("move_down") on a nonexistent action just logs ERROR: The InputMap action "move_down" doesn't exist and returns false — no crash, no obvious symptom besides silently-dead input. Fix: call InputRouter.new().ensure_actions() unconditionally as the very first line of main.gd’s top-level _ready(), before the headless/start-menu branch — it’s pure InputMap registration with no tree/scene dependency, safe before any node is even added. Any new custom action bound outside project.godot’s [input] section should register at boot, not lazily on first use. (2026-07-04.)
  • Viewport.is_input_handled()/set_input_as_handled() is Viewport-GLOBAL state that GUT never resets between test functions in the same run — an earlier test (same file OR an alphabetically-earlier sibling test file) can leave it true before a later test’s own assertion runs, producing a false-positive that can’t distinguish “my code set this” from “someone else’s code already did.” Confirmed live: a test using assert_true(get_viewport().is_input_handled()) as a “did my input-handling code actually run” signal passed identically whether the wiring under test existed or not, once a prior test in the same file had already called set_input_as_handled(). Use genuinely LOCAL state instead — a member-var call-counter incremented unconditionally inside the function under test — not shared engine/Viewport state, whenever a test needs to observe “was this called.” (2026-07-05/06, ShipConfigPanel input wiring — caught by an independent second review that built a scratch Godot project to verify the reset semantics empirically.)
  • A custom _input() handler that calls get_viewport().set_input_as_handled() for a raw InputEventScreenTouch/InputEventScreenDrag UNCONDITIONALLY (regardless of screen position) silently prevents any real Button sharing that screen from ever receiving its own click — _input() runs on every node BEFORE Godot’s GUI-click dispatch phase (the mechanism that fires Button.pressed), so an already-set_input_as_handled() event never reaches it. Confirmed as a real, live bug: a touch-drag-to-page-a-carousel handler in ui/ship_config_panel.gd unconditionally consumed every touch, so tapping the panel’s real BACK button ALSO fired the carousel’s own tap-to-confirm logic (near-zero displacement always reads as a tap) before the button’s click could fire — silently equipping whichever ship was centered, on every cancel. Invisible to GUT tests, which call _input() directly and bypass the real Viewport/GUI-dispatch pipeline entirely. Fix: either never call set_input_as_handled() for touch (matching ui/start_menu.gd’s own deliberate pattern — see its inline comment), or explicitly gate on some_button.get_global_rect().has_point(event.position) before treating a touch as belonging to your own custom handling. (2026-07-06, ship-config touch input.)
  • UI content positioned via absolute viewport-fraction math (not a VBoxContainer/HBoxContainer child) needs a DEDICATED reserved-space spacer inside whatever Container it visually shares the screen with — removing other items from that Container (e.g. deleting rows/cards) shrinks its total height, which (via CenterContainer) shifts its remaining children into the same screen region the absolutely-positioned element renders in. Caught via a live screenshot capture, not any passing unit test: a carousel card positioned via _center()/CENTER_Y_FRAC fractions visually overlapped the menu’s own footer buttons once mode cards + an inline row were removed from its VBox. Fix: a Control spacer sized to the element’s actual rendered footprint (with margin), and a comment stating exactly what screen-size/content-count combination was screenshot-verified vs. assumed — this class of bug is invisible to any test that only checks node properties, never renders + looks at the composed screen. (2026-07-06, start-menu carousel.)
  • GUT ships a built-in spy/call-count mechanism (gut.get_spy(), assert_call_count, see addons/gut/spy.gd) that can verify “was this method called” WITHOUT adding test-only instrumentation to production code. Reach for it before adding a manual counter field to a shipping class purely for test observability (a real instance: ui/ship_config_panel.gd’s _confirm_ship_call_count — a fine, minimal fix, but the spy mechanism would have avoided touching production code at all). (2026-07-06.)
  • A growing button list inside a bare CenterContainer (no ScrollContainer) silently runs off the edges of a fixed 1280×720 TV viewport once it’s tall enough — CenterContainer has no clipping/scrolling, it just centers the overflow, hiding whatever doesn’t fit. ui/debug_settings_panel.gd’s row list grew to 13 buttons + title/status (~832px of content) against the 720px viewport, silently making the bottom rows unreachable — no error, no warning. Fix: wrap the row VBoxContainer in a ScrollContainer with follow_focus = true (auto-scrolls to keep whichever row is currently focused on screen — essential since there’s no mouse on a 10-foot/d-pad UI to drag a scrollbar) plus TV-safe top/bottom margins (offset_top/offset_bottom, since overscan eats further into the raw edges). Any menu/panel whose row count can grow over time — dev panels especially, they tend to accrete rows — should use this pattern from the start instead of a bare CenterContainer. (2026-07-04.)
  • Godot 4.7.stable (Homebrew cask godot, macOS) crashes on exit from --headless mode — EVERY time, not just under GUT. Reproduced with the plainest possible command (godot --headless --path . --quit-after 10, zero tests): SIGABRT in std::__1::recursive_mutex::lock() on the main thread during final process teardown (all 16 WorkerThreads idle on a condvar when it hits — a static-destructor teardown-order bug in this engine build, not project code). macOS’s crash reporter pops a dialog + writes a .ips to ~/Library/Logs/DiagnosticReports/Godot-*.ips for every one. The crash is strictly post-completion: GUT’s full pass/fail summary and any --export-release output are already fully written to stdout before it happens, so test results and exported builds are NOT corrupted — always read the printed summary/DONE lines, don’t infer success from the process exit code. That exit code IS a real problem for anything that trusts it: it’s always nonzero (134) even on an all-green suite, so scripts/check-test-count.sh’s set -e around -gexit will look like it aborted/failed on every run regardless of actual test outcome — don’t take that script’s exit status at face value until this is fixed upstream or worked around. To silence the local crash dialog (optional, user-level, reversible, affects ALL apps not just Godot): defaults write com.apple.CrashReporter DialogType none (undo with DialogType developer). (2026-07-07, Godot 4.7 upgrade for the 8-dimensions branch.)
  • BuildConfig.dev_tools() (OS.has_feature("editor") or OS.has_feature("dev")) evaluates to TRUE under godot --headless -s res://addons/gut/gut_cmdln.gd ... test runs — the headless CLI runner is still the editor binary, not an exported release build. A test that assumes a dev-only UI element (e.g. a “Remote Control” debug button/card) is ABSENT in its test environment will be wrong by default — it’s present in every local/CI test run, only genuinely absent in an actual exported --export-release build. Write such tests by deriving the expected state from BuildConfig.dev_tools() live (assert_eq(has_remote, BuildConfig.dev_tools())), not by hardcoding either true or false. (2026-07-14, start-menu carousel declutter.)
  • Detecting “the player is actively moving the mouse” (vs. “the cursor merely has a position”) needs frame-to-frame diffing, not a one-shot distance check. A resting mouse cursor has no neutral/centered position the way a released analog stick snaps back to zero — so any “cursor is >N px from the player” check reads as “aiming” essentially permanently once no other input is held, since a cursor is almost never exactly on top of the player sprite. Fix pattern (input/input_router.gd’s set_mouse_aim_context): store the previous frame’s cursor-relative offset; only treat the current frame as “real motion” if the offset changed by more than a small epsilon since last frame (a genuinely stationary polled position reports the IDENTICAL offset every frame — no OS jitter to fight, since this is a polled read via get_global_mouse_position(), not a raw InputEventMouseMotion stream). Diff the cursor-relative-to-player OFFSET, not raw world position (a camera with any smoothing/lag would otherwise read its own lag as false “motion”) — confirm the camera has zero smoothing first. ⚠️ UPDATE (2026-07-16): the 200ms “active window” this entry originally recommended (re-idle after 200ms of no further motion) was itself a bug, not a fix — it made mouse-aim go idle the instant the player held the cursor still to keep firing at a target, which is the single most natural way to aim with a mouse, and didn’t stop a connected controller’s incidental mouse/trackpad motion from hijacking aim between stick bursts. Replaced with a permanent motion-latch (once real motion is detected, stays “active” indefinitely — no re-idle timer at all) PLUS a device-presence gate (Platform.has_mouse() + InputRouter.has_playable_joypad_connected(): mouse is never even consulted while any real controller is connected, or on iOS/tvOS). The lesson generalizes: a decaying “was this input recently active” timer is the wrong tool whenever “hold still” is a valid, common user action for that input — prefer a latch (set/cleared by real events) or a presence/precedence gate over a recency window. (2026-07-14 original finding; 2026-07-16 correction, both input/input_router.gd.)
  • Any windowed godot --path . invocation (editor context, even a throwaway one-off verification scene run via res://some/scratch.tscn) can silently REWRITE project.godot in Godot’s own canonical format — including reformatting/mangling hand-authored multi-line comments into unreadable single-line garbage and reverting config/features’s engine-version tag to whatever binary you just ran. Hit twice in one session running quick visual-verification scenes (an aim-weapon shader check, an icon-bake candidate) via godot --path . res://tools/.../scratch.tscn. Confirmed via git diff project.godot: it dropped the entire [physics] section, reverted config/features from "4.7" back to "4.6", and merged several comment lines into one unspaced run-on string. --headless invocations do NOT trigger this (confirmed: repeated headless boot-smoke/GUT runs never touched project.godot) — it’s specifically windowed/editor-context runs. Always git diff project.godot (or at minimum git status --short | grep project.godot) immediately after ANY windowed godot invocation, and git checkout -- project.godot immediately if it shows modified — never let this land in a commit. (2026-07-16, app-icon + aim-weapon verification harnesses.)
  • SwarmRenderer.sync()’s optional per-instance scales parameter and its _orient (bullet-shape, velocity-oriented) mode are mutually exclusive — passing scales silently DROPS orientation for every instance, not just the scaled ones. The scaled branch builds Transform2D(Vector2(s,0), Vector2(0,s), pos) unconditionally, never checking _orient, so a renderer configured via set_bullet_shape() (auto-fire bullets, all velocity-oriented) would render every projectile axis-aligned instead of pointed along its travel direction the moment ANY per-instance scale array was passed — this is why per-instance-scaling a SHARED oriented-bullet pool (e.g. to visually shrink just one weapon’s projectiles) isn’t a safe drop-in fix without first extending sync() to compose scale+rotation together (untested, unshipped combination as of 2026-07-16 — treat as a real follow-up task, not a quick patch). Separately: the shared proj_renderer’s round dot is a FIXED 12px diameter (mesh_radius=6.0 in configure(), doubled by _make_mm) for every player weapon regardless of that weapon’s own proj_radius sim stat (hitbox size and visual size are intentionally decoupled) — an overlay bullet-shape narrower than 12px drawn on top of it will always show the round dot poking out past the overlay’s edges on both sides; this exists at the SAME relative severity even at the overlay’s original/shipped width, not just at a newly-thinned one (confirmed via a magnified throwaway capture comparing several widths). (2026-07-16, manual-weapon “Railspike” visual redesign.)
  • Untyped Variant inference is a HARD COMPILE ERROR in this project’s GDScript config, not a warning — and the resulting symptom actively misleads you toward suspecting the wrong file. var script := weapon.get_script() (or any var x := .../var x = ... whose right-hand side is a Variant-returning expression — Object.get(property), Object.get_script(), an untyped Dictionary subscript like some_dict[key].field) fails to compile with “The variable type is being inferred from a Variant value… (Warning treated as error)”. Because this is a COMPILE failure, the class’s class_name never registers at all — so every later static/class-name call into it (MyClass.some_method(), or even MyClass.new()) fails at RUNTIME with the unrelated-looking “Nonexistent function ‘X’ in base ‘GDScript’”, which looks exactly like “the class doesn’t exist” or “the engine is broken,” not “there’s a type error three lines into this file.” This blocked one task for over an hour before the real cause was found by writing an isolated SceneTree probe script and reading its actual compile error output directly (GUT’s own runner swallows/reframes the underlying parse error into the misleading “Nonexistent function” message). Fix: add an explicit type annotation at every such site — var script: Script = weapon.get_script(), var v: Variant = weapon.get(field), var w = pilot.arsenal.weapon_by_id.get(wid) (untyped var w = expr with no := is fine — inference-from-:= is specifically what’s rejected, so switching to a bare untyped var x = expr OR an explicit var x: SomeType = expr both work; only var x := expr on a Variant-typed RHS fails). When you see “Nonexistent function … in base ‘GDScript’” on what looks like a normal class-name call, suspect THIS first — check every new var x := ... line in the file that failed to register, before suspecting the engine, a stale cache, or the referenced class itself being broken. (2026-07-17, ai/weapon_range.gd’s WeaponRange class — the exact symptom recurred and was recognized/avoided immediately in every subsequent ai/ task the same night once documented mid-session.)
  • Headless (or windowed) Godot can be SIGKILLed by macOS with ZERO output (exit 137) when the Mac is under severe system memory pressure — this is a machine-level resource issue, not a Godot/project bug, and it can strike even a bare --quit-after 5 boot with no test code involved. Confirmed directly across three separate points in one long session (2026-07-19, the sound-map-tool build): vm_stat showed as little as ~150MB free physical RAM with 8→31 concurrent claude processes running simultaneously on the same Mac (Chris routinely runs many parallel Claude Code sessions). Reproduced independently in both a subagent’s sandbox and the main session — even a plain godot --version/--quit-after smoke test with no flags related to the actual work got killed instantly, confirming it wasn’t specific to any one command or test file. Diagnostic: godot ... > file.log 2>&1; echo $? then check for exit 137 AND a zero-byte log — that combination means OOM-kill before Godot even produced output, not a real test/script failure; don’t debug the “failing” test as if it were a code defect. No in-session fix exists (retrying, caffeinate, waiting a few minutes, or reducing to the smallest possible invocation all still failed while memory stayed this low) — the only real fix is freeing system memory (closing other concurrent Claude/heavy-process sessions) before Godot can run at all again. If you hit this: do NOT skip/fake the Godot-dependent verification silently — do everything else that doesn’t need Godot (JS/Python tests, a real browser click-through if the task has a web UI), explicitly document what couldn’t be verified and why, and flag it as a genuine open item for a re-run once memory clears.
  • Main dev editor upgraded stock 4.6.3 → 4.7.1 (Homebrew cask, brew upgrade --cask godot), and the exit-134 SIGABRT-at-shutdown bug (upstream godotengine/godot#119279, previously affecting BOTH 4.6.3 and 4.7-beta per NEXT_TASKS.md’s 2026-07-05 entry, and independently hit again testing 4.7-stable on 2026-07-07 for Toby’s 8-dimensions branch — line above) appears to be genuinely FIXED, not just avoided. Confirmed empirically, not inferred from changelogs: a plain godot --headless --path . --quit-after 10 now exits 0 (previously always 134 regardless of engine version); the full GUT suite (244 scripts, 2266 tests) exits 0 with zero failures and both determinism baselines (single-pilot AND 2-pilot co-op) held byte-identical to their pinned values — no re-pin needed; scripts/check-test-count.sh also exits 0 end-to-end; a real --export-release "macOS" produced a valid .app. Root cause: the upstream issue was closed 2026-07-13 (state_reason: completed) via a maintainer-merged PR (“GDScript: Make language shutdown more crash resilient”), one day before 4.7.1-stable was tagged (2026-07-14) — close enough that the fix made it into the point release. This makes the deferred ~/Claude/godot-patched-4.7 community-patch effort (NEXT_TASKS.md, 2026-07-05 entry) obsolete — stock 4.7.1 already does what that patched fork was built to do, with an officially-merged fix instead of a maintainer-rejected AI-authored one. The macOS crash-reporter popup suppression (defaults write com.apple.CrashReporter DialogType none) is harmless to leave in place but is no longer covering an active crash on this project’s own test runs. GUT bumped 9.6.0→9.7.1 in the same pass (vendored under addons/gut/, no local modifications lost — verified via git log on the addon dir showing only the original vendor commit); its one breaking change (Godot 4.7’s stricter typed Double returns) doesn’t affect this project, which uses no GUT double()/stub() mocking anywhere. Export templates for 4.7.1 (macOS/Web) installed manually via the GitHub release .tpz (~/Library/Application Support/Godot/export_templates/4.7.1.stable/) since the Homebrew cask upgrade only replaces the editor binary, not templates. Scope: only the stock /Applications/Godot.app editor/CLI — the separate hand-patched ~/Claude/godot-tvos fork (already “4.7.dev”, used only to produce the actual tvOS/iOS App Store archive) was deliberately left untouched (Chris’s call). (2026-07-19.)
  • StoryDirector.update() runs BEFORE Sim._sweep_dead() in the same tick (sim/sim.gd) — so any story/tutorial logic reading enemy count/presence within that same tick still sees a just-killed-but-not-yet-removed corpse. damage_enemy() only sets hp <= 0; the enemy isn’t actually removed from the pool (enemies.count doesn’t drop) until the tick’s own _sweep_dead() call, which runs LATER in Sim.tick(). A test (or feature) that kills an enemy directly (elemental_system.damage_enemy(sim, i, 1e9)) and expects sim.enemies.count to reflect the death on the SAME tick is wrong by one tick — the count only updates on the FOLLOWING tick. Bit the ember_e1 solo-spawn wave-advance logic directly: code written to “hold the next wave while sim.enemies.count > 0” that ALSO stopped decrementing its own timer while held created a real deadlock once this one-tick lag was accounted for — fixed by decrementing the timer unconditionally every tick and gating only the actual spawn call on the hold, so the wave is ready to fire the instant the sweep actually clears the corpse. Any new story/director logic keyed off sim.enemies.count (or similar liveness checks) within the same-tick window a kill can happen should assume this one-tick lag exists. (2026-08-10, tutorial-stage-gating Task 7.)
  • A single await get_tree().physics_frame immediately after a heavy setup call (e.g. _on_mode_chosen("tutorial")’s full _new_run()) can resolve with ZERO _physics_process() calls having actually run — the setup consumes enough real wall-clock time that the engine’s physics clock hasn’t accumulated a full tick’s worth of new time by the point the awaited signal fires. This makes a test pass vacuously — asserting “X hasn’t happened yet” trivially succeeds when the tick that would make X happen never ran at all, so the test can’t distinguish a correct implementation from a completely missing one. Confirmed via direct instrumentation (call-counters + Time.get_ticks_msec()) in tests/test_main.gd: a brand-new gate test “passed” with zero implementation present. Fix: drive frames via DIRECT calls instead of await — m._physics_process(1.0/60.0) / m._process(1.0/60.0) — matching the pattern this project already uses elsewhere in tests/test_main.gd (e.g. test_boss_kill_that_also_levels_up_still_unfreezes_after_the_dimension_is_chosen). A related trap in the same test class: writing a DERIVED value directly (e.g. pending_levelups = 1) instead of crossing the real threshold that produces it (xp = xp_to_next, letting the tick’s own XP-processing convert it) can collapse a real one-tick ordering gap a genuine kill has, making an otherwise-correct implementation fail its own test. Whenever a new test needs to “drive one real tick forward” after any setup heavier than a bare add_child_autofree(), prefer direct _physics_process()/_process() calls over await get_tree().physics_frame and verify the assumption with an explicit call-counter at least once when writing a new test pattern. (2026-08-10, tutorial-stage-gating Task 9.)
  • AudioBusLayout has no add_bus_effect method — effects are added via AudioServer.add_bus_effect(bus_idx, effect) at runtime, then captured into a savable resource with AudioServer.generate_bus_layout(). Calling .add_bus_effect() directly on a loaded AudioBusLayout resource fails at runtime (“Nonexistent function”); the resource is a pure data snapshot, not the live mixer. Pattern for scripting a bus-layout edit headlessly: load/mutate via AudioServer (which starts pre-loaded from project.godot’s configured default bus layout), then ResourceSaver.save(AudioServer.generate_bus_layout(), "res://audio/bus_layout.tres"). Note generate_bus_layout() omits any bus property that already equals its class default (e.g. bus 0’s name="Master"/volume_db=0.0) — the resulting .tres diff looks smaller than expected but is complete; verify by loading it back rather than assuming missing lines are a bug. Separately, a real gain-staging gap this surfaced: this project’s audio_manager.gd uses the same round-robin AudioStreamPlayer polyphony pool pattern as ~/Claude/gothtime’s SoundManager — and Godot’s pool sums concurrent voices with NO auto gain-staging, so several individually-fine SFX overlapping (verified via ffmpeg -filter_complex "amix=inputs=N:normalize=0", matching Godot’s real non-normalized summing) can clip at 0dB. gothtime’s fix was per-asset (peak-normalize new ElevenLabs-generated files to -9dB at generation time) but left the “several loud sounds at once” case explicitly unresolved. This project’s fix instead: a single AudioEffectLimiter (ceiling -1dB) on the Master bus — protects every sound, old and future, against clipping from voice summation, with zero risk to the 52 existing WAV assets. (2026-08-12.) Update 2026-09-29: that effect is now AudioEffectHardLimiter (the old class is deprecated in 4.7 and was really a +2 dB makeup + clipper): pre_gain_db = 2.0 keeps the old makeup, ceiling_db = -1.0 the ceiling; the pool is no longer round-robin (idle-first, boss reserve; audio/audio_manager.gd). Bus effects still don’t run on web (Sample playback).
  • On tvOS user:// is the app’s Documents dir and it is READ-ONLY: every FileAccess.open("user://…", WRITE) fails (null, ERR_FILE_CANT_WRITE), subdir or not. Write to OS.get_cache_dir() (Library/Caches) as the fallback. MetaStore (meta/meta_store.gd header, 2026-07-01) and LocalJson both try user:// then the cache dir; that is why meta.json exists in Library/Caches. The 2026-07-23 replay fix (BUILD 185) misread this: it moved the replay from user://replays/ to the user:// ROOT on the theory that “meta.json proves user:// root is writable”, but meta.json was only there via the cache-dir fallback, so RECORD_REPLAY still wrote nothing on the Apple TV (seen again BUILD 319, 2026-10-13: a recorded run died, no replay file in Library/Caches). Fixed 2026-10-13: main._save_replay_if_recording tries user:// then the cache dir. Any new on-device file write must do the same (reuse LocalJson for small JSON). Creating a subdirectory at runtime is also unreliable there, so keep files in the root of whichever dir. Verify a suspected on-device write failure by pulling the app container (memory [[bullet-heaven-tvos-device-file-access]]) and checking Library/Caches.
  • A MultiMeshInstance2D fed through multimesh.buffer = ... gets CULLED by a stale rect. MultiMesh.set_buffer() does not emit changed (set_instance_transform_2d() does), so the node never queue_redraws, and the canvas item’s rect (Item::get_rect(), cached until a redraw re-records its commands) stays at the multimesh AABB from the last redraw. Move the instances (and camera) away from there and the whole layer silently disappears; a visibility toggle redraws and it pops back, which reads as “sometimes”. Shipped as vanishing orbit shards + v2 blaster bolts (2026-10-01). Every buffer-fed MultiMesh calls MultiMeshCulling.disable(mm) (render/multimesh_culling.gd: all-covering custom_aabb, which also skips the engine’s per-upload AABB scan); tests/test_batched_layer_culling.gd fails any production script that sets .buffer = without it. Engine source: servers/rendering/renderer_canvas_render.cpp (TYPE_MULTIMESH rect) + scene/resources/multimesh.cpp (set_buffer). (2026-10-01.)
  • An autowrap Label with no width hint lays out ~one word per line and stretches its container. Inside a VBoxContainer, a Label with autowrap_mode != OFF and no custom_minimum_size.x reports its minimum height as measured at near-zero width, so the parent grows to fit every word on its own line. Shipped (then caught before bless) on the dimension-choice cards: wrapping threat lines made every card 2269px tall, pushing the REWARD line off-screen and the hint row over the cards (ui/dimension_choice_panel.gd). Give every autowrap Label an explicit width (custom_minimum_size.x = <inner width>), and make fit tests assert the laid-out size, not only get_combined_minimum_size(): the min-size check passed while the real layout was broken. (2026-10-04.)
  • A static registry holding Callables (lambdas) aborts the process on quit (exit 134, “recursive_mutex lock failed”): GDScript tears down static members after other scripts are gone, and freeing a lambda then touches a dead script. Release such registries when the root leaves the tree (LiveTokensBus hooks root.tree_exiting, 8845fde1). scripts/gate.sh’s boot smoke now fails on an exit code >= 128, since it once hid this behind || true. (2026-10-04.)
  • Headless GUT runs share user:// with every windowed run of the game on the same Mac (~/Library/Application Support/Godot/app_userdata/Bullet Heaven/, and .../Dark Cosmos/ for the export). A test that asserts a user:// file is ABSENT fails on any machine where the game was played windowed, and on every later gate, in every worktree. test_remote_config_state::test_non_live_setup_touches_no_disk failed this way for a whole overnight run, then raced concurrent gates in other worktrees. Fixed structurally (4ba99b30): tests/gut_pre_run.gd sets LocalJson.root_override to a per-process temp dir, so any test that turns LocalJson.io_enabled on writes there, never the shared dir. A new “touches no disk” test should assert on LocalJson._paths(name), not hard-coded user:// paths; anything writing outside LocalJson still needs its own isolation. (2026-10-07.)
  • main.tscn boots straight into a survival run under GUT, so a test wanting the start-menu state (sim == null, e.g. a menu-only dev command) must call m._return_to_menu() after the first process_frame. Otherwise sim is live and menu-gated code silently takes its in-run branch (cost a debug cycle on det_probe, 2026-10-07).
  • ENetMultiplayerPeer.disconnect_peer(id, force) drops queued outgoing packets whatever force is (it calls enet_peer_disconnect, which resets the outgoing queues), so a reliable message sent just before it (the lobby’s MSG_REJECT reason) never arrives: 0/2 deliveries in a real-ENet test. Use get_peer(id).peer_disconnect_later() (flushes, waits for the reliable acks, then disconnects) and keep a deadline that force-disconnects a peer that never acks, or a dead joiner holds the slot until ENet’s own ~30 s timeout. The peer_disconnected signal fires later, so bookkeeping must tolerate an id already removed. Pattern: ENetTransport.disconnect_peer + LockstepSession HELLO_DEADLINE_MS (2026-10-08, net-lobby-harden).
  • A global uniform that exists only at runtime breaks the export shader baker (4.7.1): RenderingServer.global_shader_parameter_add() in code is enough for the editor and a live run, but the baker prints “Global uniform ‘X’ does not exist. Create it in Project Settings.” and leaves that shader UNBAKED, which costs a first-use pipeline stall on device. Declare every global in [shader_globals] of BOTH project.godot and platform/tvos/project.godot, and only global_shader_parameter_set() it at runtime (calling _add on a declared name is an engine error).
  • Under HDR 2D, colours enter shaders in two different spaces. modulate, draw_* colours and source_color uniforms are converted sRGB→linear (with the pow-2.4 curve even above 1.0: an authored 2.0 becomes ≈4.95 linear); MultiMesh instance COLOR and plain vec3/vec4 uniforms are used raw. With HDR output on, the Linear tonemapper then hard-clips PER CHANNEL at the headroom (hue shift), and adjustment_* contrast/saturation run AFTER the tonemap in sRGB space. AgX ignores tonemap_white (it uses tonemap_agx_white/tonemap_agx_contrast). Detail: docs/research/2026-10-08-hdr-colour-best-practice.md.
  • ClassDB has no class_has_property (a parse error on 4.7.1 and 4.8; a plan used it four times). Probe an engine-patch property via its setter, ClassDB.class_has_method(&"Viewport", &"set_output_gamut"), or scan class_get_property_list. (2026-10-11, HDR P3.)
  • strings <engine binary> | grep <shader text> always finds nothing: RD shader sources are zlib-compressed into *.glsl.gen.h (_code_compressed[]). To prove a shader patch reached a binary, decompress the regenerated gen.h array and search for its compressed bytes in the binary (platform/engine-patches/check-p3-shaders.py on the hdr-colour-space line). (2026-10-11.)
  • The Godot 4.8-dev fork editor crashes on exit (signal 11 in ~RenderingDeviceDriverMetal) after EVERY export, tvOS and iOS, after [ DONE ] savepack/export is already written; the patch isn’t the cause and upstream had no fix as of 2026-10-11. Export scripts must not trust the exit code blindly but must not || true either: scripts/lib/godot-export.sh (godot_export, from branch hdr-p3-t2) accepts it only after the DONE line with a Metal-teardown backtrace. A 4.8 export also rewrites ~160 tracked *.import files; the same helper restores only the ones it dirtied.
  • Audio buses: one send each, and no bus effects on web. A Godot bus routes to exactly ONE parent bus, so there are no aux/reverb sends. A shared “room” reverb is an AudioEffectReverb insert (dry 1.0, low wet) on the bus itself. Bus effects don’t run in the web export (Web Audio sample playback; per tests/test_audio_bus_layout.gd’s note, not re-measured), so web hears only what is baked into the files. audio/bus_layout.tres is rewritten by import/boot, which is why the SFX overhaul builds its effects at runtime (SfxMix.install(), plan docs/superpowers/plans/2026-10-12-sfx-organic-overhaul.md). (2026-10-12.)
  • A non-wrapping Label with OVERRUN_TRIM_ELLIPSIS and custom_minimum_size.x = 0 collapses to ~1 px inside an HFlowContainer (the flow gives it its minimum width, which trimming makes ~0). The level-up “Lv N → N+1” label shipped invisible that way on every card while its headless text/fit tests passed; only a windowed capture caught it. Set text_overrun_behavior = OVERRUN_NO_TRIMMING (or a real minimum width) on short labels in flow rows, and assert the laid-out width ≥ the text width. (2026-10-14.)
  • WebSocketPeer (WSLPeer) throws away frames that arrive in the same poll() as a close frame. A server that sends “here is why” and then closes loses the message on the client (modules/websocket/wsl_peer.cpp, in_buffer cleared on close). Put the reason in the close reason (get_close_reason()), not in a preceding frame. The online co-op relay kick does this (4005 + reason, see coop-and-pilots.md), and its fake socket models the drop so tests catch a regression.