Skip to content

Bullet Heaven — remote config

Written 2026-09-27 at the end of the remote-config SDD build (spec docs/superpowers/specs/2026-09-27-remote-config-design.md, ledger .superpowers/sdd/2026-09-27-remote-config/progress.md). This is the current architecture reference for this system, not a changelog — keep it current when you change the code. See CLAUDE.md § “Subsystem architecture — read on demand”.

Change game balance (weapon/enemy stats, shop prices, sim tuning consts) for players already on a shipped build, without an App Store build or review. Numbers only — no downloaded code, so it stays inside App Store guideline 2.5.2. A patch can also replace sound effects with downloaded WAV samples (data, not code): see § Sounds — remote sound packs. Publishing a patch: remote-config/README.md.

boot/menu/resume/run start run start during the run
────────────────────── ───────────────────── ───────────────
RemoteConfig.setup()/ main.gd._new_run(): nothing — the patch
.refresh() fetches remote_config.apply_for_run() never changes again
GET /config?build=… → Tunables.apply(patch.tunables) until the NEXT run
→ validate → cache in → GameContent.load_db(bible) start.
user://remote_config.json → Sim.new(content, …)
(last-good patch, survives remote_config.mark_run_started()
offline/error/timeout) (arms the crash marker if unproven)
...30s later...
remote_config.mark_run_stable()
(clears the marker, proves the id)
after each fetch (live builds) run start
────────────────────────────── ─────────────────────
RemoteSounds.sync(next patch) apply_for_run (records the run's patch)
prune unreferenced blobs mark_run_started (crash marker)
GET /sfx/<sha>.wav, 1 at a main: audio.set_overrides(remote_config.sound_overrides())
time → verify → cache (hash + parse cached blobs now; replaced, never merged)
  • Fetch happens at boot, on every return to menu, at the top of every _new_run() (before apply_for_run), and on NOTIFICATION_APPLICATION_RESUMED / FOCUS_IN, all throttled to RemoteConfig.REFRESH_MIN_S (5 min, net/remote_config.gd). The run-start and resume refreshes exist so a patch or rollback reaches a player who chains runs (Redeploy / Climb again never pass through the menu) or only ever suspends the app. The response is async, so the run-start refresh only ever affects the next run. It never blocks boot or the menu (async HTTPRequest, silent on any failure; the Worker answers a KV failure with 503 no-store, and any non-200 keeps the cached patch). A response that lands mid-run is only picked up at the next run start — a balance patch never mutates a run in progress. This is deliberate: it protects the balance philosophy (power grows across runs, not mid-run), keeps replays/co-op/telemetry attribution simple (one config per run, stamped once), and matches how MetaRunSetup.apply already applies shop state once at run start.
  • main.gd._new_run() calls remote_config.apply_for_run(override) before Sim.new() — the same place MetaRunSetup.apply already sits. override is a replay’s recorded patch (non-null only during replay playback); when set, the fetched/cached patch is ignored and the crash marker is never armed (a replay isn’t a fresh boot on a config nobody has run yet).
  • apply_for_run always resets every tunable to its bundled default first (Tunables.reset), then applies the patch’s tunables — never cumulative across runs or across patch changes.
  • The menu shop reads remote_config.next_bible_overlay() (the patch queued for the next run) so prices shown before a run starts match what that run will actually charge. RemoteConfig.changed fires whenever a fetch changes the next-run patch; main._on_remote_config_changed drops the cached menu content and, if the shop is open, calls MetaShopPanel.replace_defs() so displayed price == charged price == next-run price.

Sounds — remote sound packs (2026-10-13)

Section titled “Sounds — remote sound packs (2026-10-13)”

Replace shipped sound effects for players on an already-released build, no App Store resubmission. Data only (WAV samples through the existing AudioManager chain), so still inside guideline 2.5.2. Publishing one: remote-config/README.md § “Publishing a sound”.

  • Patch shape. Optional sounds: { "<sound key>": { "sha256": "<64 lowercase hex>", "bytes": n } }. A key is any AudioManager.known_sound_keys() entry: SFX_KEYS, the death pools, the per-boss spawn_<boss> / enrage_<boss> slots, and every <key>_N round-robin variant the build ships in audio/sfx_v2/ (so every audio/sfx_tiers.json key; music is not included). The list is written into the manifest (sounds) by tools/remote_config/dump_manifest.gd. A manifest with no sounds list (builds before this feature) makes publish-config.mjs refuse any sounds section. Caps: 1 MB per file, 8 MB for the section (over it, none of it applies), both checked at publish and again on the client. A non-object sounds makes RemoteConfigState.valid refuse the whole patch, the same as tunables/bible; a bad single entry or unknown key is dropped and logged.
  • Hosting. GET /sfx/<sha256>.wav on the telemetry Worker (serveSfx in telemetry/src/worker.js), reading KV key sfx-<sha> from the same CONFIG namespace as the patches (KV values go to 25 MiB, so R2 isn’t needed; the sfx- prefix never matches the build- fallback listing). Cache-Control: public, max-age=31536000, immutable; the path must be exactly 64 lowercase hex + .wav before KV is read; a miss is 404 no-store (a blob uploaded a moment later is never shadowed by a cached 404); a KV error is 503 no-store. publish-config.mjs uploads missing blobs BEFORE the patch, so no client sees a patch whose blob 404s.
  • Client download (net/remote_sounds.gd, a child of RemoteConfig, live builds only). After every fetch, and at setup from the cached patch, sync(next-run patch): deletes every cached blob the patch no longer references (the {} kill switch empties the cache) and downloads every blob not already on disk at its expected size, one at a time via HTTPRequest (never blocking boot or the menu). Cached blobs are NOT hashed or parsed at boot (see run start below). Each download is verified: byte length, SHA-256 (HashingContext) and RuntimeWav.check; anything that fails is not cached and the key plays bundled. A response for a blob the current patch no longer wants is discarded. A verified blob that can’t be written (full disk, refused dir) is kept in memory for the session, so it still plays and is not re-downloaded on every fetch. Cache dir (RemoteSounds.dir_for): <OS cache dir>/sfx_remote/ on iOS and tvOS (Caches: regenerable data must not sit in iCloud-backed Documents, and tvOS can’t write user://; a purge only means a re-download), user://sfx_remote/ elsewhere (cache dir if user:// isn’t writable). The HTTP layer is an injectable requester Callable; GUT drives on_response() directly, no network. A KV 404 right after a publish (propagation lag) is not retried until the next successful /config fetch (≤ 5 min).
  • Run start only. apply_for_run records the run’s patch; main._new_run calls AudioManager.set_overrides(remote_config.sound_overrides()) right after mark_run_started, and that first call resolves sounds.overrides_for(patch) (key → AudioStreamWAV, only for blobs cached AND verified: hash + RuntimeWav.parse happen here, so a blob that somehow crashes the engine does so under the crash marker and gets its config_id blacklisted; a cached file that fails the check is deleted and re-downloaded at the next sync). set_overrides replaces the map every run (never cumulative). A blob that lands mid-run, or a new patch, waits for the next run; Dev builds’ mid-run refetch / console apply_live never touch sounds. A replay’s recorded patch gets its own blobs if they are still cached, else bundled. A net (LAN) run applies {} → all bundled.
  • Playback. AudioManager._stream_for returns the override first, so the override goes through the exact same chain: cue_shape trims/pitches, jitter, tiers → bus (bus_for_key on the base key: boss tier stays on the Stinger bus), throttles/cooldowns, round-robin (blaster_fire_2 replaces only that variant; override every variant to replace them all) and _slot_or_fallback (an override for an empty boss slot counts as its own file, so it plays instead of the shared fallback key). Bank 2 only, like every other v2 pick: SFX_BANK = 1 (the console old/new A/B) plays the original files. A Dev live audition (AudioManager._audition, dev-tools.md § Live SFX audition) sits above the overrides, in both banks, and set_overrides never touches it. A replacement for a looping bundled stream (orbit_ambience) loops over its whole length, on a per-key copy (the cached stream can also back a one-shot key, which must not loop). publish-config.mjs warns (does not refuse) when a patch replaces only part of a round-robin family. Overrides can only replace keys the build already plays; they cannot add new round-robin variants.
  • Loader seam. audio/runtime_wav.gd (RuntimeWav.parse/check/loop_whole) is the bytes → AudioStreamWAV step, shared with the Dev live audition (net/sfx_audition.gd, which also reuses RemoteSounds.verify: byte count + SHA-256 + RuntimeWav.check). It accepts only 16-bit PCM, mono/stereo, 22.05–48 kHz, ≤ 1 MB (WAVE_FORMAT_EXTENSIBLE with a PCM sub-format included) and fills AudioStreamWAV.format/stereo/mix_rate/data itself after its own header parse, rather than calling AudioStreamWAV.load_from_buffer(): every byte is validated before the engine sees it, and nothing depends on a 4.4+ API in the tvOS fork. publish-config.mjs wavProblem mirrors the same rules. Unverified on device: the runtime path (download, cache dir fallback, playback of a runtime AudioStreamWAV) has only run headless on macOS 4.7.1; it is unverified on the tvOS/iOS 4.8 fork until a device run with a real patch.
  • Telemetry. No new stamp: config_id already identifies the patch, and the sounds entries live in it. Not stamped: which blobs had finished downloading when a run started (a run on a new config_id can still play some keys bundled). That needs a D1 column + Worker deploy; add it if a sound A/B ever needs per-run precision. active_hash deliberately does NOT include sounds: it is also the LAN hello’s config check, and sounds are render-side (peers may differ in download state).
  • Privacy. GET /sfx/<sha>.wav carries no install id, session or query: nothing beyond what a /config fetch already sends (IP + UA to Cloudflare), so no new kind of data, no privacy-page or ASC label change.

Keying: Sim_Const.BUILD, not the store build number

Section titled “Keying: Sim_Const.BUILD, not the store build number”

Patches are keyed by Sim_Const.BUILD (sim/constants.gd) — e.g. store build 36 is BUILD 204, so its patch is remote-config/build-204.json / KV key build-204. Publishing under the App Store build number reaches nobody. The Worker’s /config route (resolveConfig in telemetry/src/worker.js) looks up the exact build key, then falls back to the nearest older build that has a patch, then {} (bundled defaults) if none exists. A patch for build 204 also serves build 205+ until that build gets its own patch or an explicit {} — see remote-config/README.md § “A patch for build N also serves newer builds” before assuming an old patch is inert once a new build ships.

Per-channel patches: node scripts/publish-config.mjs <N> --channel testflight writes KV build-N@testflight, served only to that channel (lookup: build-N@ch → build-N → nearest older, same order). Use it to trial a balance patch on TestFlight before App Store players get it. The Worker stores channel truncated to 12 chars but does not whitelist it on write (only the /config read path regex-checks it), so SQL shouldn’t assume only dev/testflight/appstore/web appear in the channel column. Old rows (≤ build 206) have channel NULL.

  1. In the sim file: const X: float = 1.0 → static var X: float = 1.0. Call sites don’t change. /sim stays pure — the static var declaration is the only thing that moves; all fetching, validation and setting lives in net/ (render-side).
  2. Add one entry to net/tunables_registry.gd’s TunablesRegistry.ENTRIES: {"key": "Sim.X", "script": SIM, "name": "X"}. Bounds default to 0.1×–10× the bundled value; pass explicit "min"/"max" when the bundled default is 0 (Tunables.bounds() can’t derive a range from zero) or when the natural range isn’t symmetric around the default (see ElementalSystem.SPLIT_DMG_FRAC/SPLIT_BOSS_DMG_FRAC for both cases). A per-device knob that changes nothing deterministic (LockstepSession.JITTER_ROWS, the LAN client’s jitter buffer) adds "local": true: Tunables.config_hash() skips it, so two LAN peers may differ on it and still pass the join handshake, and the console may edit it mid-run on one peer (_dev_set_tunables applies a live layer that is local-only even in a networked run, via RemoteConfig.live_is_local_only() / apply_live on the run’s fixed override patch; any sim key in the layer still defers everything to the next run). Anything a Sim reads must NOT be local.
  3. Hot-loop rule: if the read sits inside a per-entity loop, hoist it into a local once before the loop in the same commit. Cross-class static-var reads cost ~7× an inlined literal — negligible per call, not negligible across a 6,000-enemy loop.
  4. Never referenced by another const or a match pattern. A const that other consts fold into at compile time, or that gates a match/enum branch, can’t become a static var without restructuring — leave those as const. TunablesRegistry.NON_TUNABLE_STATIC_VARS documents the converse case: existing static vars in sim/*.gd that are runtime state, not tunables, so the registry-completeness test doesn’t demand they be registered.
  5. Liveness check — prove the patch changes a real run before registering it. This is the batch-1 lesson: registering a tunable is not proof it does anything. Batch 1 shipped 12 live tunables out of 20 converted; the other 8 were reverted to const after a liveness check found they don’t affect a real run (see “Batch 1 — what shipped” below). Don’t hand-trace any more — run the probe: godot --headless --path . -s res://tools/remote_config/liveness_probe.gd -- --keys=<k1,k2,…> runs one deterministic headless soak run per key with only that key perturbed (through the real Tunables.apply / GameContent.load_db paths) and diffs state digests against an unperturbed baseline — any divergence proves the key affects real gameplay (~4 s per 3-min run). It also probes bible fields (--keys=enemies.warden.hp or --bible-prefix=<prefix>) — the exact “manifest offers boss stats nothing reads” bug class the boss batch fixed is now caught mechanically before a patch ships. Modes that matter:
    • --scenario-keys=Sim.ELITE_CHANCE=1.0 amplification applied to baseline AND every run (e.g. forces every spawn to promote, so per-type elite knobs get exercised);
    • --player=skilled|dumb — dumb gets hit (proves on-hit knobs), skilled survives (proves positional/pacing knobs);
    • --invuln=1 + --ticks=36000 reaches brutes/bosses/dimensions but proves only POSITION-diverging keys — _check_pilot_hit skips the whole hit block while dev_invuln is set, so on-hit knobs (damage/knockback/effects) need a mortal run;
    • --meta-levels=id=lvl,… --drone-loadout=a,b,… runs baseline AND every probe run with a shop save applied through the real MetaRunSetup.apply (as main.gd starts a run) — e.g. drone-slots=3,unlock-drone-bomber=1 + --drone-loadout=sentinel,bomber fields non-default drones (the auto-players deploy whatever the loadout holds). Without it the probe only ever fields the default Sentinel.
    • --area=<id> starts baseline and probe runs inside an area through Sim.enter_area (each Elemental Dimension: fire, ice, void_dim, blood, electricity, light, poison); --force-boss=warden|funzo|graviton|eye spawns that boss at tick 0 (position drawn from sim.rng, identically in baseline and probe); --pilot-hp=N starts every pilot at max_hp = hp = N, which keeps the run MORTAL (on-hit knobs still fire) but stops a dumb pilot dying before the boss reaches its enrage phase. Pair with a --scenario-keys=…_ENRAGE_FRAC=1.0 to probe enrage-only knobs from the first tick. A key that reports NO-DIFF in a scenario is dead in that scenario — either genuinely dead (revert to const, log it), or scenario-limited (trace it by hand; a shared path already proven LIVE by a sibling key plus a direct call-path trace is good evidence — batch 3 shipped 10 keys that way, see its evidence table below). A test that hand-sets the static var and checks it reads back correctly proves nothing about whether the real game ever reads it (same trap as bullet-heaven-story-mode-xp-was-zero).
  6. Regenerate the manifest (godot --headless --path . -s res://tools/remote_config/dump_manifest.gd) so remote-config/manifest/build-<N>.json reflects the new knob.
  7. Player-visible copy that states a tunable’s value must read it live, not hardcode a number. ui/upgrade_copy.gd’s Fork card is the precedent — it derives its “half-damage” text from ElementalSystem.SPLIT_DMG_FRAC at display time instead of a baked string, so a remote patch that changes the split fraction doesn’t leave stale copy on screen.

Level-up reroll + skip tunables (issue #77, 2026-10-20)

Section titled “Level-up reroll + skip tunables (issue #77, 2026-10-20)”

UpgradeSystem.SKIP_XP_REFUND_FRAC (share of the XP bar a skip refunds, 0-1, read at each skip and by the SKIP label), UpgradeSystem.REROLL_EXCLUDE_SHOWN (bool: a reroll leaves out the cards it replaces) and LevelUpPanel.SKIP_CONFIRM_S (local, LIVE: the second-press window). The first two are sim keys, so they are in config_hash and apply at the next run. The liveness probe (tools/remote_config/liveness_probe.gd) cannot reach them (the autopilot never rerolls or skips); tests/test_levelup_reroll_skip.gd pins their wiring through Tunables.apply instead.

Flags — bool kill switches (RC-1, 2026-10-01)

Section titled “Flags — bool kill switches (RC-1, 2026-10-01)”

A registry entry with "type": "bool" is a flag: no bounds (true/false is the whole domain), same fetch → reset → apply → config_hash pipeline as numeric tunables, same replay/telemetry stamping. Live flags: Sim.DIMENSIONS_ENABLED (boss-death dimension-choice offers) and DamageNumbers.ENABLED (floating combat text). Flags may point at render-side scripts — tests/test_tunables_registry.gd checks each entry against its own script file, not only sim/. The manifest types them "bool" with null min/max, publish-config.mjs accepts only JSON true/false for them, and the liveness probe perturbs a flag by flipping its bundled default.

Tunables don’t have to live in sim/ — the registry checks each entry against its own script file, so render scripts can carry static var knobs too. The RC-4 set is the proven perf levers from the BUILD 228-230 Apple TV fill-wall work:

  • QualityManager.ENABLED (bool) — the remote-enable flag for the adaptive quality ladder, replacing main.gd’s old ADAPTIVE_QUALITY const. Bundled default false (pin L0 everywhere); a published patch can switch the ladder on for a device-class perf regression without a store build. main.gd reads it at the two former const sites.

  • QualityManager.DOWN_FPS / HARD_DROP_FPS / DOWN_DWELL / PROBE_DWELL / PROBE_BACKOFF / SETTLE / WARMUP / EMA_ALPHA — the ladder’s thresholds and timings (render/quality_manager.gd). CROSS_RULES keeps HARD_DROP_FPS <= DOWN_FPS (mirrored in publish-config.mjs); a violating pair keeps bundled defaults like any other invalid value.

  • NovaBurstLook.LIFE_EVOLVED — the evolved corona’s linger time (fx/weapons/nova_burst_look.gd), a direct fill-cost dial on overlapping Supernovas.

  • ElementPalette.WEAPON_TINTS (bool) / ElementPalette.WEAPON_LIGHT_SAT (0.3-1.0, bundled 0.48) — player weapon tints (2026-10-01): the light weapons (Beam, Railgun) drawn a pale gold instead of vivid()’s floored yellow, so they stop blending with Pulse’s lightning. The flag is the kill switch; the Debug Settings “Weapon Tints (A/B)” row is the separate session-sticky VisualLook.weapon_tints (Tunables reset every run start, so a Debug choice can’t live on a registered var). Liveness: tests/test_weapon_tints.gd drives a patched saturation through FxManager.consume.

These never touch the sim, so the soak-based liveness probe can’t see them — liveness is pinned by tests/test_render_remote_config.gd instead, which drives patched values through QualityManager.tick, the real decision core. Render-side values are read per tick()/bind(), so a patch takes effect at the next run start without extra wiring; the ladder stays off by default until a patch flips ENABLED.

Bible fields (weapon/enemy/shop numbers in data/bible.json) don’t need a code conversion — every existing numeric field on an existing entry is overlayable via content/bible_overlay.gd (category.id.field, e.g. weapons.fork.base_damage) unless BibleOverlay.EXCLUDED_FIELDS lists it. Since RC-3 (2026-10-01) the whitelisted display-copy strings are overlayable too — BibleOverlay.STRING_FIELDS (weapons.name, enemies.name, mods.name, meta_upgrades.name, meta_upgrades.desc): non-empty single-line, ≤200 chars (STRING_MAX, mirrored by publish-config.mjs), stamped into config_hash via effective() like numbers. Machine-key strings (mods.effect, meta_upgrades.effect, weapons.element/archetype/evolution) are NOT copy and stay unpatched — rewriting one would be a code change, not data. The standing rule applies harder here: copy that states a tunable’s value must read it live, so a patched desc must not hardcode a number a tunables patch can move. Adding or removing fields/entries is still not allowed (guideline 2.5.2). Numeric fields keep the same two-pass validation as tunables: the value must be within 0.1×–10× of the bundled value (a bundled 0 can’t be patched — no bounds to derive), and a field in BibleOverlay.INT_FIELDS must be a whole number (written back into the bible as an int).

INT_FIELDS / EXCLUDED_FIELDS are the one definition of bible field types and patchability: BibleOverlay.apply(), effective() (the stamped hash), overlayable_fields() and tools/remote_config/dump_manifest.gd (manifest "type": "int" + whole-number min/max, which publish-config.mjs enforces) all read it. Rules are category.field (every entry) or category.id.field (one entry). A GUT test fails if a rule names a field that doesn’t exist.

Excluded Why
enemies.*.radius Structural: ElementalSystem.MAX_ENEMY_RADIUS (80) bounds the projectile hit query, so a bigger enemy silently loses hits.
enemies.*.xp_value Dead: XP is paid on biomass; EnemyPool.xp_val is written, never read.
weapons.*.level_max, weapons.*.pierce Dead: read nowhere (pierce comes from mods / weapon mods).
elements.*.stacks_max, elements.*.per_stack_scale, reactions.*.base_magnitude, reactions.*.per_stack_scale Dead: only the unreachable Elemental.apply() (reactions archived) reads them, or nothing does.
meta_upgrades.*.max_level Structural: sets drone/weapon slot counts (DroneDock, 1 + max_level) and the level cap saved levels are checked against.
mods.damage.magnitude Dead: the global damage mod is never offered (roll_upgrade_choices skips it; damage is per-weapon).
enemies.tank_missile.gold Dead: tank missiles never pay gold or XP (anti-farming). Its bundled 0 also makes it unpatchable.

Int fields: weapons.*.projectile_count, enemies.*.pellets, enemies.*.orbit_shards, enemies.*.biomass, meta_upgrades.*.base_cost, and mods.{pierce,split,overcharge}.magnitude (they add to int ModState fields). When you add a bible field the sim reads through int(), or one that is structural/dead, add it to the table and regenerate the manifest.

The backgrounds are fully procedural, so theming them is pure data: no asset download pipeline. RC-8 adds a "type": "color" tunable and registers 42 background knobs (17 numeric, 1 variant switch, 24 colors).

The color type. A patch value is a hex string with the leading # (#rgb, #rgba, #rrggbb, #rrggbbaa; 8 digits = RRGGBBAA, Godot’s Color.html order) or an [r, g, b] / [r, g, b, a] float array. The array form exists because 8-bit hex can’t express an overbright modulate: the Dimension tints run to 1.35 and hex would clamp them to 1.0. RGB channels are capped at Tunables.COLOR_CHANNEL_MAX (4.0), alpha at 0..1. Tunables.parse_color is the client check, isColor in publish-config.mjs mirrors it; anything else is rejected and the key keeps its bundled default. Colors have no numeric bounds (Tunables.bounds returns [NAN, NAN], like bool), go through the same reset → apply → config_hash pipeline (the hash stringifies the effective Color), and the manifest types them "color" with null min/max. The manifest default is #rrggbbaa when every channel fits 8-bit hex, else a rounded [r, g, b, a] array (Tunables.color_to_json). The hex default is informational and 8-bit-quantised; send the array form when exact values matter (the near-black NebulaBg grade loses precision in hex). The liveness probe skips color entries (render-side, invisible to the soak digest) and refuses them as --scenario-keys.

Keys. All are read when a backdrop is built, so a published patch lands at the next run start (Dev-build console edits land live, see RC-9 below). Both NebulaBg and ArenaBackground are rebuilt every run by main._new_run (they are not on the persistent-node list), so NebulaBg.apply_tunables() runs from its own _ready and needs no extra call site. ArenaBackground snapshots its tunables into instance fields in set_variant, so the per-frame paths (set_camera, warp streaks) never read a cross-class static.

Script Numeric Color
render/nebula_bg.gd (NebulaBg.*) PARALLAX 0..1 (default 0.1), RESOLUTION_SCALE 0.1..1 (fill-rate lever), INTENSITY 0..1.5, STAR_DENSITY 3..120 COL_LO, COL_HI (the default grade)
render/arena_background.gd (ArenaBackground.*) STEP 50..400, AURORA_LAYERS 2..12, STAR_PARALLAX 0..1, NEBULA_PARALLAX 0..1 (default 0.1), WARP_STREAK_ALPHA 0..1, WARP_STREAK_WIDTH 0.5..6, NEBULA_CLOUD_SCALE_MIN/MAX 2..32 (cross-ruled MIN <= MAX), GALAXY_ARMS 1..6, GALAXY_CORE_STARS 30..1000, GALAXY_SCATTER_STARS 0..500, GALAXY_SPIRAL_TURNS 0.25..4, GALAXY_GLOW_SCALE 2..20, GALAXY_GLOW_ALPHA 0..1 LINE_COLOR, BORDER_COLOR (also ArenaBorder’s edge glow), STARFIELD_COLOR, GALAXY_CORE_COLOR, GALAXY_OUTER_COLOR, AURORA_HUE_1..3, NEBULA_CLOUD_HUE_1..4
sim/area_defs.gd (AreaDefs.*) TINT_HOME, TINT_AURORA, TINT_EMBER_REACH, TINT_FIRE, TINT_ICE, TINT_VOID_DIM, TINT_BLOOD, TINT_ELECTRICITY, TINT_LIGHT, TINT_POISON

The upper caps are fill-rate caps: GALAXY_GLOW_SCALE 20 is the pre-2026-07-04 value the Apple TV choked on, and RESOLUTION_SCALE 1.0 is the original full-resolution FBM cost.

ArenaBackground.FORCE_VARIANT (int, -1..4) is a seasonal switch. -1 (bundled) keeps today’s data-driven pick: the area’s background name, or the random Home pool. 0..4 forces that VARIANT_* everywhere: the run-start pick (ArenaBackground.forced_variant, called by main._new_run) and every area change (set_variant_by_name). The per-area background strings in AreaDefs._DEFS stay structural and are not tunable; the dev background switcher (set_variant) ignores the force.

Dimension tints moved out of the _DEFS const (a const can’t hold a static var) into one TINT_<AREA> static per area; AreaDefs.tint_for returns the static, with unknown ids following Home as get_def does. They are plain data in /sim, read only by render/UI (main’s area change, DimensionChoicePanel), never at tick time, so the determinism baselines are untouched.

NebulaBg grade precedence. VisualLook.nova_ground (the lit Nova ground) still wins while it is on: _process pushes the gamma pre-compensated VisualLook.NOVA_COL_LO/HI and NOVA_INTENSITY as before, and apply_tunables() never pushes the grade while the Nova ground holds the uniforms. When the Nova ground switches off, _process falls back to the snapshotted COL_LO/COL_HI/INTENSITY (bundled or patched) instead of the old hardcoded literals. STAR_DENSITY is not part of the Nova grade and is always pushed. The default grade is pushed raw, as the shipped grade always was: under HDR 2D it renders about gamma-2.2 darker than the values say (see the HDR trap comment in nebula_bg.gd). Only the Nova ground is pre-compensated; compensating the default grade would brighten the shipped look, which is a visual change, not a remote knob.

Liveness is pinned by tests/test_bg_remote_config.gd: each test patches through the real Tunables.apply, builds the backdrop the way _new_run does, and reads the result off the built nodes (shader parameters, sprite scales/modulates, star counts, layer positions, ArenaBorder’s edge_color). Draw-only colors (LINE_COLOR, BORDER_COLOR on the ArenaBackground rect, the aurora hues, warp streak alpha/width) are checked at their set_variant snapshot, since headless draw calls can’t be read back. Bundled values are identical to the old consts and shader uniform defaults, so the default path is pixel-identical by construction.

RC-9 — background & environment live sweep (2026-10-07)

Section titled “RC-9 — background & environment live sweep (2026-10-07)”

Every remaining hard-coded backdrop, arena-edge and WorldEnvironment value is a tunable: 87 new keys (119 NebulaBg/ArenaBackground/ArenaBorder/EnvironmentLook (now ColorProfileSdr) keys with RC-8’s), plus a few variety knobs that default to a no-op. Bundled values equal the old literals and the default render-RNG draw order is unchanged, so the default look is pixel-identical: a fixed-fps --write-movie A/B of all five variants against the pre-RC-9 commit was bit-identical under mobile (the Apple TV renderer) and differed by at most 1/255 on 2-5 of 921,600 pixels under gl_compatibility (GLSL compiler float contraction once literals became uniforms).

All of them are live mid-run (Dev builds, console set_tunables) through LiveScope.HOOK_PREFIXES, which main._apply_live_config runs:

Prefix Hook What main does
NebulaBg. HOOK_NEBULA nebula_bg.apply_tunables() pushes every uniform (the Nova ground still wins while on)
ArenaBackground. HOOK_ARENA arena_bg.rebuild(): regenerates with the same seed, so only the edited values move
ArenaBorder. HOOK_BORDER (new) arena_border.apply_tunables(): uniforms + strip redraw
EnvironmentLook. HOOK_ENV (new) EnvironmentLook.apply(world_env.environment, quality_manager.hdr_capable)
ColorProfileSdr. / ColorProfileHdr. HOOK_ENV main._apply_color_profile() (EnvironmentLook.apply + ShaderGlobals.push)

tests/test_env_border_tunables.gd fails if a background/environment key has no hook or main stops handling one. Liveness is pinned by that file and tests/test_bg_remote_config.gd (patch through Tunables.apply, then run the hook action on an already built node).

NebulaBg (render/nebula_bg.gd + render/shaders/nebula_bg.gdshader, the far full-screen FBM): CLOUD_SCALE 2.4 (0.3..10), CLOUD_LO/CLOUD_HI 0.42/0.92 (0..1, cross-ruled), DRIFT_X/DRIFT_Y 0.002/0.004 (-0.05..0.05), FBM_OCTAVES 2 (1..4; the shader loops to a constant 4 with a break so GLSL ES compiles; each octave is a noise sample per low-res pixel), STAR_CHANCE 0.05 (0..0.5, pushed as star_threshold = 1 - chance), STAR_BIG_CHANCE 0.07 (0..1), STAR_RADIUS/STAR_BIG_RADIUS 0.12/0.17 (0.02..0.5), STAR_CORE_FRAC 0.35 (0..0.95), STAR_BRIGHTNESS/STAR_BIG_BRIGHTNESS 0/0 (0..2; 0.32/0.8 until RC-10 turned the shader stars off, both 0 skips the star math), TWINKLE_AMP 0.12 (0..1), TWINKLE_SPEED 0.7 (0..6), STAR_MARGIN 0.15 (0..0.5; spread is 1 - 2 * margin; a margin below the radius clips dots square). Variety: STAR_COLOR (white), CLOUD_SQUARE 0 (0..1; 0 = the shipped screen-stretched cloud, 1 = aspect-correct like the stars), COL_ACCENT + ACCENT_AMOUNT 0 (0..1) + ACCENT_SCALE 0.9 (0.1..8): a second low-frequency noise field mixes the accent hue into the cloud; at 0 the branch is skipped, so it costs nothing.

ArenaBackground (render/arena_background.gd):

  • Starfield: STARFIELD_COUNT 340 (0..1500), STAR_RADIUS_MIN/MAX 0.7/1.5 (0.2..6), STAR_ALPHA_MIN/MAX 0.14/0.5 (0..1), STAR_TWINKLE_SPEED_MIN/MAX 0.5/1.4 (0..6). Shared star look (Starfield + Galaxy): STAR_TWINKLE_DEPTH 0.25 (0..1, star_field.gdshader uniform on the duplicated material and the warp-streak curve), STAR_SIZE_MULT 1 (0.25..4, fill ~ square), STAR_EDGE_HARDNESS 6 (1..32; the cached star texture rebuilds when it or the mipmap flag changes), STAR_MIPMAPS (bool, off: on = mipmapped texture + TEXTURE_FILTER_LINEAR_WITH_MIPMAPS, the candidate fix for sub-pixel star popping). Variety: STARFIELD_COLOR_2 + STAR_COLOR_MIX 0 (0..1): the fraction of Starfield stars in colour 2 is picked by its own RNG (seed + 0x5EED), drawn only when the mix is on, so the scatter never moves.
  • Nebula variant (organic default): NEBULA_CLOUD_COUNT 12 (0..24), NEBULA_CLOUD_ALPHA_MIN/MAX 0.24/0.42 (0..1), NEBULA_PULSE_DEPTH 0.3 (0..1), NEBULA_PULSE_SPEED 0.5 (0..4; snapshotted, _process reads instance fields).
  • Nebula look params (the organic/cluster VisualLook.nebula_look options, 2026-10-07): NEBULA_ORGANIC_SCALE 0.6 (0.1..2, organic sprite scale vs the disc’s), NEBULA_ORGANIC_CHURN 0.035 (0..0.5 rad/s, max spin), NEBULA_CLUSTER_LOBES 6 (int 1..16), NEBULA_LOBE_SCALE_MIN/MAX 0.35/0.6 (0.05..2, cross-ruled), NEBULA_LOBE_SPREAD 0.35 (0..2 of the disc half-size).

NebulaCloudTextures (render/nebula_cloud_textures.gd, the organic look’s CPU-baked cloud textures; prefix mapped to HOOK_ARENA, so the rebuild re-requests shape(), which rebakes all four shapes (~0.3 s) when signature() differs from the cached bake’s). The first seven are the CENTRE of a per-shape roll of centre +- a fixed *_SPREAD const x SHAPE_VARIETY, drawn in the original order, so the bundled bake is byte-identical (SHA-pinned in tests/test_nebula_look_tunables.gd): WARP 0.525 (0..2), FILAMENT_FREQ 3.1 (0.5..12), FILAMENT_SHARPNESS 1.3 (0.3..6; the roll is floored at 0.05), EDGE_RAGGEDNESS 0.675 (0..2), BODY_FALLOFF 1.65 (0.1..8), DENSITY_THRESHOLD 0.14 (0..0.8), DENSITY_SOFTNESS 0.475 (0.05..1.5), SHAPE_VARIETY 1 (0..3). Direct values: CORE_ALPHA 0.62 (0..1), CORE_RADIUS 0.42 (0..1), HAZE_FLOOR 0.30 (0..1, filament brightness = floor + (1 - floor) x ridge), EDGE_IN/EDGE_OUT 0.35/0.95 (0..1 / 0.05..1, cross-ruled). Left const: FEATHER_IN/OUT (the unwarped outer skirt that keeps the quad’s square edge invisible), the per-shape spreads, the shape seeds, SIZE/SHAPE_COUNT.

  • Galaxy: GALAXY_ARM_SPREAD 0.22 (0..1.5 rad), GALAXY_CORE_RADIUS 60 (0..600), GALAXY_EXTENT 0.5 (0.1..1 of ARENA_HALF), GALAXY_RADIAL_JITTER 40 (0..300), GALAXY_CORE_STAR_R_MIN/MAX 1.6/4.2 and GALAXY_SCATTER_STAR_R_MIN/MAX 1.2/2.8 (0.2..8), GALAXY_CORE_STAR_A_MIN/MAX 0.55/1.0 and GALAXY_SCATTER_STAR_A_MIN/MAX 0.25/0.6 (0..1), GALAXY_TWINKLE_SPEED_MIN/MAX 1.5/4.0 (0..8).
  • Grid: GRID_LINE_WIDTH 1 (0.5..6), GRID_BREATHE_DEPTH 0.15 (0..1), GRID_BREATHE_SPEED 0.8 (0..6); the grid material is now duplicated per build (it was the shared saved .tres).
  • Aurora: AURORA_WAVE_AMP 120 (0..600), AURORA_WAVE_FREQ 0.0018 (0..0.02), AURORA_WAVE_SPEED 0.6 (0..4), AURORA_ALPHA 0.022 (0..0.2), AURORA_SHIMMER 0.008 (0..0.1), AURORA_SHIMMER_SPEED 0.7 (0..4), AURORA_WIDTH_MAX/MIN 260/50 (2..600, cross-ruled; the max is the fill-cost lever).

ArenaBorder (render/arena_border.gd, arena_border.gdshader): INSIDE 160 (40..400; the halo reach and the strips’ inner edge), FOG_DEPTH 420 (50..2000), FOG_COLOR (0.015, 0.02, 0.06), GLOW_GAIN 1 (0..3), GLOW_WIDTH 1 (0.25..4; stretches the coloured halo, the white-hot core line keeps its width, INSIDE still caps it). border_look.gdshaderinc gained border_look_w(..., glow_width); the old border_look() forwards to it with 1.0, so story_wall.gdshader is unchanged.

(Superseded by the colour profiles, step 1: the keys are ColorProfileSdr.; old EnvironmentLook. keys are retired docs for builds up to 281+.) EnvironmentLook (render/environment_look.gd, new; main._new_run calls EnvironmentLook.apply(env, hdr)). HDR (Metal): GLOW_INTENSITY 1.15 (0..4), GLOW_STRENGTH 1 (0..2), GLOW_BLOOM 0.05 (0..1), GLOW_HDR_THRESHOLD 1 (0..4), GLOW_HDR_SCALE 1.5 (0..4), GLOW_LEVEL_1..7 at Godot 4.7’s defaults 0/0.8/0.4/0.1/0/0/0 (0..1; a non-zero level 5-7 adds blur passes), BRIGHTNESS 1 (0.5..2), CONTRAST 1.08 (0.5..2), SATURATION 1.14 (0..2). Both paths: GLOW_BLEND_MODE 0 (0..4, Environment.GlowBlendMode). LDR (web): LDR_GLOW_INTENSITY 0.9 (0..4), LDR_GLOW_BLOOM 0.2 (0..1). apply() never writes glow_enabled: QualityManager owns that switch (bloom at L0 only), so a live re-apply can’t fight the ladder.

Cross rules (mirrored in publish-config.mjs): NebulaBg.CLOUD_LO <= CLOUD_HI, NebulaCloudTextures.EDGE_IN <= EDGE_OUT and every ArenaBackground *_MIN <= *_MAX pair above.

Left as const, with reason:

  • NebulaBg shader hash constants and the accent field’s smoothstep band/offset: structural noise plumbing, no visual meaning on their own.
  • ArenaBackground.VARIANT_*, the three aurora bands (one per AURORA_HUE_*) and their base-height layout, the galaxy radial-jitter growth term (t + 0.15), the warp-streak length factor (0.6 + r * 0.25), the nebula-pulse phase offset per cloud: structural or already covered by a sibling knob.
  • ArenaBorder.OUTSIDE (3200): geometry that must stay past any camera view.
  • border_look.gdshaderinc falloff constants, pulse and fog-noise constants: shared with the story walls, so a tunable would retheme the tutorial too; GLOW_GAIN/GLOW_WIDTH/FOG_* cover the edge.
  • VisualLook.NOVA_COL_LO/HI/INTENSITY: the Nova ground is a Debug Settings row (off by default), not the shipped look.
  • ScreenFeedback vignette (VIGNETTE_INNER/OUTER/MAX_ALPHA/FRAME_MARGIN): baked once into a texture whose frame margin is derived from them, so a live edit needs a rebake; damage-feedback values were out of scope.

Manifest: not regenerated on this branch (BUILD is still 262, whose manifest describes the installed 262). Regenerate remote-config/manifest/build-<N>.json with dump_manifest.gd at the BUILD bump that installs this, and redeploy the console Worker (control/src/tunable_docs.js changed).

A screen-space far star layer, render/far_stars.gd (FarStars, CanvasLayer -1 between the NebulaBg cloud at -2 and the world), replaces NebulaBg’s upscaled shader stars (NebulaBg.STAR_BRIGHTNESS and STAR_BIG_BRIGHTNESS now default to 0, an approved visual change). Architecture: docs/architecture/rendering-and-performance.md § Background layer stack. main._new_run builds it every run (render-side randi() seed), feeds set_camera each frame, mirrors arena_bg.warp_dir/warp_len through set_warp, and tints it on an area change like the arena backdrop. Defaults are the look Chris approved on the Apple TV (the arena starfield driven as far stars) at the count one screen tile needs.

All 20 keys are live through LiveScope.HOOK_FAR_STARS (prefix FarStars.): main calls far_stars.rebuild(), which regenerates with the same seed and keeps the area tint. HOOK_TINT (AreaDefs.TINT_*) re-tints it too.

Key Default Bounds
COUNT 110 (stars per screen tile, ~85 on screen at 1280x720) 0..800
PARALLAX 0.1 (screen speed = p x camera) 0..1
RADIUS_MIN / RADIUS_MAX 0.8 / 1.4 px 0.2..6 each, cross-ruled
ALPHA_MIN / ALPHA_MAX 0.25 / 0.75 0..1 each, cross-ruled
SIZE_MULT 1 0.25..4
EDGE_HARDNESS 6 1..32
TWINKLE_SPEED_MIN / TWINKLE_SPEED_MAX 0.5 / 1.4 rad/s 0..6 each, cross-ruled
TWINKLE_DEPTH 0.15 0..1
COLOR #b8d9ff color
COLOR_2 (1, 0.82, 0.62) color
COLOR_MIX 0 0..1
BIG_CHANCE 0 0..1
BIG_RADIUS_MULT 1.8 1..4
BIG_ALPHA_MULT 1.4 (alpha capped at 1) 1..4
STREAK_ALPHA 0.3 0..1
STREAK_LEN_MULT 1 (x main’s WARP_STAR_STREAK 40 px) 0..4
MIPMAPS true bool

BIG_CHANCE and COLOR_MIX roll from their own RNG (seed + 0x5EED, always drawn), so changing them never moves the scatter; the wrap margin is always sized for a big star for the same reason (so BIG_RADIUS_MULT, RADIUS_MAX, SIZE_MULT and STREAK_LEN_MULT do resize the tile and reshuffle it). Cross rules (mirrored in publish-config.mjs): the three *_MIN <= *_MAX pairs. Pinned by tests/test_far_stars.gd (patch through Tunables.apply, build the layer as main does, read the MultiMesh / shader params; main wiring; live hook; the wrap math as FarStars.wrap_pos). The manifest is not regenerated here; do it at the BUILD bump that installs this.

meta_upgrades.*.base_cost / cost_growth are patchable, so owned levels can have been bought under several price regimes. MetaState.reset_shop() therefore refunds MetaState.gold_spent — the gold every successful buy() actually deducted, persisted in the save — never a re-price of owned levels (total_spent(defs)), which would mint gold after a price rise and lose it after a cut. Levels written without buy() (tests, economy-sim tools) never enter the ledger.

Only publish a shop-price patch for a BUILD that contains the ledger. Patches are keyed per Sim_Const.BUILD (and served to newer builds by fallback, never older), so the safe rule is: price patches target ledger builds only. A build without it would refund at the patched price.

Saves from before the ledger (or with a corrupt gold_spent: anything but a finite number in 0..MetaState.GOLD_SPENT_MAX) load with it unset (MetaState.GOLD_SPENT_UNSET, -1), and MetaStore.load_from() migrates them once from the frozen pre-ledger price table data/meta_prices_legacy.json (MetaStore.legacy_price_defs()) — the shop prices of every build up to BUILD 204 / store build 36, taken from the bible at 9a224b7. Never edit or regenerate that file: it records what old saves were charged, so a later bible price or id change (e.g. the shop-economy Plan 2 renames) must not reach it; tests/test_refund_ledger.gd pins it. An owned id the table doesn’t know counts 0 and is reported (MetaStore.last_migration_unpriced + a warning). The migration persists only when the load was clean (same rule as ensure_install_id). It runs inside the load because every real save passes through there; at boot that is before RemoteConfig exists. The runs.meta_spent telemetry column and soak’s meta_spent report gold_spent too, so they don’t move when prices are patched; total_spent() stays for the migration and the economy sim.

Batch 1 — what shipped (commits d1a2dfd, f355bc7, 5cc9180)

Section titled “Batch 1 — what shipped (commits d1a2dfd, f355bc7, 5cc9180)”

12 live tunables: wave/boss/elite pacing (Sim.WAVE_PHASE_S, BOSS_PREP_S, BOSS_REST_S, BIOMASS_TRIGGER, BIOMASS_TARGET, WAVE_INCOMING_S, MAX_ALIVE_BRUTES, BOSS_ADDS_DELAY_S, BOSS_ADD_RATE, LEECH_CAP_FRAC_PER_S) and Fork/Penetrator split damage (ElementalSystem.SPLIT_DMG_FRAC, SPLIT_BOSS_DMG_FRAC).

8 converted-then-reverted-to-const after the liveness check (kept exactly as they were, registry entries removed, remote-config/manifest/build-204.json regenerated without them):

Const Why it’s dead
Sim.BOSS_FIRST_TIME, Sim.BOSS_INTERVAL (Deleted 2026-09-29.) Only fed _next_boss_time, which was write-only — real boss timing is phase-driven (WAVE_PHASE_S → wind-down → BOSS_PREP_S), not these two.
Sim.SWARM_BURST_TIME (Deleted 2026-09-29.) Read nowhere in the sim.
Sim.SWARM_BURST_COUNT (Deleted 2026-09-29.) _spawn_swarm_burst() had no live caller (only a test).
Sim.ELITE_BOSS2_TIME, Sim.ELITE_BOSS2_BIOMASS Behind const BOSS2_ENABLED := false — Boss2 is hidden from all auto-spawn.
Sim.ELITE_WARDEN_TIME, Sim.ELITE_WARDEN_BIOMASS Only reachable in Aurora/Ember Reach, which real play never enters (dev panel only).

The dead code itself (the unused fields/functions) is left in place under the feature freeze — logged in NEXT_TASKS.md (“Dead sim pacing fields/functions”) for whoever removes it later.

Cross-key rule: keep Sim.BIOMASS_TRIGGER <= Sim.BIOMASS_TARGET (bundled 100 / 400). A trigger above the target re-arms “WAVE INCOMING” every WAVE_INCOMING_S (2 s) while each wave spawns nothing, because the field is already at target. Check this by eye before publishing any patch touching either key — validated since batch 3: see TunablesRegistry.CROSS_RULES (both the client and publish-config.mjs enforce it).

Remaining batches (spec §7, most-likely-to-tune first, each still needs its own conversion + liveness probe + gate): enemy behaviours/attacks, the drone director, then the rest of sim.gd. Batch 2 (Supernova + boss bible stats) and batch 3 (elites, below) are done. Launch ships with whatever’s landed by then; anything not converted still needs an App Store build to change, as today.

67 keys: the Sim.ELITE_* promotion/stat family (16, incl. the formerly const-folded ELITE_CHARGER_HP_MULT/ELITE_CHARGER_CONTACT_MULT, now independent own-value knobs) plus the EliteKit attack family (51). ELITE_XP_MULT is not among them — dead (multiplies only the never-read xp_val column; elite reward rides biomass×HP-mult in _spawn_one). Structural bounds to keep: ELITE_CONTACT_MULT/ELITE_CHARGER_CONTACT_MULT max 1.47 (elite Swarmer final hit must stay under the Warden swing 44 — test_difficulty pins the bundled pair), ELITE_RADIUS_MULT max 1.6 / ELITE_CHARGER_RADIUS_MULT max 3.0 (promotable base radii 48/26 must stay inside MAX_ENEMY_RADIUS 80 or projectile hits go missing — same reason enemies.*.radius is bible-excluded). Hot-loop hoists rode along: the homing turn rates (read once per tick in _move_enemy_proj now), EliteKit’s update() cadence gates, and prowl_dir’s prowl/dive/hop reads; the WEAVE reads stay in-branch on purpose (bounded by live elite count — see the comment at enemy_behaviors.gd step_walk).

Liveness evidence (tools/remote_config/liveness_probe.gd, built with this batch):

  • 57/67 mechanically LIVE — divergent digests across: mortal skilled 3-min runs (30), ELITE_CHANCE=1.0-amplified skilled runs (+13), an invuln dumb 10-min coverage run (+1: WEB_HOLD_S). Wiring tests pin the Charger per-type mult independence and the kit fire/gate paths (test_elite_remote_config.gd).
  • 2 more pinned by wiring test (ELITE_SCATTERER_HP_MULT/ELITE_BRUTE_HP_MULT): their elites never spawn in any single headless scenario (the probe run’s dimension visit swaps in a roster that filters them out), but the force-elite spawn test proves their _spawn_one branches read patched values — and the scatterer/brute weights in SpawnTable + UNLOCK_ORDER confirm they spawn in ordinary survival play.
  • 8 trace-verified (probe NO-DIFF everywhere; direct call paths + sibling keys on the same path proven LIVE): EliteKit.SCATTER_DMG/RIFT_DMG/RIFT_KNOCKBACK (on-hit values on the exact _shot→_check_pilot_hit pipeline that proved BLASTER_DMG/FROST_DMG/HOMING_DMG; the probe’s pilot never ate one of those specific shots), EliteKit.JOLT_S/SWARM_TURN_RATE (Warden swarm-drone path: boss_warden.gd sets EFFECT_JOLT, sim.gd:_move_enemy_proj reads the turn rate for is_homing==2), and Sim.ELITE_WEAVE_FREQ/ELITE_WEAVE_AMP/ WYRM_ELITE_HP_MULT (reachable only via kit-less elites = Wyrm segments, which are Void/Poison-dimension content — real play reaches Dimensions, single probe scenarios don’t).
  • Added 2026-10-07 (Toby playtest): Sim.ELITE_ORBITER_HP_MULT (12) and Sim.ELITE_GLASS_WISP_HP_MULT (6), bounds 1-40, NEW_SPAWNS scope. Every per-type elite HP override now lives in ONE helper, Sim._elite_hp_mult(tid), read by both _spawn_one and spawn_story_enemy (they used to carry duplicate if/elif chains). Pinned by wiring test test_elite_hp_orbiter_wisp.gd (patch → real spawn), not a probe run.

Also shipped with this batch: TunablesRegistry.CROSS_RULES — declarative cross-key rules (Tunables.apply rejects the pair and keeps both bundled defaults; publish-config.mjs mirrors the same rule pre-upload). First rule: Sim.BIOMASS_TRIGGER <= Sim.BIOMASS_TARGET. Add future cross-key constraints there, never as eye-checks. Mirror trap: the publish-side check skips a pair whose key isn’t in the manifest, so a casing/name typo in the publish-config.mjs mirror (e.g. Funzo. for FunZo.) silently disables that rule with no error — when adding a mirror entry, grep the registry for the exact key string.

WeaponBeam.TURN_RATE (6.0, 0..30), WeaponBeam.LANCE_TURN_RATE (3.5, 0..30), WeaponBeam.LANCE_COOLDOWN_S (0.25, 0.05..1.0), WeaponBeam.LANCE_DMG_MULT (2.5, 0.5..10). Off values (today’s behaviour exactly): turn rates 0, Lance 0.1 / 1.0. Patch the two Lance keys together to hold DPS. Liveness probe (default skilled scenario): TURN_RATE LIVE; the three Lance keys NO-DIFF because that run never evolves the Beam (scenario-limited) — their live read is proven by test_lance_cadence_through_the_real_evolve_and_tick_path (apply_upgrade("evolve:beam") + Sim.tick), and LANCE_TURN_RATE shares TURN_RATE’s proven _slew path.

WeaponRailgun.TELL_S (0.1, 0..0.5), WeaponRailgun.MARK_POP_FRAC (0.25, 0..2), WeaponRailgun.MARK_POP_RADIUS (40, 10..160). Off values (today’s behaviour exactly): TELL_S 0 (no tell, aim at fire time) and MARK_POP_FRAC 0 (no pops; the radius is then unused). Liveness: the shipped probe’s default soak never takes the Railgun, so all three report NO-DIFF there (scenario-limited). A one-off run of the same probe idea with the Railgun granted at tick 0 (seed 1000, mortal skilled autopilot, real Tunables.apply + Sim.tick_single + level-ups, 10,800 ticks) diverged at the first checkpoint for each key: TELL_S=0, MARK_POP_FRAC=0 and MARK_POP_RADIUS=120 all LIVE. The end-to-end GUT tests in tests/test_weapon_railgun.gd drive the same keys through Sim.tick_single.

WeaponOrbit.KB_SPEED (160, 0..1000), KB_LEVEL_STEP (0.2, 0..1), KB_LEVEL_CAP (int 8, 0..20), KB_EVOLVED_MULT (1.4, 1..4), KB_ELITE_MULT (0.5, 0..1), PROJ_PUSH_ACCEL (1200, 0..10000). Off values (pre-shove behaviour exactly): KB_SPEED 0 and PROJ_PUSH_ACCEL 0. Liveness: the shipped probe’s soaks never take the Orbit (default skilled 10,800 ticks and --ticks=36000 --invuln=1 both NO-DIFF for every key: scenario-limited, same as Beam/Railgun). The live read is proven end to end in tests/test_orbit_knockback.gd: patches go through the real Tunables.apply and the orbit is granted / upgraded / evolved through upgrade_system, then driven by Sim.tick_single (KB_SPEED 0 leaves the enemy in place, default shoves it; PROJ_PUSH_ACCEL 0 leaves bullet velocity untouched). The scale keys share shove_scale() with both.

Event Horizon warp (2026-10-07), evolved orbit only, every key 0 = off: WeaponOrbit.WARP_PROJ_ACCEL (90, 0..3000), WARP_LENS_ACCEL (600, 0..10000), WARP_LENS_RADIUS (56, 0..200), WARP_ENEMY_SPEED (30, 0..400), WARP_ENEMY_LENS_SPEED (60, 0..600). Read per tick in WeaponOrbit.update (live). Look: OrbitShardsLook.EH_CORE (color, #ff1a12, blood red), EH_TINT_MIX (0.7, 0..1), WARP_GAIN (0.4, 0..3), WARP_EDDIES (int 3, 0..7), EDDY_HALF (30, 4..120), pushed on change every orbit sync (live). Hum: AudioManager.EH_HUM_PITCH (0.7, 0.3..1.5), EH_HUM_WOBBLE (0.035, 0..0.3), EH_HUM_WOBBLE_HZ (0.23, 0.02..4), read on every set_orbit_active call (live). Liveness proven end to end in tests/test_event_horizon.gd (patches through Tunables.apply; the orbit is evolved through the real crystal-threshold path and driven by Sim.tick_single); the shipped probe’s soaks never evolve the Orbit, so it would report NO-DIFF (scenario-limited, same as the shove keys). The look also hides its lens / eddies when the matching sim strengths are patched to 0, so the visual never claims a warp the sim isn’t doing.

Batch 4 — enemy behaviours/attacks (2026-10-01)

Section titled “Batch 4 — enemy behaviours/attacks (2026-10-01)”

92 keys: EnemyBehaviors (56 — skirmish/rush/ghost/accumulator/orbiter/leech/coil/flee/wyrm behaviour and the non-kit elite powers), EnemyAttacks (21 — tank volleys, shooters, elite shooter/scatterer/lancer/tank/orbiter/brute powers, Chronowell fire-clock slow, fleet-kite fire), EnemyChipFire (15 — the whole ambient chip-fire director). Hot-loop hoists rode along in 12 per-entity step/update functions (batch-3 pattern). Reverted 9 (batch-1 “a patch must be able to reach a real run” rule): ELITE_ZAPPER_SPREAD (the Zapper is removed from every spawn pool and Dimension roster — test_crystal_endless_tuning.gd pins that — so the key is dead in real play) and CUSTOM_* ×8 (custom enemies spawn only via the dev Remote Playtest Console, never auto-spawn). Deliberately left const: LEECH_OFFSET (Vector2 — Tunables is numeric/bool only), CHARGER_CRUISE_WHILE_CHARGING (never-shipped behaviour gate), WYRM_CONSTRAINT_MAX_ITER (loop safety cap, not a balance knob), SPEED_BY_TYPE/ _OWN_ATTACK (const-folded tables).

Structural bounds to keep: ACCUMULATOR_MAX_RADIUS max 80 (= ElementalSystem.MAX_ENEMY_RADIUS — past it an Accumulator silently loses projectile hits; test_enemy_remote_config.gd pins the pair), ELITE_CHARGER_CHAIN_CHARGE_S max 0.64 (must stay ≤ the Charger’s bible lunge duration or the zero-gap chain breaks), ELITE_GHOST_SECOND_TELEGRAPH_S max 1.2 (the second-telegraph clock starts at GHOST_TELEGRAPH_S - ELITE_GHOST_SECOND_TELEGRAPH_S; past the bundled 1.2 it would start pre-elapsed), CHRONOWELL_ATK_SLOW_MULT max 1.0 (a “slow” mult above 1 SPEEDS UP fire), the ELITE_*_MULT/_PELLET_MULT/_SPIN_MULT family floored at 1.0 (a buff knob below 1 is a stealth nerf), TANK_MISSILE_COUNT/ELITE_TANK_MISSILE_COUNT int caps 5/9. New CROSS_RULES (7, all min≤max pairs, mirrored in publish-config.mjs + a node test): CHIP_RANGE, ORBITER_ACCEL/ORBITER_FRICTION/ORBITER_WALK_S, WYRM_ACCEL/WYRM_FRICTION, COIL_RADIUS.

Liveness evidence (probe + wiring, batch-3 evidence-table template):

  • 30/92 mechanically LIVE — divergent digests: mortal skilled 3-min (SHOOTER_FIRE_INTERVAL, SHOOTER_PROJ_SPEED, 14 of 15 CHIP_*), ELITE_CHANCE=1.0-amplified (ELITE_CHARGER_LUNGE_SPEED_MULT, ELITE_CHARGER_CHAIN_CHARGE_S, ELITE_SHOOTER_LEAD_S), invuln dumb 10-min (ACCUMULATOR_GROW_RATE/ACCUMULATOR_MAX_RADIUS, the six ORBITER_ACCEL/FRICTION/WALK_S min/max keys, DASH_INACCURACY_DEG, CHARGER_RECOVER_S, and CHIP_RATE_MAX — the chip rate only pegs the cap from ~240s, so the 3-min scenario can’t see it; diverges at checkpoint 9/20 in the 10-min run).
  • 62/92 pinned by wiring tests (tests/test_enemy_remote_config.gd, 30 tests) through the real step/update functions with only spawn/timers forced (the test_elite_remote_config.gd pattern). The probe CANNOT field these: an alive-by-type diagnostic run (new alive_by_type_checkpoints/wave_reached fields on the SoakRun report — additive, digest- neutral) shows its auto-player scenarios only ever field {swarmer, shooter, charger, boss, centurion} at Home plus the Light-Dimension roster {lancer, orbiter, accumulator, centurion, glass-wisp, halo-chain}; the run stalls ~3 minutes on an unkillable (dumb) boss, picks the Light dimension, and wave-pace tops out at wave 12–16. Ghost/skirmisher/tank/leech/coil/ fleet-kite never spawn in ANY probe scenario; Wyrms are Void/Poison-roster content no auto-player picks; elite powers of those types doubly so; the on-hit elite damage keys (ELITE_SHOOTER_DAMAGE_MULT, ELITE_SCATTERER_DAMAGE_MULT, ELITE_BRUTE_SLAM_*, ELITE_LANCER_TWIN_OFFSET) need a mortal pilot to actually eat the hit in-window. All are live survival content (SpawnTable weights + UNLOCK_ORDER; Wyrm via area_defs.gd rosters), so they ship on force-path wiring evidence — this batch is exactly the scenario-blindness case “Open items” warned about; a roster/wave-pace probe scenario override is now the clear next tooling investment rather than more trace rows. One key is probe-blind by construction: ACCUMULATOR_WARN_FRAC only emits fx_events (not in state_checksum) — its wiring test asserts the accum_warn fx fires at the patched fraction.

Tooling shipped with this batch: the probe’s _perturb_tunable clamps to a CROSS_RULES partner’s bundled default (a 10× perturbation of a min-side key would otherwise violate its own pair rule and exit 2), and --scenario-keys now accepts true/false for bool entries (Sim.DIMENSIONS_ENABLED=false keeps a long run out of a Dimension’s restricted roster).

40 keys, all DroneDirector (sim/drone_director.gd; sim/drone_state.gd has no consts): the Sentinel/decoy family (DECOY_LIFE/SPEED/RECHARGE_RATE/PULSE_RADIUS/BASE_DAMAGE/POWER/WANDER/ ACCEL, SENTINEL_REFLECT_PER_LEVEL/SHIELD_DMG/SHIELD_RADIUS), shared drone HP (DRONE_BASE_HP, DRONE_RADIUS), and every Bomber (5), Interceptor (7), Disruptor (8) and Logistics (7) knob. Nothing reverted — every key reaches a real run once the class is fielded. The four cadences DECOY_PULSE_INTERVAL/BOMBER_CD/INTERCEPTOR_CD/LOGI_CD were left const here (folded into the _DRONE_MAX_CD table) and converted in sweep S4 (2026-10-04): the table is now _drone_max_cd(), so the dock’s cooldown_frac tracks a patched cadence. All four probe LIVE (the Decoy one in the default run, the rest with --meta-levels/--drone-loadout), pinned by test_remote_cadence_keys_reach_each_class_pulse_and_the_dock_cooldown. Deliberately left const: the DRONE_* class-id Strings, the _KIND_* type-id arrays. Hot-loop hoists rode along in drone_disruptor’s field loop, bomber_one_blast and the Interceptor strike loop. enemy_speed_scale reads DISRUPTOR_SLOW_MULT per enemy but only for slowed ones (in-class read).

Structural bounds to keep: DISRUPTOR_SLOW_MULT 0.05..1.0 (above 1 a “slow” speeds enemies up), INTERCEPTOR_CHAIN_FALLOFF 0.1..1.0 (above 1 damage grows per hop), BOMBER_HEAVY_BONUS 1..5 and INTERCEPTOR_CRIT_MULT 1..10 (a bonus below 1 is a stealth nerf), INTERCEPTOR_CRIT_PER_LEVEL 0..0.25 (4 shop levels = a certain crit), DISRUPTOR_WEAKEN_PER_LEVEL 0..0.5, SENTINEL_REFLECT_PER_LEVEL 0..1.0, LOGI_OVERCHARGE_DRONE 0.04..1.0 (fraction of max HP). No CROSS_RULES: the drone family has no min/max pairs.

Liveness evidence (probe + wiring, batch-3 template). The default probe only fields the Sentinel (the auto-players deploy the instant the meter fills, and current_loadout falls back to one basic Sentinel without a shop save), so this batch added the probe’s --meta-levels/ --drone-loadout flags (see “Converting a const” step 5):

  • 10/40 LIVE in the default mortal skilled 3-min run — all 8 DECOY_*, DRONE_BASE_HP, DRONE_RADIUS.
  • +23 LIVE with drone-slots=3, all four class unlocks and the novel-attr levels (sentinel-reflect/shield=4, bomber-payload=4, interceptor-chain/crit=4, disruptor-weaken=4, disruptor-suppress=1) and --drone-loadout=sentinel,bomber, interceptor,disruptor — all Bomber, Interceptor, Disruptor and SENTINEL_* keys.
  • +5 LIVE with --drone-loadout=logistics,sentinel,bomber,interceptor (+ drone-logistics- overcharge=4,drone-logistics-endurance=4 for the overcharge cadence): LOGI_SPEED, LOGI_RADIUS, LOGI_DRONE_REPAIR, LOGI_REPAIR, LOGI_OVERCHARGE_CD.
  • 2 pinned by wiring test (LOGI_OVERCHARGE_HEAL, LOGI_OVERCHARGE_DRONE): the overcharge path itself is proven LIVE by LOGI_OVERCHARGE_CD, but in the probe runs its bursts land on a full-HP pilot/drone (or 4 levels × 25 already tops the pilot up), so the clamp hides the patched value. tests/test_drone_remote_config.gd drives one burst through update_drones and asserts both patched amounts; a mutation check (literal 25.0 / 0.4 back in the code) fails it.

Finding (not changed here): at bundled values the first Logistics overcharge is due at LOGI_OVERCHARGE_CD = 8 s, exactly when the drone’s 8 s DECOY_LIFE expires — update_drones removes it first, so a bought drone-logistics-overcharge never fires unless the player also owns drone-logistics-endurance (or a decoy_life_mult bonus). Verified headless: a lone Logistics drone with overcharge=1 expires after 481 ticks with zero overcharge heal. A balance call, now patchable remotely (LOGI_OVERCHARGE_CD below 8, or a longer DECOY_LIFE).

186 keys shipped (205 converted, 19 reverted to const as dead): Sim 45, SpawnDirector 2 (DIFFICULTY_START, DIFFICULTY_PER_MIN; +6 exponential-scaling keys DIFFICULTY_EXP_*/CONTACT_EXP_*/SPEED_EXP_* added 2026-10-04, all default-off), BossWarden 34, FunZo 24, Eye 29, Graviton 25, DimensionHazards 27. The manifest is now 421 tunables. The Sim set covers the warp (DASH_*), player fire-rate cap and poison floor, stray recall (STRAY_MARGIN, recall timer), flanker distances, still-heal, hazard cadence and the soft enemy cap. Boss keys are attack cadence, projectile speeds/damage/lifetimes, enrage thresholds and multipliers; dimension keys are the per-dimension hazard knobs (fire orbits and DoT, ice/void slow zones, laser cadence, etc.).

Excluded, not deferred: boss stat blocks (*_HP, *_RADIUS, *_ARMOR, *_SPEED, *_CONTACT, *_XP) — the bible overlay already patches them. Deferred to a later batch: the tutorial/story/meta/upgrade and weapon_* consts, elemental_system, glass_wisp, halo_chain, beacon, chronowell, turret, aim and orbit modules, and the dead Boss2 family (BOSS2_ENABLED is false). Structural consts (type ids, strings, array sizes, enum-like ints) stay const.

ai/skilled_auto_player.gd’s const WARP_DIST = Sim.DASH_SPEED * Sim.DASH_TIME no longer const-folds once those are static vars, so it is now SkilledAutoPlayer.warp_dist() — the AI’s warp landing prediction follows a patched dash. Hot-loop hoists: _recall_stray_enemies, the flank branch of EnemyBehaviors.step_walk, and FunZo.update_zones.

Reverted (dead in real play, traced by hand after a NO-DIFF):

  • SpawnDirector.BUILDUP_TIME/WAVE_PERIOD/SURGE_PERIOD/SURGE_LEN/SURGE_RATE — only rate_at/ spawn_count_for read them, and the sim calls neither.
  • Sim.GEM_MAGNET_RADIUS/GEM_MAGNET_SPEED — AUTO_COLLECT_XP is a true const, so the magnet branch never runs.
  • Sim.WEAPON_PICKUP_RADIUS — story-reward pickups only.
  • Sim.AIM_KNOCKBACK — test-only; the live value is WeaponAim.DEF_KNOCKBACK.
  • BossWarden.BOSS_BARRAGE_COUNT/BOSS_MISSILE_COUNT/BOSS_SWING_DMG/BOSS_MISSILE_DMG — the tutorial/story (non-SV_*) Warden path only; survival uses the SV_* knobs.
  • Eye.EYE_MULTIBEAM_COUNT — the multibeam code hardcodes 2 extra beams.
  • FunZo.FUNZO_ZONE_DURATION — live zones never decrement it; only the post-death fast decay ends a zone, and that total is FUNZO_ZONE_FAST_DECAY_S.
  • FunZo.FUNZO_ZONE_FAST_DECAY_S — the fast decay runs after the boss dies (visual only).
  • Graviton.GRAVITON_BLOB_LIFETIME, FunZo.FUNZO_CONFETTI_LIFETIME — _boss_proj spawns them with persist = 1, which ignores the lifetime.
  • DimensionHazards.LASER_ACTIVE_S — the hit check runs once when the laser fires; the active window is visual only.

Structural bounds to keep: Sim.FLANK_NEAR 0..400 and Sim.FLANK_FAR 420..1500 (disjoint, so near can never pass far); Sim.STRAY_MARGIN minimum above SPAWN_BORDER_MARGIN (a recall margin inside the spawn ring would recall every fresh spawn); every *_ENRAGE_FRAC 0..1; FIRE_ORBIT_* counts as whole numbers.

New CROSS_RULES (10, mirrored in scripts/publish-config.mjs; a parity test in scripts/test/publish-config.test.mjs now parses every rule out of the registry so the two can’t drift): DASH_TIME ≤ DASH_IFRAMES (no vulnerable warp tail), HAZARD_INTERVAL_RANDOM_MIN ≤ MAX, STILL_HEAL_BASE_DPS ≤ MAX_DPS, FUNZO_DASH_SPEED ≤ FUNZO_DASH_SPEED_MAX, EYE_LASER_WINDOW_FLOOR ≤ EYE_LASER_WINDOW_S, GRAVITON_PULL_STRENGTH ≤ GRAVITON_PULL_STRENGTH_MAX, FIRE_ORBIT_RADIUS/ANG_SPEED/COUNT _MIN ≤ _MAX, VOID_COUNT_MIN ≤ VOID_COUNT_MAX.

Liveness evidence (probe + wiring). This batch added the probe’s --area, --force-boss and --pilot-hp flags (see “Converting a const” step 5):

  • 21 LIVE in the default mortal skilled 3-min run (the baseline died at ~90 s, so only early Sim/spawn keys reach a difference there).
  • +159 LIVE across scenario passes: --force-boss per boss, --area per dimension, --invuln=1 --ticks=36000 for positional keys, --pilot-hp=100000 dumb runs for on-hit and late-phase keys, --scenario-keys=<boss>_ENRAGE_FRAC=1.0 for enrage-only keys, and --meta-levels=field-medic=1 for the still-heal keys. 180/186 probe-LIVE in total.
  • 6 pinned by wiring test (probe-blind): Sim.FIRE_RATE_CAP (the auto-players never stack enough fire-rate to reach the cap) and Sim.POISON_MAXHP_FLOOR in tests/test_sim_remote_config.gd; FunZo.FUNZO_JESTER_CONTACT in tests/test_boss_attack_remote_config.gd; DimensionHazards.FIRE_ORBIT_RADIUS_MIN, FIRE_ORBIT_DPS, FIRE_PARENT_DPS in tests/test_hazard_remote_config.gd. Each drives the real path (upgrade pick, enter_area + hit, spawn_jester + _check_pilot_hit, dimension_hazards.update) after a Tunables.apply.

132 keys shipped (manifest 500 → 632 once regenerated at the next BUILD bump; build-257.json is left as is because it describes the installed build). Defaults identical: the seed-1234 determinism baselines did not move. Per file: BossWarden 9 (BULLET_DMG + the story/tutorial stat block and attack counts), Eye 1 (EYE_LASER_LENGTH), Sim 4 (ENEMY_PROJ_OFFMAP_MARGIN, SPAWN_RING, WYRM_BASE_SEGMENTS, WYRM_SEGMENT_SPACING), PlayerState 2 (KITE_SLOW_MULT, BEAM_SLOW_MULT), DimensionHazards 13, ElementalSystem 7, AreaDefs 28, HaloChainDirector 6, GlassWisp 8, BeaconDirector 4, SpawnTable 1, DroneDirector 4 (per-class cadences), weapons 42 (WeaponAim 9, WeaponBeam 2, WeaponBlaster 1, WeaponChronowell 5, WeaponMomentumCore 3, WeaponRailgun 5, WeaponOrbit 3, WeaponScythe 1, WeaponScatter 1, WeaponScarTissue 1, WeaponWildfire 4, WeaponThunderWake 2, WeaponTurret 5) and UpgradeSystem 3.

  • BossWarden.BULLET_DMG (17): every Warden bullet (barrage, second ring, spiral, fan, lunge burst) reads it instead of the shared Sim.SHOOTER_PROJ_DAMAGE. A patch that raised SHOOTER_PROJ_DAMAGE for the Warden’s sake now only moves regular shooters/skirmishers; retarget it to BossWarden.BULLET_DMG.
  • SpawnDirector.*_EXP_MAX = 0 means no design cap (_exp_mult); the registry minimum is 0. Bundled caps are unchanged. Hard safety ceilings still apply (HP_MULT_CEILING 1e5, CONTACT_MULT_CEILING 20, SPEED_MULT_CEILING 2.25), so an uncapped curve never reaches float32 inf (inf HP is unkillable; inf x 0 = NaN never dies). Speed is 2.25, not 3: at 3 the worst-case elite Spider covers 61 px a tick, past its 48 px overlap window with the pilot. A cap in (0, 1) means scaling OFF, never uncapped. Pinned by tests/test_tunable_safety_bounds.gd.
  • Other safety bounds (review, same file pins them): SPAWN_RING [850, 2000] (widest device view half-diagonal 734 + MAX_ENEMY_RADIUS; ARENA_HALF). Ice/Void hitboxes are clamped at DimensionHazards.IMPACT_RADIUS_CEILING (800 = ARENA_DESPAWN_MARGIN; bundled max 740). MARK_BONUS min 0.01, and a compounded mark stops at WeaponRailgun.MARK_AMT_CEILING (1e4). BLOOD_SIZE_MULT max is its bundled 1.15: Blood’s elite Brute is already past 80 px there.
  • Dead per-weapon bible fields are refused (BibleOverlay.EXCLUDED_FIELDS, DEAD_WEAPON_FIELD): e.g. turret projectile_speed/lifetime_s, orbit/beam/nova projectile_*, scar_tissue area, chronowell lifetime_s, blaster max_range, and projectile_count on every weapon except orbit/blaster/scatter/momentum_core. The manifest of builds up to 257 still lists them.
  • Boss drift speed now reads the pool (sim.enemies.speed[i], like the Warden), so bible enemies.funzo/eye/graviton.speed are live (they probed NO-DIFF before; the drift read the consts). Same values at bundled content.
  • Behaviour-shape changes (identical at defaults): DroneDirector._DRONE_MAX_CD → _drone_max_cd() (the dock follows a patched cadence); WeaponTurret.MAX_RANGE → max_range(); RailgunMarkLook.stacks_for follows MARK_BONUS; Momentum Overdrive undoes the multiplier it applied; Chronowell/turret/orbit _init read their bible fields (cooldown_s, duration_s, area, projectile_count, projectile_radius) that were dead before; VOID_SPEED_MIN/MAX no longer derive from ICE_SPEED_MAX; AreaDefs.get_def() returns a copy with the live numbers.
  • Probe tooling: liveness_probe.gd --start-upgrades=a+b (grants weapons/crystals first). Probe-blind keys are pinned by wiring tests: tests/test_warden_sweep_remote_config.gd, test_boss_speed_remote_config.gd, test_drone_remote_config.gd, test_sweep_s3_remote_config.gd and the S5 weapon tests (DEF_DEADZONE, MAX_TURRETS, STACK_DECAY_S, SCYTHE_BASE_PCT).

Left as const: structural (type/attack/phase ids, array sizes, pool caps, collision radii incl. BOSS_RADIUS and SPAWN_BORDER_MARGIN, loop safety caps, String/Vector2/Dictionary tables), survival boss stat fallbacks the bible already patches, dead values (BOSS_XP — xp_val is read nowhere; ELITE_ZAPPER_SPREAD; the dev-only CUSTOM_*; the batch-6 dead list incl. SpawnDirector.BUILDUP_TIME/WAVE_PERIOD/SURGE_*), Boss2, tutorial/story pacing and shop prices.

Open: FunZo’s radius grows to 127.5, past MAX_ENEMY_RADIUS (80), so shots at its rim can miss the spatial-hash query. So can Blood’s elite Brute (up to 103 px with the stat roll). ai/weapon_range.gd hardcodes ranges that go stale under a patch. The Disruptor cadence is an inline 0.5, not a const.

Every source of run gold is patchable, and all of it goes through one helper, Sim._add_gold(amount). That helper multiplies by Sim.GOLD_MULT and adds the result to a float remainder, Sim._gold_frac. Only whole coins move into run_gold, which stays an int because the HUD, the results panel, banking (main.gd) and telemetry all read it. The remainder lives on the Sim, so it resets with the run. Gold is not in snapshot_string/state_checksum, so the seed-1234 baselines did not move.

What Where Bounds
Gold for one kill of a type: the whole payout, bosses included bible enemies.<id>.gold (float), read once per content load into Sim._enemy_gold by _build_enemy_gold (also on a live bible swap) 0.1x-10x bundled (overlay default)
Master multiplier on all run gold (kills, bosses, crystals) Sim.GOLD_MULT (float, 1.0) [0.1, 5.0]
Dimension boss-kill crystal bonus: flat, not scaled by reward_mult but scaled by GOLD_MULT Sim.CRYSTAL_GOLD_BONUS (now a float, 20.0) [0, 200]
Per-area multiplier on kill gold (and XP) AreaDefs.<DIM>_REWARD_MULT (Batch 7) registry
Shop Greed upgrade: xmagnitude per owned level on ALL run gold, applied once per run (not per pilot) bible meta_upgrades.greed.magnitude (1.08) via MetaState.gold_mult → Sim.meta_gold_mult at run start (MetaRunSetup); NEXT_RUN in LiveScope 0.1x-10x bundled (overlay default)
Shop prices bible meta_upgrades.<id>.base_cost / cost_growth see “Shop prices” above
  • Bundled values reproduce the old Home numbers exactly: basic = 1, the old “+2 heavy” list (tank, brute, elite, skirmisher, bomber, lancer, orbiter, accumulator) = 3, every boss = 41 (the old BOSS_GOLD 40 plus the per-kill 1 every boss also got), tank_missile = 0 (excluded in BibleOverlay.EXCLUDED_FIELDS: it never pays, by design). tests/test_gold_economy.gd requires every bible enemy to carry gold, so a new enemy can’t ship paying 0 by omission (the biomass trap). Gold is looked up by TYPE at kill time, never a new EnemyPool.add() column.
  • Per-boss data, not a shared BOSS_GOLD: every pooled boss already has a bible entry (the Warden, FunZo, Graviton and Eye; TYPE_BOSS2 reads the dormant sentinel entry), so one field per type covers bosses the same way as everything else, and a patch can price one boss on its own. Dev-only custom enemies have no bible entry; they pay Sim.CUSTOM_ENEMY_GOLD (const 1).
  • reward_mult now applies exactly. It used to be rounded per kill, which erased Nebula’s 1.25x on a basic kill and doubled it at Aurora’s 1.5x. Per kill, before -> after (on average): basic 1/1/2/1/1/1 -> 1/1.25/1.5/1.3/1.35/1.4, heavy 3/4/5/4/4/4 -> 3/3.75/4.5/3.9/4.05/4.2, boss 41/51/62/53/55/57 -> 41/51.25/61.5/53.3/55.35/57.4, for Home/Nebula/Aurora/1.3x dimensions (Fire, Ice, Blood, Electricity, Poison)/Light 1.35x/Void 1.4x. Home is unchanged.
  • Greed (2026-10-04) multiplies inside _add_gold with GOLD_MULT; it is 1.0 (exact identity) with no levels and is never set by the baselines. Its shop desc is the template "+{pct}% run gold per level", filled from the live magnitude by MetaCopy.desc — a patched meta_upgrades.<id>.desc should keep its token ({pct}/{pct_less}/{mag_pct}/{mag}, see meta-and-story.md) rather than bake a number. Publish a tokenised desc only for builds that have those tokens (from BUILD 259); an older build shows the braces raw.
  • Removed keys: Sim.GOLD_PER_KILL and Sim.BOSS_GOLD are gone, not renamed (a patch carrying them now gets “unknown tunable” for that key only). Their console tooltips stay in tools/tunable_docs/docs.json under "retired" while installed builds still publish them.
  • Liveness (probe, 2026-10-04): LIVE Sim.GOLD_MULT, enemies.{swarmer,shooter,elite,lancer,orbiter,accumulator,centurion,warden}.gold (Home, --invuln=1 --ticks=36000), Sim.CRYSTAL_GOLD_BONUS and enemies.funzo.gold (--area=fire --force-boss=funzo). Types Home never spawns (tank, brute, skirmisher, ghost) probe NO-DIFF there; they share the same table lookup, and tests/test_gold_economy.gd drives a tank kill and an overlay-patched swarmer and Eye through spawn -> damage_enemy -> _sweep_dead.

Operator notice — the banner channel (RC-2, 2026-10-01)

Section titled “Operator notice — the banner channel (RC-2, 2026-10-01)”

The Worker merges a global notice object (KV key notice, published by scripts/publish-notice.mjs) into every /config response — including builds with no patch ({"notice": {...}} alone is a valid response; RemoteConfigState.valid() ignores the key). The client normalizes/filters it per build (RemoteConfig.notice_for_build: min_build/max_build window, style info/warn, text cap), exposes it as RemoteConfig.notice + notice_changed, and the start menu shows it as a glass banner (StartMenu.set_notice). It is comms, not config: excluded from config_id, the crash guard and config_hash; a Worker-side notice failure serves the patch unmerged. Cached with the response, so the last-seen notice survives an offline boot. Deploy note: the Worker side is additive — old builds ignore the extra notice member entirely.

CTA link (RC-6, 2026-10-01): the notice may carry an optional url (https:// only, ≤200 chars — enforced by both publish-notice.mjs and the client’s notice_for_build). The start menu renders it as a tappable mono-cyan line under the banner text and opens it with OS.shell_open — only on platforms with a browser (Platform.can_open_links(): everything but tvOS, which hides the line rather than show a dead one). This is the remote-URLs answer for player-facing links (Discord/support/privacy): the game binary has NO hardcoded user-facing URLs to replace — the only hardcoded URLs are the Worker endpoints themselves, which can’t be remote-served (bootstrap problem), and the store-listing links (privacy/support/marketing) live in App Store Connect metadata, changeable without a build. A general links map was deliberately NOT built: with no in-game link UI there’d be no consumer, and an unread knob is a liability (the liveness rule). If an about/credits UI ever lands, extend this same notice-style channel then.

A patch may carry "features": {"internet_coop": true}: an on/off gate for a whole feature, read like notice and NOT a registered bool tunable. Why top-level: main._lan_open calls apply_for_run({}) for every networked session (all tunables back to bundled defaults), a flag tunable would enter config_hash (the join handshake) and only take effect at the next run start, and the gate must be readable at the start menu. So it is not in config_hash and never touches /sim.

  • Client: RemoteConfig.features (static, {} = all off) + features_changed. RemoteConfig.features_of keeps only keys in OnlineCoop.FEATURES with bool values. _seed_cached() sets it at setup() from the cached patch, but ONLY when that cache was accepted by this same BUILD on this same channel (RemoteConfigState.cached_build / cached_channel, stamped by receive(); cache_matches()): iOS keeps the app container from a TestFlight install to an App Store install over it, so a TestFlight-only flip must not open a store build before its own fetch answers (final review I2, guideline 2.3.1). A mismatched or pre-stamp cache seeds {} (its tunables/bible still apply as before). _on_done re-reads it from the ACCEPTED patch (state.peek_for_run() after receive()), unlike the notice which reads the raw response: a blacklisted (crashed) patch can never open a gate. RemoteConfigState.valid() rejects a non-object features (the whole patch is refused). Gate check: OnlineCoop.enabled(dev, RemoteConfig.features) (Dev builds always true).
  • Manifest / publish: dump_manifest.gd writes "features": ["internet_coop"]; publish-config.mjs refuses a non-object, an unknown key, a non-bool value, and any non-empty features for a build whose manifest has no features list (it predates the gate).
  • Flip (Chris decision: TestFlight only until a reviewed version shows it, guideline 2.3.1): publish a --channel testflight patch for the BUILD with features added. A channel patch REPLACES the base patch for that channel, so it must also carry the base patch’s tunables/bible/sounds (copy remote-config/build-<N>.json, add features). publish-config.mjs enforces this: a patch with any feature set true is REFUSED (exit 1, dry-run too) unless the channel is testflight or dev. The plain build-<N> key (which the appstore channel falls back to), appstore and web all refuse. The escape hatch --allow-store-feature exists only for the post-review flip (e.g. 1.2, a version submitted to App Review with the online co-op row visible); never pass it otherwise. It also REFUSES any feature true on every channel except dev unless the LIVE privacy page (https://darkcosmos.lumara.digital/privacy/) lists the key in a data-privacy-covers="..." attribute (bullet-heaven-site privacy/index.html, Multiplayer section for internet_coop). An unreachable page refuses too, and there is no override flag: deploy the site’s privacy copy first. A new feature key needs its disclosure + marker on the site before it can be switched on.
  • Kill: republish that channel patch without features (keeping its other sections). {} is the whole-patch kill switch for the channel and also drops its tunables/bible/sounds, so use it only as an all-off kill. Devices see it at the next refresh (boot / menu / resume, throttled). There is no TTL: a device that never reaches /config again (offline) keeps its cached ON, the same as every other cached patch section, but only on the build + channel that fetched it.
  • The Worker needs no change: it serves the KV patch as stored (plus the merged notice).

Dev builds (RemoteConfig.dev_live = BuildConfig.dev_tools()) treat a balance patch as something that can change during a run; other builds keep the next-run contract.

  • Refetch. RemoteConfig.dev_tick forces a /config fetch every DEV_REFRESH_S (30 s) on a live platform. When the patch changes mid-run, main._on_remote_config_changed calls rebase_on_next() (the new patch becomes base_patch) and _apply_live_config(apply_live(), "fetch"), so it lands like a console edit. The top-right PatchToast (Dev only) says Patch <id> applied · <time>; it never fires for the boot fetch or at run start (fetches_done > 1). Non-dev builds leave the run alone and say nothing: store, TestFlight and web behave exactly as before.
  • Live layer. Console set_tunables edits sit on top of base_patch (effective_patch()); apply_for_run re-layers them at every run start until a reset. next_bible_overlay() includes them. While a live layer exists mark_run_started does not arm the crash marker, so a bad console value cannot blacklist the published patch.
  • Identity. apply_live recomputes active_hash from the effective patch and suffixes the id with +live (LIVE_SUFFIX), sets RemoteConfig.live_edited, and main pushes a config_live event {run_time, patch, hash, source: "console"|"fetch", tunables, bible} (changed keys), so telemetry rows after the change are attributable.
  • Replays. ReplayRecorder.note_config(patch) records the full effective patch with the tick it lands before (config_events); playback applies ReplayLoader.configs_at(data, tick) through apply_live(recorded) before that tick, and a replay run otherwise refuses live edits. An edit in the very last recorded tick is not replayed (no tick follows it).
  • Scope. LiveScope (net/live_scope.gd) is the list of what an edit cannot change on the next tick and why; main reports those keys as status.config.pending.
  • published_at + summary. publish-config.mjs and publish-notice.mjs stamp published_at (unix seconds, UTC) onto the uploaded copy. A patch may carry an optional player-facing summary (≤ 400 chars, validated at publish, capped in RemoteConfig.patch_info); note stays operator-only: RemoteConfig.patch_info(patch, allow_note) falls back to it only for Dev callers, so it never reaches a store player. The start-menu notice shows its time under the text on every build (PublishedAt.format: “Today 14:32” / “Yesterday 09:10” / “3 Oct 14:32”). The pause menu’s left-gutter block (PauseMenu.patch_lines(..., dev)) is a Dev-only readout: the run’s config_id + time, the summary (or note), + live edits; hidden on default with no edits and on every non-dev build. The summary is capped to the lines the gutter holds (ellipsis). All three use font.sm (the 15px TV floor); fit is tested at the iPad, iPhone and TV profiles.

RemoteConfigState (net/remote_config_state.gd) writes a marker to user:// (run_started) naming the active config_id right after apply_for_run in _new_run — only if that id is not yet in proven (spec §5: “before the first run under a new config_id”). After RemoteConfig.STABLE_AFTER_S (30s) of live play, run_stable() clears the marker and adds the id to proven (persisted in the same file), so later runs under it never arm and an unrelated early crash can’t revert a config that already worked on this device. A normal run end, return to menu, and backgrounding / losing focus / a window close (NOTIFICATION_APPLICATION_PAUSED, FOCUS_OUT, WM_CLOSE_REQUEST — an iOS background-kill or Cmd-Q is not a crash) call run_ended(): the marker is cleared but the id is not proven, so the next run under it arms again. blacklist and proven are each capped at RemoteConfigState.MAX_IDS (32, oldest dropped). If the marker is still set at the next boot, that config_id is blacklisted locally (RemoteConfigState.blacklist) and every future fetch of that id is refused (receive()) until a different patch is published. A refused refetch of a blacklisted id also clears pending and the cache: the server’s current answer is a config this device can’t run, so the next run uses bundled defaults, not an older cached patch the server has since replaced. The game falls back to bundled defaults and reports the blacklist event via telemetry (runs.config_blacklisted, gameplay_telemetry.note_blacklisted). A replay’s recorded patch (override) never arms the marker — it isn’t the fetched config, so it can’t be blamed for a crash the marker exists to catch.

Every effective config carries active_id (config_id, or "default" for bundled values) and active_hash (Tunables.config_hash() — a hash of every registered tunable’s effective value plus the sorted bible entries this build actually applied, BibleOverlay.effective(): rejected entries don’t count and int fields hash as ints). Both are stamped into telemetry samples, runs, encounters and feedback rows (telemetry/src/worker.js, D1 columns config_id / runs.config_hash / runs.config_blacklisted — migration comment in telemetry/schema.sql), and into the replay header (ReplayRecorder.new(run_seed, RemoteConfig.active_patch)), so a replay reproduces under the same values it was recorded with and D1 queries can separate results by config.

The client already sends a stable install_id (MetaState.install_id, 128-bit random hex generated once via MetaStore.ensure_install_id, persisted in the save, sent on every /config request) and stamps whatever config_id it got. A/B cohorting is therefore a Worker-only change later: hash install_id into a cohort, serve that cohort’s patch, split D1 queries by config_id. No app build needed. The Worker already accepts and ignores install today.

soak --config=<path> (tools/soak/soak_runner.gd) applies a patch (tunables + bible) to every soak run and exits 2 on any invalid patch — unknown key, wrong type, out-of-bounds value, or a non-object tunables/bible section — printing every problem to stdout (soak.sh hides stderr), so a bad patch can’t silently soak on bundled defaults. This is also the tool to use for the liveness check in step 5 above: soak with a patch that changes only the candidate key and confirm the report differs from a default-config soak.

GUT covers: Tunables.apply (types, unknown keys, bounds, reset-to-defaults, non-cumulative); BibleOverlay.apply (merge, rejection of added/removed entries, zero-bounds rejection); the fetcher cache + offline fallback + mid-run deferral + throttled refresh (and the resume/focus-in wiring); the crash-loop guard (arm only unproven ids, prove at 30 s, pause/focus-out/close clearing without proving, blacklist-on-reboot, blacklisted refetch → defaults); bible field types/exclusions and the effective-value hash; the open shop repricing on replace_defs; install id persistence; the wiring test (apply a patch that changes one tunable → the pinned determinism checksum differs; reset → it returns — proves the value reaches the sim through the real path, same style as the story-mode XP fix); registry completeness (every static var in sim/*.gd is registered or listed in NON_TUNABLE_STATIC_VARS, and vice versa); the Worker’s build-fallback chain, empty-patch default cache header, and KV-failure 503 no-store (node --test in telemetry/); and publish-config.mjs --dry-run rejecting unknown keys, wrong types, out-of-bounds values, fractional int fields, and a non-object patch / tunables / bible. Remote sound packs: test_runtime_wav.gd (PCM16 mono/stereo parse; truncated/garbage/wrong format/ rate/oversize refused), test_remote_sounds.gd (one-at-a-time download, sha/length/WAV mismatch not cached, prune, kill switch, corrupted cache, stale response, mid-run patch applies only at the next run start, replay patch), test_audio_remote_overrides.gd (through the real AudioManager.play()/consume(): override stream with the bundled volume/pitch/bus, cue_shape, round-robin variant, boss spawn/enrage slots, replace-not-merge, bank 1, looping), telemetry/test/sfx.test.mjs (route, strict path, 404/503 no-store, no collision with /config), and scripts/test/publish-config.test.mjs (WAV check, file resolution, caps, old manifests, missing blobs).

  • ATV frame-time check for the BUILD 230 keys (batches 2-3) — pending with Chris (spec §7.3’s per-batch gate: soak + scripts/gate.sh are green, but the A12 Apple TV 4K frame-time comparison hasn’t been run for the Supernova/boss or elite keys yet; batch 1’s check passed 2026-09-28 on BUILD 205). Don’t publish a balance patch that changes pacing/spawn/elite pressure before this runs. Batch 3’s hot-loop hoists make it less likely to regress, not safe.
  • App Store privacy label. install_id is a random per-install identifier sent with every /config request — before the next submission, check whether the privacy label needs “Identifiers → Device ID, Analytics, not linked” (tracked as a release gate in docs/RELEASE-0.1.md §1).
  • reset_shop refunds at current prices. RESOLVED (d0019f1, Chris chose “refund what was paid”): see “Shop prices and the refund ledger” above. Publish a shop-price patch (meta_upgrades.*.base_cost / cost_growth) only for a BUILD that contains the ledger — patches are keyed per Sim_Const.BUILD, and a build without it re-prices on respec. No build shipped so far (up to 204) contains remote config at all, so the first remote-config build must carry the ledger (don’t upload one without it).
  • Sim.BIOMASS_TRIGGER <= Sim.BIOMASS_TARGET isn’t validated RESOLVED (batch 3): TunablesRegistry.CROSS_RULES + the publish-config.mjs mirror reject the pair on both sides.
  • No end-to-end test of _new_run’s call order (apply_for_run → arm marker → GameContent.load_db → Sim.new). main.gd isn’t headless-drivable with a live patch today, so a future reorder of that sequence would go uncaught by tests.
  • Liveness-probe false negatives are scenario-shaped. The probe proves LIVE conclusively but NO-DIFF only proves “dead in this scenario”. Batch 3’s evidence table (above) is the template: classify each NO-DIFF as spawn-gated (wire a force-path test), on-hit (needs a hit in-window), or content-gated (dimension/boss content a single scenario can’t always reach), and trace before reverting. If a future batch keeps hitting NO-DIFFs of the same shape, consider a dedicated probe scenario (dimension-pick override, boss-phase shortcut) instead of growing the trace list.

Player armor stopped being a percentage (6%/point, 75% cap) and became a flat subtraction: Sim.armor_apply(amount, armor) = max(amount - armor * ARMOR_FLAT_PER_POINT, amount * ARMOR_MIN_FRAC), applied before the damage gate and never to DoTs (is_dot bypasses armor, so a dps * dt tick is not floored to a chunk per tick). Both values are live tunables under Player & warp: Sim.ARMOR_FLAT_PER_POINT (1.0, dmg) and Sim.ARMOR_MIN_FRAC (0.25, frac). The fraction floor is what lets Armor Plating stay an unlimited pick: contact scaling caps at CONTACT_EXP_MAX, so a flat floor of 1 would have made ~18 armor permanent swarm immunity. Set the floor toward 0 on the console to feel the “minimum 1” variant. The stats sheet and level-up card quote Sim.armor_per_point().

Render-side AudioManager tunables, group Sound in the console (tools/tunable_docs/docs.d/sound_low_risk.json). All are read per play, so every edit is live.

  • Kill-pop camera gate: DEATH_OFFSCREEN_DB (-12), DEATH_OFFSCREEN_CD_TICKS (8), DEATH_ONSCREEN_MARGIN (64 px). A kill inside the UN-padded camera view (main passes _camera_view_rect()) grown by the margin plays at full level on the shared death cooldown, so the margin is the whole band and 0 means exactly the screen. Enemy telegraph gating uses the same view grown by AudioManager.ENEMY_FX_VIEW_PAD (110 px), which is also main’s render cull pad. Off-screen kills are handled after every other event that tick (sorted last for PER_FRAME_CAP): at most one quiet pop per DEATH_OFFSCREEN_CD_TICKS, and they never use the on-screen cooldown.
  • Previously-silent weapons: RAILGUN_ / CHALICE_ / THUNDER_WAKE_ / WILDFIRE_ / STASIS_WEB_ + SFX_DB / SFX_PITCH, applied by AudioManager.cue_shape(kind) on top of the build-load fade.
  • Formerly-unused assets: OVERCHARGE_SFX_DB (special_rally), AEGIS_SFX_DB (special_aegis, the Decoy’s Shield Burst), WAVE_CLEAR_DB (wave_clear, a Story encounter room cleared, through AudioManager.consume_story).

SFX_COOLDOWN_TICKS stays a const (each AudioThrottle is built once in _ready), including its new wildfire_pop entry. The weapon-load fade counts only WEAPON_FIRE_KIND_FOR_WID weapons. Every bible weapon must be in that map or in LOAD_SILENT_WIDS. Liveness and mapping tests: tests/test_audio_coverage.gd. The four shipping weapons are driven through a real sim. Stasis Web is DevFlags-only, so its test uses the event shape.

Render-side DeathSequence tunables (render/death_sequence.gd), group Death sequence in the console (tools/tunable_docs/docs.d/death_sequence.json). ENABLED (bool) is the master switch: off goes straight to the results card. Timings (WINDUP_S, TOTAL_S (now a minimum: results wait for the explosion’s derived end), SETTLE_S, FADE_S, SKIP_GRACE_S, SLOWMO_SCALE, …), sizes (RING_RADIUS_FRAC, CORE_SIZE, SHARD_SIZE, …), counts (SHARD_COUNT, SPARK_COUNT, EMBER_COUNT, DISSOLVE_MAX, FRACTURE_COUNT, ints) and intensities (FLASH_ALPHA, GLOW_BOOST, SHAKE_PX, RIPPLE_STRENGTH, PUNCH_STRENGTH, …) are all registered; colour-bearing knobs have *_HDR twins used while HDR output is on. Every key is read per frame or at each death, so a console edit is live from the next death. The keys read in the BOOM’s spawn functions (_spawn_fire, _spawn_crystals) are listed in LiveScope as reviewed LIVE exceptions. Pinned by tests/test_death_sequence.gd (patches through Tunables.apply reach the real node; every static var is registered and labelled). Not "local", like the other render looks. The manifest is not regenerated here; do it at the BUILD bump that ships it.

Batch — App Clip Warden demo (2026-10-15)

Section titled “Batch — App Clip Warden demo (2026-10-15)”

Four new keys on sim/clip_director.gd, read only while a ClipDirector is attached (the App Clip’s clip mode; plan docs/superpowers/plans/2026-10-15-app-clip-warden.md):

Key Default Scope Evidence
ClipDirector.CLIP_XP_MULT 3.0 LIVE (per kill, Sim._award_xp) test_xp_multiplier_is_a_live_tunable
ClipDirector.CLIP_RAMP_S 45 s LIVE until the Warden spawns test_ramp_holds_the_wave_loop, test_warden_spawns_at_ramp_end_with_explicit_biomass
ClipDirector.CLIP_WARDEN_HP_SCALE 1.0 NEW_SPAWNS (baked at spawn) test_warden_hp_scale_is_baked_at_spawn
ClipDirector.CLIP_SWARM_DENSITY 2.5 LIVE (each wave + refill check) test_swarm_density_refills_the_field_sooner, test_swarm_density_scales_the_wave_target
ClipDirector.CLIP_PICK_GAP_S 10.0 LIVE (each level-up check) test_level_ups_are_spaced, test_level_up_pacing_is_live

The liveness probe reports all four NO-DIFF because it runs Survival: they are scenario-limited and shipped on the wiring tests above. CLIP_SWARM_DENSITY scales Sim.BIOMASS_TRIGGER and the wave’s biomass target together (the BIOMASS_TRIGGER <= BIOMASS_TARGET cross-rule still holds); it does not lift Sim.SOFT_ENEMY_CAP.

Render-side ShipSkin tunables (render/ship_skin.gd), group Ship skins in the console (tools/tunable_docs/docs.d/ship_skin.json). All 28 keys are "local": true (render-only, per device) and read per frame, so every edit is live mid-run. ShipSkin.LOOK (int 0-4) is the Dev-only skin A/B the spec calls ship_skin: honoured only when BuildConfig.dev_tools(), so a published patch can never switch an unreviewed skin on for App Store players (Apple 2.3.1). ShipSkin.BETA_LOOK (int 1-4) picks which look the Beta skin draws; it only matters to players who own the skin, so patching it is a look patch. The manifest is not regenerated here; do it at the BUILD bump that ships it.

ShipBonuses per-hull base_hp, base_speed, base_radius and the headline magnitude are 28 registered tunables (ShipBonuses.<HULL>_BASE_HP|BASE_SPEED|BASE_RADIUS|BONUS). ShipBonuses.TABLE stays the single source of the bundled numbers (each static var initialises from it) and all reads go through ShipBonuses.live(ship, field). Values are unchanged; a tests/test_hull_tunables.gd case pins defaults == TABLE. label_for rebuilds the “+10 Armor” style text from the live bonus when it differs. Slot counts, class and names stay structural. Bounds are explicit per field; not liveness-probed (hull stats are read at run start by apply_base/apply_to, covered by a wiring test through Tunables.apply). The release manifest (remote-config/manifest/build-N.json) is regenerated at the next build bump.

The iOS on-screen controls (ui/touch_controls.gd) are fully live-tunable, with defaults equal to the literals they replaced (zero visual change):

  • Colours are touch.* UI tokens (ui/tokens/defaults.json), each aliasing the role it used before (touch.disc = @surface.void, touch.warp = @accent.main, touch.drone = @violet.soft, touch.aim.fill|ring|tick|knob|knob_ring, touch.move.ring|deep|wedge|knob|knob_ring|ghost, touch.sys.pause|feedback, touch.icon.hot, touch.nudge.ring). Retune one through the live-token path; the pad repaints via TokenBind.redraw, and retuning touch.warp leaves accent.main alone.
  • Numbers are 70 TouchControls.* static vars (Remote Console group “Touch controls”, labels in tools/tunable_docs/docs.d/touch_controls.json): every alpha (*_A, 0..1), line width / offset in POINTS (*_W, ABILITY_HALO_PAD, LABEL_PT, multiplied by the device’s units-per-point), the idle fade (IDLE_FADE_MS, IDLE_EASE_MS, IDLE_ALPHA), READY_PULSE_MS and the drone-nudge rates. All "local". They are read in _draw / _process; the pad only redraws itself while animating, so LiveScope.HOOK_TOUCH (prefix rule TouchControls.) has main._apply_live_config call TouchControls.apply_tunables(), which repaints an idle pad at once.
  • Left const on purpose: hit and layout geometry (MOVE_R_PT, AIM_R_PT, SYS_HIT_PT, the 44 pt hit floor, ABILITIES radii and angles, EDGE_PT, GAP_PT, TABLET_*) because tests pin the Apple 44 pt minimum and the bottom footprint other UI avoids; input feel (JOY_DEADZONE, JOY_ZONE_FRAC, SLIDE_SLOP, DRONE_HOLD_MS, DRONE_NUDGE_*); and the icon / glyph shape ratios inside the draw helpers (pure drawing proportions, not look knobs).

Not liveness-probed (render-only); tests/test_touch_controls_tunables.gd pins the defaults against the old literals, the token aliases, and the real paths (Tunables.apply → _cluster_alpha / label_px, and an idle pad’s draw firing after apply_tunables). The manifest is regenerated at the next build bump.