Bullet Heaven — Godot gotchas
Bullet Heaven — Godot gotchas
Section titled “Bullet Heaven — Godot gotchas”Extracted from
CLAUDE.mdon 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. SeeCLAUDE.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.
Godot gotchas learned the hard way
Section titled “Godot gotchas learned the hard way”- GUT
watch_signalson 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 aFeedbackPanelorSettingsPanel,watch_signalsit, closes it (queue_free) and repeats hung on most runs at 4+ iterations; one watched panel per test is fine. Count signals with a plainconnect+ 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 inmain._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_dirsoverride and only ran the typed branch. A test seam that overrides a default must come with a test of the default itself. tvOS Godotprint/push_erroroutput does not reachdevicectl ... --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 Macuser://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 (RDrenderer_canvas_render_rd.cpp, GLES3) copyCopyBackBuffer.rect; the cull step computes a transformedscreen_rectthat nothing reads. Withstretch/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*.importfiles. The crash is signal 11 inRenderingDeviceDriverMetal::~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, thenERROR: 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-afterexits 0. No upstream fix as of 2026-10-11: upstreamdrivers/metalhas 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 byscripts/lib/godot-export.sh‘sgodot_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 saysProject export for preset ... failed(even on exit 0) or whose output (the.pck, or<name>.xcodeproj/project.pbxprojfor 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 precedeshandle_crash:, noERROR:line sits between them, and the backtrace namesRenderingDeviceDriverMetal. It reverts only the*.importfiles 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 byscripts/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.
ControlClientauto-arm inmain._ready, which posts a boot/statusstraight away) never runs in a plaingodot --headless --path . --quit-after 600. 2026-10-09: that boot/statuscalleddebug_settings.graphics_report()beforedebug_settingsexisted; the editor (debug) VM logs aSCRIPT ERRORand carries on, the export (release) VM crashed on launch on ATV + iPhone.scripts/gate.shstep “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 printDEVICE_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, andpow()/exp()return an untyped value.var x := powf(a, b)is a parse error, andvar x := pow(a, b)fails inference (“Cannot infer the type”); writevar x: float = pow(a, b). A parse error in aclass_namescript fails every dependent script’s compile, and a headless-stool that depends on it then hangs silently instead of erroring — rungodot --headless --path . --check-only -s res://<file>.gdto 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.zipata8d5c0bb62, editor at284e9804c4).ShaderRDappendsGODOT_VERSION_HASHtobase_sha256, which becomes thegroup_sha256directory 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_checkonly counts cache entries in the pack andshell_engine_checkcompares 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 BEFOREarchive_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
CanvasItemMaterialbreaks 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 inuser://shader_cachebeside a bake-only key.MTL_DEBUG_LAYER=1turns the silent vanish into an assert. Unfixed in 4.7.x: Metalcanvas_databuffer-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 allCanvasItemMaterial.new()with the shared savedres://fx/additive_mat.tres(baker ON, BUILD 254). Runtime.duplicate()of saved ShaderMaterials was harmless.tests/test_baked_shader_materials.gdbansCanvasItemMaterial.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 screenshotworks 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 afterdevicectl 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) computesci->modulate * parent_modulateandreturns if.a < 0.007– nothing is submitted, so no Metal pipeline is linked either (self_modulateis not part of the test; a CanvasLayer or SubViewport resets the chain). “Alpha 0.001 = invisible but drawn” is therefore false:PrewarmPassran 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_ALPHAmirrors the constant andtests/test_prewarm_cull_alpha.gdguards it. - Headless cannot read back
MultiMeshper-instance transforms/colors. Under--headlessthe dummy RenderingServer returns(0,0)/black forget_instance_transform_2d/get_instance_coloreven thoughinstance_countis correct. So MultiMesh render tests can only assertinstance_count(+ resync behavior) headlessly; pixel placement is verified by playtest. (Probe to confirm: aSceneTreescript that sets then gets an instance transform.) run/main_scenepointing at a not-yet-existent scene makesgodot --importHARD-FAIL (Cannot open file 'res://main.tscn'), which leaves the global class cache stale so newly-addedclass_namescripts aren’t registered — and GUT then silently drops their tests from a-gdirscan. A green suite can be running fewer tests than you think. Always verify the test COUNT, not just “all passed.” Keepmain.tscnpresent (or don’t reference a missing main_scene)..godot/is gitignored, so CI rebuilds the cache from scratch — a CI--importstep that fails on error + an expected-test-count assert would guard this.SwarmRenderer.configure(color)setsmodulateANDsync()sets per-instance color → passing the same color to both renders it squared (darker). Configure withColor.WHITE, let per-instancesynccolor 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 intests/test_determinism_survival.gdandtests/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 inproject.godot. Godot serializes key events asObject(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_actiontrue butaction_get_events().size()==0), soInput.get_vectorsilently returns zero and nothing responds — symptom: “can’t move,” no error. Register input actions in CODE instead:InputRouter.ensure_actions()bindsphysical_keycodeWASD viaInputMap.add_action/action_add_event(layout-independent, idempotent, unit-testable). Probe to diagnose any input issue: aSceneTreescript printingInputMap.action_get_events(a).size(). - GUT 9.6 FAILS any test in which an un-asserted
push_errorfires (addons/gut/error_tracker.gd, defaulttreat_push_error_as = FAILURE;gut.gdshould_test_fail_from_errors). So a function thatpush_errors on bad input (e.g.ContentLoader.load_from_dicton 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 againstContentLoader.validate(raw)’s returned problem array, which does NOT push_error; (b) when you must exercise the erroring path, consume the expected error withassert_push_error("substr")/assert_push_error_count(n)(case-insensitive; marks ithandledso it no longer fails the test). Keep the productionpush_error(it’s the boot-time fail-loud); just consume it in the one test that triggers it. Corollary: pure/simhelpers that “should never” hit a branch (e.g.StatEffects.applyon an unknown effect) take a silent no-op rather thanpush_error, since the bad path is upstream-guarded by validation and apush_errorthere only trips tests. (OS.is_debug_build()is NOT an escape hatch —OSis 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 typedDouble/stub return values under 4.7) doesn’t affect this project — no test file uses GUT’sdouble()/stub()mocking. CanvasLayerhas nothemeproperty — it’s aNode, not aControl. Setting.themeon a CanvasLayer root silently does nothing. ApplyNeonTheme.get_theme()to the first childControlnode (e.g.center.theme = …on aCenterContainer), 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 whenais a Dictionary (values are Variants). Writevar 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 acrossorwith a narrowed-event property → a SILENT-HANG class-compile failure.var ok := a or (event is InputEventX and event.pressed)— GDScript does NOT narroweventtoInputEventXinside anor, soevent.pressed/.button_indexread 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 anif event is InputEventX:block or annotatevar ok: bool =. Always boot-check forSCRIPT ERRORbefore trusting a build (note:timeoutis not on macOS PATH — it’sgtimeout— so atimeout-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 isassert_lte) is a Parse Error that silently DROPS the whole test file from a-gdirrun — the suite still reports “All tests passed” with one FEWER script. Thescripts/check-test-count.shguard catches it (ran fewer scripts thantest_*.gdfiles); always trust the COUNT, not just “passed”. GUT methods:assert_lte/assert_gte, notassert_le/assert_ge. (2026-06-24.) - Under warnings-as-errors,
:=also fails onweakref()results — annotate themvar w: WeakRef = weakref(x). Same class as theDictionary.get()trap: the inferred type is not what the later.get_ref() as Tcall 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) needsMAX_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 nobible.jsonentry; skirmisher=7), so_enemy_type_colors.resize(names.size())thenarr[tid]=…indexes out of bounds for tid 7. Size toTYPE_SKIRMISHER + 1and 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-inreadysignal →Parse Error: Member "ready" redefined (original in native class 'Control'), which fails the WHOLE script compile →.new()returns null → a silent freeze (the boot-checkgrep "SCRIPT ERROR"catches it). Use another name (e.g.crackling). Same class as the:=-across-orsilent-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_statsis called every spawn and DOES move the checksum (it draws rng values per spawn, shifting entity positions). The task-10 re-pin movedstate_checksumfrom1267954985→2325839371;snapshot_string().hash()remained4152236597(aggregates unaffected by per-entity stat variation). - A render-side effect that DECAYS / restores state each frame MUST be advanced ABOVE the
_processearly-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.advancesat 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 afterif sim == null: return) and force-finish them ongame_over. The_physics_processsim-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 SLOTi— NOT a per-type LUT indexed bytype_id. (It readcolors[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_colorsall run throughElementPalette.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 fromdata/bible.json) andEnemyPool.TYPE_NAMES(telemetry attribution) can diverge from the content id:EnemyPool.TYPE_ELITE(bible id"elite", behavior=dash) displays as “Charger”;EnemyPool.TYPE_BOSS2displays 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_NAMEStelemetry label are unchanged, onlybible.json/bestiary.jsonnamefields moved). When a request names an enemy by its in-game/display name, grepbestiary.json+EnemyPool.TYPE_NAMESfirst. (2026-07-01.) - A baked player-ship hull sprite (
render/ship_sprites/ship3d_*.png, shown byPlayerRendererwhenUSE_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.PlayerRendererjust rotates the wholeSprite2DviaNode2D.rotationto 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_previewSHIP_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 throughtools/ship_previewSHIP_VARIANT=current(windowed, the REALPlayerRenderer, 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 biblexp_value) is VESTIGIAL — never read. Actual kill XP is 100% biomass-driven viaSim._xp_worth()/enemies.biomass. A feature that reads/writesxp_valexpecting it to affect reward is a no-op. (2026-07-01.)- GUT’s
-gtest=res://tests/<file>.gddid 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-gdirrun 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 byrotradians: 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 assertget_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
.gdfile with aclass_namedeclaration 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). Movingui/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 resolvedLevelUpPanel.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: stripclass_namefrom any.gdfile 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
/simspace (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 aCamera2D.zoomchosen 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 (reusingGlowTexture, the patternrender/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 aset_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.QualityManagercallsset_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 checkset_lowalready 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 inset_low’s loop, not by a failing test — add a regression test assertingset_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 exercisingAreaDefs.other()istests/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
@rpcannotation) any method it invokes viarpc_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 viagodotengine/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 (seenet/mp_host.gd/net/mp_client.gd’sreceive_snapshot/receive_inputpairs 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 on127.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
NSLocalNetworkUsageDescriptionin 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.plistfor tvOS,build-ios/BulletHeaven/BulletHeaven-Info.plistfor iOS — NOT the DerivedData build output, which gets regenerated) — this persists across--export-packre-exports exactly like the existing signing-identity pbxproj fix, since--export-packonly 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_Aconfirm,_buttons/_sel/_focus_sel/_move(), seeui/pause_menu.gd) — plain default-focus GodotButtons do NOT reliably respond to the Siri Remote. Confirmed as a real, live bug: a new lobby screen built with ordinaryButtons (relying on default Godot Control focus/click) silently did nothing when pressed on a real Apple TV, even thoughgrab_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 focusedLineEditbefore the LineEdit’s owntext_submittedmechanism ever sees it. Confirmed live: a screen with both a custom debounced-nav_input()(Enter confirms_buttons[_sel]) and aLineEditfor 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_selwas, 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(), checkline_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 (localmainroutinely 40-90+ commits ahead oforigin/main), butEnterWorktree’s defaultfreshmode branches fromorigin/<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 onmain(e.g. a plan doc written minutes earlier) are simply missing in the new worktree. Fix: immediately afterEnterWorktree, rungit log <new-branch> --not main --oneline— if empty (no unique commits), it’s safe togit reset --hard mainto bring it current. Always re-verify withgodot --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
maininstead of its worktree branch; a controller-level plan-doc edit did the same). It’s usually invisible until you explicitly diffgit log <worktree-branch> --onelineagainstmain’s own log after every dispatch. Cheap mitigation that measurably helped: tell every subagent to runpwd && git rev-parse --show-toplevelas its literal first action AND again immediately before its final commit, and independently re-verify (git log --oneline -3in 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 memorybullet-heaven-worktree-tooling-gotchasfor the full writeup + the broadened mitigation (2026-07-04). Control.set_anchors_preset(FULL_RECT)called onselffrom 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 defaultresize_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: callset_anchors_presetfrom_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 callingadd_childon it — seedialogue_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 printingget_rect()(not visible from a screenshot alone, since a collapsed Control just renders nothing rather than erroring). New regression tests assert the resolvedoffset_left/top/right/bottomare all0.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
.visiblewill silently override an explicit one-shot toggle set by other code — this has now recurred 3 times. Pattern: somemain._process()-driven toggle (e.g._toggle_tactical_hud()) setssome_node.visible = falseonce, but a DIFFERENT function ALSO runs every frame and doessome_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 nextupdate()call”). Confirmed instances:marketing/capture/shoot.gd’s own comment aboutauto_label.visible=_autofighting a hide attempt;DroneDock.update_dock(sim)doingvisible = total > 0every frame (totalis essentially always > 0) and stompingmain._toggle_tactical_hud()’s hide (2026-07-04) — the siblingWeaponPanelnever had this bug because its ownupdate_panel()never touches.visibleat all, leaving toggle ownership exclusively withmain.gd. Rule for any new per-frameupdate()-style method: if some OTHER code owns turning a node visible/invisible via an explicit toggle, theupdate()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 assertingupdate()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 tomove_*/aim_*) only ran inside_new_run()’sInputRouter.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-inui_*actions don’t need registering).MenuNav.is_down/up/left/right’sevent.is_action_pressed("move_down")on a nonexistent action just logsERROR: The InputMap action "move_down" doesn't existand returns false — no crash, no obvious symptom besides silently-dead input. Fix: callInputRouter.new().ensure_actions()unconditionally as the very first line ofmain.gd’s top-level_ready(), before the headless/start-menu branch — it’s pureInputMapregistration with no tree/scene dependency, safe before any node is even added. Any new custom action bound outsideproject.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 ittruebefore 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 usingassert_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 calledset_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 callsget_viewport().set_input_as_handled()for a rawInputEventScreenTouch/InputEventScreenDragUNCONDITIONALLY (regardless of screen position) silently prevents any realButtonsharing that screen from ever receiving its own click —_input()runs on every node BEFORE Godot’s GUI-click dispatch phase (the mechanism that firesButton.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 inui/ship_config_panel.gdunconditionally 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 callset_input_as_handled()for touch (matchingui/start_menu.gd’s own deliberate pattern — see its inline comment), or explicitly gate onsome_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/HBoxContainerchild) 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 (viaCenterContainer) 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_FRACfractions visually overlapped the menu’s own footer buttons once mode cards + an inline row were removed from its VBox. Fix: aControlspacer 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, seeaddons/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(noScrollContainer) silently runs off the edges of a fixed 1280×720 TV viewport once it’s tall enough —CenterContainerhas 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 rowVBoxContainerin aScrollContainerwithfollow_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 bareCenterContainer. (2026-07-04.) - Godot 4.7.stable (Homebrew cask
godot, macOS) crashes on exit from--headlessmode — EVERY time, not just under GUT. Reproduced with the plainest possible command (godot --headless --path . --quit-after 10, zero tests): SIGABRT instd::__1::recursive_mutex::lock()on the main thread during final process teardown (all 16WorkerThreads 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.ipsto~/Library/Logs/DiagnosticReports/Godot-*.ipsfor every one. The crash is strictly post-completion: GUT’s full pass/fail summary and any--export-releaseoutput 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, soscripts/check-test-count.sh’sset -earound-gexitwill 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 withDialogType 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 undergodot --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-releasebuild. Write such tests by deriving the expected state fromBuildConfig.dev_tools()live (assert_eq(has_remote, BuildConfig.dev_tools())), not by hardcoding eithertrueorfalse. (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’sset_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 viaget_global_mouse_position(), not a rawInputEventMouseMotionstream). 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, bothinput/input_router.gd.) - Any windowed
godot --path .invocation (editor context, even a throwaway one-off verification scene run viares://some/scratch.tscn) can silently REWRITEproject.godotin Godot’s own canonical format — including reformatting/mangling hand-authored multi-line comments into unreadable single-line garbage and revertingconfig/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) viagodot --path . res://tools/.../scratch.tscn. Confirmed viagit diff project.godot: it dropped the entire[physics]section, revertedconfig/featuresfrom"4.7"back to"4.6", and merged several comment lines into one unspaced run-on string.--headlessinvocations do NOT trigger this (confirmed: repeated headless boot-smoke/GUT runs never touchedproject.godot) — it’s specifically windowed/editor-context runs. Alwaysgit diff project.godot(or at minimumgit status --short | grep project.godot) immediately after ANY windowed godot invocation, andgit checkout -- project.godotimmediately 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-instancescalesparameter and its_orient(bullet-shape, velocity-oriented) mode are mutually exclusive — passingscalessilently DROPS orientation for every instance, not just the scaled ones. Thescaledbranch buildsTransform2D(Vector2(s,0), Vector2(0,s), pos)unconditionally, never checking_orient, so a renderer configured viaset_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 extendingsync()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 sharedproj_renderer’s round dot is a FIXED 12px diameter (mesh_radius=6.0inconfigure(), doubled by_make_mm) for every player weapon regardless of that weapon’s ownproj_radiussim 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 anyvar x := .../var x = ...whose right-hand side is aVariant-returning expression —Object.get(property),Object.get_script(), an untypedDictionarysubscript likesome_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’sclass_namenever registers at all — so every later static/class-name call into it (MyClass.some_method(), or evenMyClass.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 isolatedSceneTreeprobe 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)(untypedvar w = exprwith no:=is fine — inference-from-:=is specifically what’s rejected, so switching to a bare untypedvar x = exprOR an explicitvar x: SomeType = exprboth work; onlyvar x := expron 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 newvar 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’sWeaponRangeclass — the exact symptom recurred and was recognized/avoided immediately in every subsequentai/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 5boot with no test code involved. Confirmed directly across three separate points in one long session (2026-07-19, the sound-map-tool build):vm_statshowed as little as ~150MB free physical RAM with 8→31 concurrentclaudeprocesses 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 plaingodot --version/--quit-aftersmoke 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 (upstreamgodotengine/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 plaingodot --headless --path . --quit-after 10now 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.shalso 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.7community-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 underaddons/gut/, no local modifications lost — verified viagit logon the addon dir showing only the original vendor commit); its one breaking change (Godot 4.7’s stricter typedDoublereturns) doesn’t affect this project, which uses no GUTdouble()/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.appeditor/CLI — the separate hand-patched~/Claude/godot-tvosfork (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 BEFORESim._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 setshp <= 0; the enemy isn’t actually removed from the pool (enemies.countdoesn’t drop) until the tick’s own_sweep_dead()call, which runs LATER inSim.tick(). A test (or feature) that kills an enemy directly (elemental_system.damage_enemy(sim, i, 1e9)) and expectssim.enemies.countto reflect the death on the SAME tick is wrong by one tick — the count only updates on the FOLLOWING tick. Bit theember_e1solo-spawn wave-advance logic directly: code written to “hold the next wave whilesim.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 offsim.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_frameimmediately 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()) intests/test_main.gd: a brand-new gate test “passed” with zero implementation present. Fix: drive frames via DIRECT calls instead ofawait—m._physics_process(1.0/60.0)/m._process(1.0/60.0)— matching the pattern this project already uses elsewhere intests/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 bareadd_child_autofree(), prefer direct_physics_process()/_process()calls overawait get_tree().physics_frameand 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.) AudioBusLayouthas noadd_bus_effectmethod — effects are added viaAudioServer.add_bus_effect(bus_idx, effect)at runtime, then captured into a savable resource withAudioServer.generate_bus_layout(). Calling.add_bus_effect()directly on a loadedAudioBusLayoutresource 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 viaAudioServer(which starts pre-loaded fromproject.godot’s configured default bus layout), thenResourceSaver.save(AudioServer.generate_bus_layout(), "res://audio/bus_layout.tres"). Notegenerate_bus_layout()omits any bus property that already equals its class default (e.g. bus 0’sname="Master"/volume_db=0.0) — the resulting.tresdiff 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’saudio_manager.gduses the same round-robinAudioStreamPlayerpolyphony pool pattern as~/Claude/gothtime’sSoundManager— and Godot’s pool sums concurrent voices with NO auto gain-staging, so several individually-fine SFX overlapping (verified viaffmpeg -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 singleAudioEffectLimiter(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 nowAudioEffectHardLimiter(the old class is deprecated in 4.7 and was really a +2 dB makeup + clipper):pre_gain_db = 2.0keeps the old makeup,ceiling_db = -1.0the 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: everyFileAccess.open("user://…", WRITE)fails (null, ERR_FILE_CANT_WRITE), subdir or not. Write toOS.get_cache_dir()(Library/Caches) as the fallback. MetaStore (meta/meta_store.gdheader, 2026-07-01) andLocalJsonboth try user:// then the cache dir; that is whymeta.jsonexists in Library/Caches. The 2026-07-23 replay fix (BUILD 185) misread this: it moved the replay fromuser://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, soRECORD_REPLAYstill 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_recordingtries user:// then the cache dir. Any new on-device file write must do the same (reuseLocalJsonfor 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
MultiMeshInstance2Dfed throughmultimesh.buffer = ...gets CULLED by a stale rect.MultiMesh.set_buffer()does not emitchanged(set_instance_transform_2d()does), so the node neverqueue_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 callsMultiMeshCulling.disable(mm)(render/multimesh_culling.gd: all-coveringcustom_aabb, which also skips the engine’s per-upload AABB scan);tests/test_batched_layer_culling.gdfails 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
Labelwith no width hint lays out ~one word per line and stretches its container. Inside aVBoxContainer, a Label withautowrap_mode != OFFand nocustom_minimum_size.xreports 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-outsize, not onlyget_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 (LiveTokensBushooksroot.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 auser://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_diskfailed this way for a whole overnight run, then raced concurrent gates in other worktrees. Fixed structurally (4ba99b30):tests/gut_pre_run.gdsetsLocalJson.root_overrideto a per-process temp dir, so any test that turnsLocalJson.io_enabledon writes there, never the shared dir. A new “touches no disk” test should assert onLocalJson._paths(name), not hard-codeduser://paths; anything writing outsideLocalJsonstill needs its own isolation. (2026-10-07.) main.tscnboots 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 callm._return_to_menu()after the firstprocess_frame. Otherwisesimis live and menu-gated code silently takes its in-run branch (cost a debug cycle ondet_probe, 2026-10-07).ENetMultiplayerPeer.disconnect_peer(id, force)drops queued outgoing packets whateverforceis (it callsenet_peer_disconnect, which resets the outgoing queues), so a reliable message sent just before it (the lobby’sMSG_REJECTreason) never arrives: 0/2 deliveries in a real-ENet test. Useget_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. Thepeer_disconnectedsignal fires later, so bookkeeping must tolerate an id already removed. Pattern:ENetTransport.disconnect_peer+LockstepSessionHELLO_DEADLINE_MS(2026-10-08, net-lobby-harden).- A
global uniformthat 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 BOTHproject.godotandplatform/tvos/project.godot, and onlyglobal_shader_parameter_set()it at runtime (calling_addon a declared name is an engine error). - Under HDR 2D, colours enter shaders in two different spaces.
modulate,draw_*colours andsource_coloruniforms 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 plainvec3/vec4uniforms are used raw. With HDR output on, the Linear tonemapper then hard-clips PER CHANNEL at the headroom (hue shift), andadjustment_*contrast/saturation run AFTER the tonemap in sRGB space. AgX ignorestonemap_white(it usestonemap_agx_white/tonemap_agx_contrast). Detail:docs/research/2026-10-08-hdr-colour-best-practice.md. ClassDBhas noclass_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 scanclass_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.pyon thehdr-colour-spaceline). (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/exportis 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|| trueeither:scripts/lib/godot-export.sh(godot_export, from branchhdr-p3-t2) accepts it only after the DONE line with a Metal-teardown backtrace. A 4.8 export also rewrites ~160 tracked*.importfiles; 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
AudioEffectReverbinsert (dry 1.0, low wet) on the bus itself. Bus effects don’t run in the web export (Web Audio sample playback; pertests/test_audio_bus_layout.gd’s note, not re-measured), so web hears only what is baked into the files.audio/bus_layout.tresis rewritten by import/boot, which is why the SFX overhaul builds its effects at runtime (SfxMix.install(), plandocs/superpowers/plans/2026-10-12-sfx-organic-overhaul.md). (2026-10-12.) - A non-wrapping
LabelwithOVERRUN_TRIM_ELLIPSISandcustom_minimum_size.x = 0collapses to ~1 px inside anHFlowContainer(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. Settext_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 samepoll()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_buffercleared 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, seecoop-and-pilots.md), and its fake socket models the drop so tests catch a regression.