App Clip (Warden demo) — implementation plan
App Clip (Warden demo) Implementation Plan
Section titled “App Clip (Warden demo) Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a native iOS App Clip (“Dark Cosmos” Warden demo): no menu, a 3-2-1 countdown, a ~45 s dense ramp with ~5x XP and curated showpiece offers, the Warden, then a win-or-lose end card that writes a “Beta skin earned” handoff file into a shared App Group, offers Apple’s install overlay, and a “Fight again” button; the full app consumes the handoff on launch and grants the Beta skin.
Architecture: One codebase. Two new Godot export presets (iOS-Clip, iOS-Clip-Dev) carry the feature tag app_clip, which routes main.gd’s boot straight into a new "clip" mode. In that mode the run setup attaches a ClipDirector (/sim, RefCounted) to the sim: it scales XP and swarm density, holds the wave loop until CLIP_RAMP_S, spawns the Warden with explicit biomass, restricts level-up offers to a curated pool, and latches a WIN/LOSE outcome; null everywhere else, so Survival, co-op and the determinism baselines never reach it. A render-side ClipFlow CanvasLayer draws the countdown, hint and end card. The native bridge (DcAppClip: App Group container path + SKOverlay) is a third class in the EXISTING dc_haptics GDExtension. A second Xcode target (DarkCosmosClip, product type on-demand-install-capable) is added to the exported iOS shell by an idempotent script that drives the Ruby xcodeproj gem; a node size gate fails the clip above 95 MB.
Tech Stack: Godot 4.8 (patched fork) typed GDScript, GUT; Objective-C++ GDExtension (godot-cpp godot-4.5-stable, SCons); Ruby xcodeproj gem 1.27.0 (bundled in Homebrew CocoaPods); bash + PlistBuddy; Node 22 node:test; Xcode xcodebuild / devicectl.
Spec: docs/superpowers/specs/2026-10-14-app-clip-warden-design.md (approved 2026-10-14). This plan implements its sections “Player experience”, “Build: one codebase, a new preset + mode”, “ClipDirector”, “Native bridge”, “Xcode: scripted”, “Size gate”, and their Testing items. NOT in this plan: the Beta skin system (docs/superpowers/plans/2026-10-15-beta-skin.md) and the launch screen + boot splash (docs/superpowers/plans/2026-10-15-launch-screen-boot-splash.md).
Cross-plan dependency (hard): Task 8 calls MetaState.grant_skin(id: String) -> bool (idempotent; true only when newly added; does NOT save) and reads MetaState.owned_skins: Array[String], both delivered by the Beta-skin plan with skin id "beta". Task 8 starts with a precheck and STOPS if they are not on origin/main. Every other task is independent of the Beta-skin plan.
Amendments to the spec (Chris-approved or decided here; flag in review):
- Native bridge goes into the existing
platform/tvos/addons/dc_hapticslibrary as classDcAppClip(Chris-approved), not a newaddons/dc_appclip. It therefore also ships in the full iOS app, which is what the full app needs to read the App Group. - App Group id is
group.$(BH_APP_ID): storegroup.digital.lumara.bulletheaven(the spec’s value), Devgroup.digital.lumara.bulletheaven.dev, so Dev and store installs never share a container. - Music: the clip keeps ONE track,
audio/music/boss_warden.mp3, and plays it for the whole run (the spec said “all music exceptboss_theme”;boss_theme.mp3is the legacy theme, the Warden’s live theme isboss_warden). Chris’s call to change. CLIP_SWARM_DENSITYscales BOTH the wave refill trigger and the wave’s biomass target. Scaling the target alone does nothing: the first Survival wave already reaches the 200-bodySim.SOFT_ENEMY_CAP.
Execution order (controller ruling 2026-10-15)
Section titled “Execution order (controller ruling 2026-10-15)”Device risk (signing, App Group, size, the install script) is retired FIRST, because of the 1.1 hard drop rule (Global Constraints). Phase A needs no gameplay: without Task 7 the clip boots into the normal menu, which is enough to prove signing, the App Group and size. Phase A and Phase B are file-disjoint, so one implementer can run B straight after A or a second worktree can run it in parallel. Tasks 11 and 12 are split so a per-task extractor sees each piece (ids 11a, 11b, 12a, 12b, 12c).
Phase A (a signed clip on Chris’s iPhone):
- Task 3 (presets)
- Task 5 (
DcAppClipnative bridge) - Task 9 (Xcode target script; first seed the worktree’s iOS shell, see its Step 8)
- Task 10 (size gate)
- Task 11a (Dev install path + the
install-dev.shclip-target guard; static checks only) - Task 12a [DEVICE] (Dev signing, App Group, clip MB, entitlements)
- Task 11b (opt-in release path, docs, What’s new)
- Task 12c [CHRIS OK] (store archive + real thinning report, from a SEPARATE seeded worktree; the only proof of store signing, so run it now, not last)
Phase B (gameplay):
9. Task 1, then 2, then 4, then 6, then 7
10. Task 12b [DEVICE] (clip run, stall check, baked-iPhone check, pool sign-off)
11. Task 8 once Beta-skin Task 1 (MetaState.grant_skin) is on main, then Task 12b’s handoff step
12. Task 13 (Chris-gated). Claude prepares the ASC App Clip metadata files and the clip-telemetry privacy diff (Step 2 and Step 4) during Phase B; Chris decides by 2026-10-16.
Shell rule (all tasks): the worktree has no Xcode shell (gitignored). Tasks 9 and 11 cp -R the main checkout’s platform/tvos/build-ios into the worktree (never a symlink; skill bh-worktree-device-install). NEVER write under /Users/chris/Claude/bullet-heaven/platform/tvos/ (the main checkout’s live shell).
Global Constraints
Section titled “Global Constraints”- Target: 1.1 under Chris’s hard drop rule (2026-10-15): if the clip is not green on Chris’s iPhone by the 2026-10-17 feature freeze it comes out of 1.1 and follows in 1.1.1 (an App Clip ships inside an app version’s build). The release path is opt-in (
BH_WITH_CLIP=1) and stays OFF by default until Chris approves (Task 13). - App Clip limit: 100 MB uncompressed, iOS 17+, digital invocation only. Gate: fail above 95 MB.
- Clip target: product type
com.apple.product-type.application.on-demand-install-capable;IPHONEOS_DEPLOYMENT_TARGET = 17.0; entitlementscom.apple.developer.on-demand-install-capable = true,com.apple.developer.parent-application-identifiers = [$(AppIdentifierPrefix)<parent id>],com.apple.security.application-groups = [group.<parent id>]; Info.plistNSAppClip = {NSAppClipRequestEphemeralUserNotification: false, NSAppClipRequestLocationConfirmation: false}. - Bundle ids: store
digital.lumara.bulletheaven.Clip, Devdigital.lumara.bulletheaven.dev.Clip. Parent ids unchanged (digital.lumara.bulletheaven,digital.lumara.bulletheaven.dev). - Handoff file:
<App Group container>/clip_rewards.json,{"beta_skin": true, "earned_at": <unix>}; the full app adds"consumed_at": <unix>once granted. The skin is granted on BOTH outcomes. - End card copy (spec, verbatim): win
Warden destroyed. Beta skin unlocked.; loseSo close. Beta skin unlocked anyway.Secondary buttonFight again. - Console tunables:
ClipDirector.CLIP_XP_MULT,ClipDirector.CLIP_RAMP_S,ClipDirector.CLIP_WARDEN_HP_SCALE,ClipDirector.CLIP_SWARM_DENSITY(spec namesclip_xp_mult,clip_ramp_s,clip_warden_hp_scale,clip_swarm_density). /simis pure logic:ClipDirector extends RefCounted, no Node/Engine/Time/Input APIs. All offer randomness onsim.upgrade_rng, neversim.rng.- Determinism: the three pinned baseline tests (
tests/test_determinism_survival.gd,tests/test_determinism.gd,tests/test_determinism_coop.gd) must stay green UNTOUCHED. Every sim hook isif clip_director != null. - XP is paid on
biomass: the Warden spawn setsenemies.biomass[bi]explicitly; the test asserts the XP paid through the realdamage_enemy->_sweep_deadpath. - UI: no colour literals, numeric font sizes or theme constants in
ui/**/*.gdormain.gd(literal ratchet,tests/test_ui_literal_ratchet.gd): useTokenBind/Tokens. Fit tests at the real device profiles (ShopLayoutFixture: tv, iphone, ipad). - Xcode shells (
platform/tvos/build-ios/, newplatform/tvos/build-ios-clip/) are gitignored and regenerated by exports: never hand-edit; every change is a tracked input underplatform/tvos/patches/applied by an idempotent script. - Secrets: scripts source
"$HOME/.sec""rets"(the existing split-string idiom) and referenceASC_KEY_ID/ASC_ISSUER_IDby name only. Never print, read or paste a value. - No Game Center, no Sentry in the clip.
- Store players get the clip only in a reviewed version where it is visible (Apple 2.3.1); never a remote flag.
Working rules for every task
Section titled “Working rules for every task”- Work in your OWN worktree, never the main checkout:
git -C /Users/chris/Claude/bullet-heaven fetch origin && git -C /Users/chris/Claude/bullet-heaven worktree add .claude/worktrees/app-clip -b app-clip origin/main(first task), then reuse/Users/chris/Claude/bullet-heaven/.claude/worktrees/app-clipfor the rest. All paths below are relative to that worktree. - After adding a new
class_name, rungodot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tresbefore running a single test, or a headless run can hang on the stale class cache. - Single GUT script:
perl -e 'alarm 300; exec @ARGV' godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=<file>.gd -gexit > "$TMPDIR/bh-one.log" 2>&1; grep -E "Passing|Failing|\[Failed\]|SCRIPT ERROR|Parse Error" "$TMPDIR/bh-one.log". Never pull a raw GUT run into context. - Full suite:
GUT_LOG="$TMPDIR/bh-gut-app-clip.log" bash scripts/check-test-count.sh(prints ~4 lines; names failures). Before the branch is offered for merge:bash scripts/gate.sh. - Node tests:
node --test scripts/test/<name>.test.mjs. - Steps marked [DEVICE] need Chris’s physical iPhone/iPad; [CHRIS] need his decision or approval. Agents never install on a device while a human playtest is live.
- Xcode shells: the worktree has none.
cp -R /Users/chris/Claude/bullet-heaven/platform/tvos/build-ios platform/tvos/build-ios(a private copy; never a symlink, never write into the main checkout’s shell). - Do not commit the plan’s own changes to
maindirectly; the controller merges perdocs/reference/builds-and-releases.md.
File Structure
Section titled “File Structure”| Path | Action | Responsibility |
|---|---|---|
sim/clip_director.gd |
Create | ClipDirector: phases, outcome, 4 tunables, ramp/Warden spawn, XP and density scaling, curated offer pool |
sim/sim.gd |
Modify | clip_director var + 6 null-guarded hooks |
sim/upgrade_system.gd |
Modify | roll_upgrade_choices delegates to the director when attached |
net/tunables_registry.gd |
Modify | 4 registry rows |
net/live_scope.gd |
Modify | 3 LiveScope rows (XP_MULT is per-kill LIVE by default) |
tools/tunable_docs/docs.d/app_clip.json |
Create | Console labels for the 4 keys |
tools/tunable_docs/generate.mjs |
Modify | App Clip group rule + order |
control/src/tunable_docs.js |
Regenerate | Generated from docs.d (never hand-edit) |
docs/architecture/remote-config.md |
Modify | Batch section for the clip keys |
tests/test_clip_director.gd, tests/test_clip_offers.gd |
Create | Sim-side tests |
platform/tvos/export_presets.cfg |
Modify | preset.7 iOS-Clip, preset.8 iOS-Clip-Dev |
tests/test_release_presets.gd |
Modify | Roster + clip rules |
scripts/bump-build.sh |
Modify | 7 Apple presets |
docs/reference/builds-and-releases.md, docs/architecture/dev-tools.md, scripts/release/release.sh, .claude/skills/bh-appstore-release/SKILL.md, .claude/skills/bh-merge-reviewed-branch/SKILL.md (+ .factory/ mirror) |
Modify | “5 Apple presets” -> 7 |
meta/clip_rewards.gd |
Create | ClipRewards: handoff file + thin native wrapper |
tests/fake_dc_app_clip.gd, tests/test_clip_rewards.gd |
Create | Test double + tests |
platform/tvos/addons/dc_haptics/src/dc_app_clip.{h,mm} |
Create | DcAppClip native class |
platform/tvos/addons/dc_haptics/src/register_types.mm |
Modify | Register/unregister the DcAppClip singleton |
ui/clip_flow.gd |
Create | ClipFlow: countdown, hint, end card |
tests/test_clip_flow.gd |
Create | Copy, timing and device-fit tests |
main.gd |
Modify | Boot route, clip mode, end flow, full-app consume |
tests/test_clip_main.gd, tests/test_clip_handoff_main.gd |
Create | Through-main tests |
platform/tvos/patches/ios-clip/add_clip_target.rb |
Create | pbxproj edit (xcodeproj gem) |
platform/tvos/patches/ios-clip/DarkCosmosClip.entitlements |
Create | Clip entitlements template |
scripts/release/add-app-clip-target.sh |
Create | Idempotent driver: plists, entitlements, ruby, checks |
scripts/release/apply-ios-metrickit.sh |
Modify | Accept the array-form OTHER_LDFLAGS the gem writes |
scripts/test/apply-ios-metrickit.test.mjs |
Modify | 2 array-form tests |
scripts/release/check-clip-size.mjs |
Create | Size gate (--report / --app) |
scripts/test/check-clip-size.test.mjs, scripts/test/fixtures/clip-thinning-report.txt |
Create | Gate tests + fixture |
scripts/dev/install-clip-dev.sh |
Create | Dev build + install of the clip (and optionally the parent) |
scripts/dev/install-dev.sh |
Modify | Lib refresh (DONE 24c199de); Task 11a adds the clip-target guard (switch to BH_APP_ID) |
scripts/release/env.sh, scripts/release/build-ios.sh |
Modify | Opt-in BH_WITH_CLIP=1 release path + thinning gate |
.gitignore |
Modify | platform/tvos/build-ios-clip*/ |
platform/tvos/patches/README.md, platform/tvos/CLAUDE.md, docs/architecture/dev-tools.md, control/src/whats_new.js |
Modify | Docs + What’s new |
Task 1: ClipDirector run shape + its console tunables
Section titled “Task 1: ClipDirector run shape + its console tunables”Files:
- Create:
sim/clip_director.gd,tests/test_clip_director.gd,tools/tunable_docs/docs.d/app_clip.json - Modify:
sim/sim.gd(var block ~line 685;tick()after the_check_pilot_hitloop ~1050;_spawn_enemies~2254;_spawn_wave~2292;_award_xp~2984;_roll_dimension_choice~3238),net/tunables_registry.gd(const block ~line 40; end ofENTRIES~1511),net/live_scope.gd(~line 80),tools/tunable_docs/generate.mjs(GROUP_RULES~39,ORDER~81),docs/architecture/remote-config.md - Regenerate:
control/src/tunable_docs.js
Interfaces:
-
Produces:
class_name ClipDirector extends RefCountedwithenum Phase { RAMP, WARDEN, ENDED },enum Outcome { NONE, WIN, LOSE },var phase: int,var outcome: int,var warden_spawned_at: float,func ended() -> bool,func holds_waves() -> bool,func update_spawns(sim: Sim) -> void,func spawn_warden(sim: Sim) -> void,func post_tick(sim: Sim) -> void,func xp_mult() -> float,func swarm_density() -> float,const WARDEN_BIOMASS: int = 400, static varsCLIP_XP_MULT,CLIP_RAMP_S,CLIP_WARDEN_HP_SCALE,CLIP_SWARM_DENSITY.Sim.clip_director: ClipDirector(null by default). -
Step 1: Create the worktree (see Working rules) and confirm the baseline:
GUT_LOG="$TMPDIR/bh-gut-app-clip.log" bash scripts/check-test-count.sh→ all passing. -
Step 2: Write the failing test
tests/test_clip_director.gd:
extends GutTest
# App Clip Warden demo, sim side (sim/clip_director.gd). Every check runs through Sim.tick and the# real spawn -> damage_enemy -> _sweep_dead path; tunables are patched through Tunables.apply.
func after_each() -> void: Tunables.reset()
func _apply(patch: Dictionary) -> void: assert_eq(Tunables.apply(patch), [], "patch %s applies cleanly" % str(patch))
func _sim(clip: bool, seed_v: int = 1234) -> Sim: var s := Sim.new(seed_v, SimContentFixture.db()) s.enable_survival() if clip: s.clip_director = ClipDirector.new() s.player.dev_invuln = true return s
func _tick(s: Sim, n: int) -> void: for _i in n: s.tick_single(InputState.new())
func _xp_total(s: Sim) -> float: var t := 0.0 for k in s.xp_by_type: t += float(s.xp_by_type[k]) return t
func test_no_director_outside_clip_mode() -> void: assert_null(Sim.new(1, SimContentFixture.db()).clip_director)
func test_xp_multiplier_scales_every_kill() -> void: _apply({"ClipDirector.CLIP_SWARM_DENSITY": 1.0}) # same swarm in both sims, only the XP differs var plain := _sim(false) var clip := _sim(true) _tick(plain, 900) _tick(clip, 900) assert_gt(_xp_total(plain), 0.0, "sanity: something died in 15 s") assert_eq(clip.kills, plain.kills, "same kills: the multiplier touches XP only") assert_almost_eq(_xp_total(clip), _xp_total(plain) * ClipDirector.CLIP_XP_MULT, 0.01)
func test_xp_multiplier_is_a_live_tunable() -> void: _apply({"ClipDirector.CLIP_SWARM_DENSITY": 1.0, "ClipDirector.CLIP_XP_MULT": 2.0}) var plain := _sim(false) var clip := _sim(true) _tick(plain, 900) _tick(clip, 900) assert_almost_eq(_xp_total(clip), _xp_total(plain) * 2.0, 0.01)
# Thin both fields to the same biomass (the real damage path), then tick: only the clip, whose refill# trigger is BIOMASS_TRIGGER x CLIP_SWARM_DENSITY, arms the next wave.func _thin_to(s: Sim, biomass_left: int) -> void: while s.enemies.count > 0 and s.total_biomass() > biomass_left: s.elemental_system.damage_enemy(s, s.enemies.count - 1, 1.0e9, true) s._sweep_dead()
func test_swarm_density_refills_the_field_sooner() -> void: var plain := _sim(false) var clip := _sim(true) plain._spawn_wave() # the real wave fill on an empty field clip._spawn_wave() var mid := int(round(Sim.BIOMASS_TRIGGER * (1.0 + ClipDirector.CLIP_SWARM_DENSITY) * 0.5)) _thin_to(plain, mid) _thin_to(clip, mid) assert_gt(plain.total_biomass(), Sim.BIOMASS_TRIGGER, "sanity: above the Survival trigger") _tick(plain, 1) _tick(clip, 1) assert_eq(clip.spawn_banner().get("text", ""), "WAVE INCOMING", "the clip re-arms a wave") assert_eq(plain.spawn_banner().get("text", ""), "", "Survival waits for the field to drain further")
func test_swarm_density_scales_the_wave_target() -> void: _apply({"ClipDirector.CLIP_SWARM_DENSITY": 0.5}) # below 1: fewer than the 200-body cap, so it shows var plain := _sim(false) var clip := _sim(true) plain._spawn_wave() clip._spawn_wave() assert_lt(clip.total_biomass(), plain.total_biomass()) assert_gte(clip.total_biomass(), int(round(Sim.BIOMASS_TARGET * 0.5)))
func test_ramp_holds_the_wave_loop() -> void: _apply({"Sim.WAVE_PHASE_S": 20.0, "ClipDirector.CLIP_RAMP_S": 60.0}) var plain := _sim(false) var clip := _sim(true) _tick(plain, 1500) _tick(clip, 1500) assert_ne(plain.spawn_phase, Sim.PHASE_WAVES, "sanity: survival left the wave phase at 20 s") assert_eq(clip.spawn_phase, Sim.PHASE_WAVES, "the clip ramp never winds down") assert_eq(clip.clip_director.phase, ClipDirector.Phase.RAMP) assert_eq(clip.boss_rotation.boss_index(clip), -1, "no Warden before the ramp ends")
func _to_warden(s: Sim) -> int: for _i in 600: _tick(s, 1) if s.clip_director.phase == ClipDirector.Phase.WARDEN: break return s.boss_rotation.boss_index(s)
func test_warden_spawns_at_ramp_end_with_explicit_biomass() -> void: _apply({"ClipDirector.CLIP_RAMP_S": 3.0}) var s := _sim(true) var bi := _to_warden(s) assert_ne(bi, -1, "the Warden is on the field") assert_almost_eq(s.clip_director.warden_spawned_at, 3.0, Sim_Const.DT * 1.5) assert_eq(s.spawn_phase, Sim.PHASE_BOSS) assert_eq(int(s.enemies.biomass[bi]), ClipDirector.WARDEN_BIOMASS, "biomass passed explicitly, not left 0")
func test_warden_hp_scale_is_baked_at_spawn() -> void: _apply({"ClipDirector.CLIP_RAMP_S": 1.0}) var a := _sim(true) var hp_1: float = a.enemies.max_hp[_to_warden(a)] Tunables.reset() _apply({"ClipDirector.CLIP_RAMP_S": 1.0, "ClipDirector.CLIP_WARDEN_HP_SCALE": 0.5}) var b := _sim(true) assert_almost_eq(b.enemies.max_hp[_to_warden(b)], hp_1 * 0.5, 0.01)
func test_killing_the_warden_wins_and_pays_its_xp() -> void: _apply({"ClipDirector.CLIP_RAMP_S": 2.0}) var s := _sim(true) var bi := _to_warden(s) var before := float(s.xp_by_type.get(EnemyPool.TYPE_BOSS, 0.0)) s.elemental_system.damage_enemy(s, bi, 1.0e9, true) _tick(s, 1) assert_eq(s.clip_director.phase, ClipDirector.Phase.ENDED) assert_eq(s.clip_director.outcome, ClipDirector.Outcome.WIN) assert_false(s.game_over, "a win is not a death") var paid := float(s.xp_by_type.get(EnemyPool.TYPE_BOSS, 0.0)) - before assert_almost_eq(paid, ClipDirector.WARDEN_BIOMASS * Sim.XP_PER_BIOMASS * ClipDirector.CLIP_XP_MULT, 0.01, "the Warden pays its biomass through _sweep_dead, times the clip multiplier") assert_true(s.pending_dimension_choice.is_empty(), "no Dimension warp offer in the clip")
func test_dying_loses() -> void: var s := _sim(true) _tick(s, 30) s.player.dev_invuln = false s.player.hp = 0.0 _tick(s, 1) assert_true(s.game_over) assert_eq(s.clip_director.phase, ClipDirector.Phase.ENDED) assert_eq(s.clip_director.outcome, ClipDirector.Outcome.LOSE)
func test_dying_to_the_warden_loses_too() -> void: _apply({"ClipDirector.CLIP_RAMP_S": 1.0}) var s := _sim(true) _to_warden(s) s.player.dev_invuln = false s.player.hp = 0.0 _tick(s, 1) assert_eq(s.clip_director.outcome, ClipDirector.Outcome.LOSE)
func test_outcome_latches() -> void: _apply({"ClipDirector.CLIP_RAMP_S": 1.0}) var s := _sim(true) var bi := _to_warden(s) s.elemental_system.damage_enemy(s, bi, 1.0e9, true) _tick(s, 1) s.clip_director.post_tick(s) assert_eq(s.clip_director.outcome, ClipDirector.Outcome.WIN, "a later post_tick never rewrites it")-
Step 3: Run it to verify it fails. Run the single-script command for
test_clip_director.gd. Expected: parse error /ClipDirectornot declared. -
Step 4: Create
sim/clip_director.gd:
class_name ClipDirectorextends RefCounted
# App Clip Warden demo (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md). One ~2 minute# run: RAMP (dense swarm, CLIP_XP_MULT x XP, offers only from the curated showpiece pool) -> at# CLIP_RAMP_S the Warden spawns through BossWarden.spawn with its biomass set explicitly (root CLAUDE.md# XP trap) -> ENDED, outcome WIN (Warden dead) or LOSE (every pilot down). The UI reads `phase` and# `outcome`; main stops ticking once phase == ENDED.## Attached by main ONLY in clip mode (`sim.clip_director`); null everywhere else, so Survival, co-op and# the three pinned determinism baselines never reach any of this. Pure sim: RefCounted, no Node/Engine.
enum Phase { RAMP, WARDEN, ENDED }enum Outcome { NONE, WIN, LOSE }
# Remote Console tunables (net/tunables_registry.gd, tools/tunable_docs/docs.d/app_clip.json).static var CLIP_XP_MULT: float = 5.0 # XP income multiplier for the whole clip runstatic var CLIP_RAMP_S: float = 45.0 # run seconds before the Warden spawnsstatic var CLIP_WARDEN_HP_SCALE: float = 1.0 # Warden HP multiplier (baked at spawn)# Scales BOTH the wave refill trigger (Sim.BIOMASS_TRIGGER) and the wave's biomass target, so# BIOMASS_TRIGGER <= BIOMASS_TARGET still holds. Sim.SOFT_ENEMY_CAP (200 bodies) is NOT scaled: it is the# perf ceiling, and a first wave already reaches it, so the visible effect is a field refilled before# it thins out (trigger 250 instead of 100 at 2.5), not more bodies than Survival ever shows.static var CLIP_SWARM_DENSITY: float = 2.5
# The Warden pays a phase boss's XP (Sim.BOSS_XP_BIOMASS), set explicitly on the spawned slot.const WARDEN_BIOMASS: int = 400
var phase: int = Phase.RAMPvar outcome: int = Outcome.NONEvar warden_spawned_at: float = -1.0 # run_time of the spawn, -1 before it
func ended() -> bool: return phase == Phase.ENDED
# Sim._spawn_enemies, while RAMP: hold the wave loop in PHASE_WAVES (no wind-down, no Home boss# rotation) and spawn the Warden once the ramp is over. After this, Sim's own PHASE_BOSS handling runs# (boss adds after BOSS_ADDS_DELAY_S).func holds_waves() -> bool: return phase == Phase.RAMP
func update_spawns(sim: Sim) -> void: if phase == Phase.RAMP and sim.run_time >= CLIP_RAMP_S: spawn_warden(sim)
func spawn_warden(sim: Sim) -> void: sim.boss_warden.spawn(sim, CLIP_WARDEN_HP_SCALE) var bi := sim.boss_rotation.boss_index(sim) if bi != -1: sim.enemies.biomass[bi] = WARDEN_BIOMASS sim._set_spawn_phase(Sim.PHASE_BOSS) phase = Phase.WARDEN warden_spawned_at = sim.run_time
# End of Sim.tick (after _sweep_dead and the pilot hit checks). A Warden that dies on the same tick as# the last pilot counts as a WIN: the player did kill it.func post_tick(sim: Sim) -> void: if phase == Phase.ENDED: return if phase == Phase.WARDEN and sim.boss_rotation.boss_index(sim) == -1: _end(Outcome.WIN) elif sim.game_over: _end(Outcome.LOSE)
func _end(o: int) -> void: phase = Phase.ENDED outcome = o
func xp_mult() -> float: return CLIP_XP_MULT
func swarm_density() -> float: return CLIP_SWARM_DENSITY- Step 5: Hook it into
sim/sim.gd(six edits; each is a no-op whenclip_director == null):
# 1) var block, directly after `var story_director: StoryDirector = null ...`:var clip_director: ClipDirector = null # App Clip Warden demo only (main attaches it); null = every other mode# 2) tick(): directly after# for pilot in pilots:# _check_pilot_hit(pilot) if clip_director != null: clip_director.post_tick(self) # App Clip: Warden dead -> WIN, all pilots down -> LOSE# 3) _spawn_enemies(): replace the single line# _update_spawn_phase(dt) # advance the wave → clear → boss → repeat macro-loop# with if clip_director != null and clip_director.holds_waves(): clip_director.update_spawns(self) # App Clip ramp: waves only, then the Warden at CLIP_RAMP_S else: _update_spawn_phase(dt) # advance the wave → clear → boss → repeat macro-loop# 4) _spawn_enemies(): replace# elif total_biomass() < BIOMASS_TRIGGER:# with elif total_biomass() < (BIOMASS_TRIGGER if clip_director == null else int(round(BIOMASS_TRIGGER * clip_director.swarm_density()))):# 5) _spawn_wave(): directly after# var biomass_target: int = HardTuning.biomass_target(self, BIOMASS_TARGET) # T6: unchanged unless hard tuning if clip_director != null: biomass_target = int(round(biomass_target * clip_director.swarm_density())) # App Clip: denser swarm# 6a) _award_xp(): directly after `var v := amount * area_reward_mult * _xp_depth_mult()` if clip_director != null: v *= clip_director.xp_mult() # App Clip: ~5x XP so the ramp reaches 4-5 level-ups# 6b) _roll_dimension_choice(): directly after the `if not areas_enabled: return` block, before `if story != null:` if clip_director != null: return # App Clip: the Warden's death ends the run (ClipDirector.post_tick), no warp offer- Step 6: Register the tunables. In
net/tunables_registry.gdadd afterconst UPGRADE_SYSTEM := "res://sim/upgrade_system.gd":
const CLIP_DIRECTOR := "res://sim/clip_director.gd"and append to the END of ENTRIES (after the LockstepSession.READY_TIMEOUT_MS row):
# App Clip Warden demo (sim/clip_director.gd): read only while a ClipDirector is attached (clip mode). {"key": "ClipDirector.CLIP_XP_MULT", "script": CLIP_DIRECTOR, "name": "CLIP_XP_MULT", "min": 1.0, "max": 20.0}, {"key": "ClipDirector.CLIP_RAMP_S", "script": CLIP_DIRECTOR, "name": "CLIP_RAMP_S", "min": 1.0, "max": 180.0}, {"key": "ClipDirector.CLIP_WARDEN_HP_SCALE", "script": CLIP_DIRECTOR, "name": "CLIP_WARDEN_HP_SCALE", "min": 0.2, "max": 5.0}, {"key": "ClipDirector.CLIP_SWARM_DENSITY", "script": CLIP_DIRECTOR, "name": "CLIP_SWARM_DENSITY", "min": 0.5, "max": 4.0},In net/live_scope.gd, inside TUNABLES, directly after the "Sim.BIOMASS_TARGET" row:
# App Clip (sim/clip_director.gd): the Warden's HP scale is baked into it at spawn; the rest are read # per tick / per wave while a ClipDirector is attached. "ClipDirector.CLIP_WARDEN_HP_SCALE": [NEW_SPAWNS, "", "Warden HP baked at spawn"], "ClipDirector.CLIP_SWARM_DENSITY": [LIVE, "", "read at each wave and each refill check"], "ClipDirector.CLIP_RAMP_S": [LIVE, "", "read each tick until the Warden spawns"],Create tools/tunable_docs/docs.d/app_clip.json:
{ "ClipDirector.CLIP_XP_MULT": {"label": "App Clip: XP multiplier", "desc": "XP from every kill in the App Clip Warden demo is multiplied by this, so the 45 s ramp reaches 4-5 level-ups. App Clip runs only.", "unit": "×", "src": "code"}, "ClipDirector.CLIP_RAMP_S": {"label": "App Clip: ramp before the Warden", "desc": "Run seconds of swarm before the Warden spawns. Takes effect until the Warden is out. App Clip runs only.", "unit": "s", "src": "code"}, "ClipDirector.CLIP_WARDEN_HP_SCALE": {"label": "App Clip: Warden HP", "desc": "Multiplies the Warden's HP when it spawns (tune so a typical player wins in about 75 s). App Clip runs only.", "unit": "×", "src": "code"}, "ClipDirector.CLIP_SWARM_DENSITY": {"label": "App Clip: swarm density", "desc": "Scales the wave refill trigger and the wave's biomass target, so the field refills before it thins out. The 200-enemy field cap still applies. App Clip runs only.", "unit": "×", "src": "code"}}In tools/tunable_docs/generate.mjs: add [/^ClipDirector\./, 'App Clip'], to GROUP_RULES directly after the [/^BossWarden\./, 'Warden boss'], row, and in ORDER change 'Performance', 'Networking', 'Dev tools']; to 'Performance', 'Networking', 'App Clip', 'Dev tools'];. Then regenerate: node tools/tunable_docs/generate.mjs (expect a line reporting App Clip: 4; control/src/tunable_docs.js changes — never hand-edit it).
-
Step 7: Run the test to verify it passes.
godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres, then the single-script command fortest_clip_director.gd. Expected: 12/12 passing. -
Step 8: Probe and classify the keys (skill
bh-tunable-batchsteps 3-4).perl -e 'alarm 900; exec @ARGV' godot --headless --path . -s res://tools/remote_config/liveness_probe.gd -- --keys=ClipDirector.CLIP_XP_MULT,ClipDirector.CLIP_RAMP_S,ClipDirector.CLIP_WARDEN_HP_SCALE,ClipDirector.CLIP_SWARM_DENSITY > "$TMPDIR/bh-probe-clip.log" 2>&1; tail -8 "$TMPDIR/bh-probe-clip.log". Expected: all four NO-DIFF (the probe runs Survival, which never attaches a ClipDirector). Classify all four scenario-limited, shipped on wiring-test evidence:test_xp_multiplier_is_a_live_tunable,test_swarm_density_*,test_ramp_holds_the_wave_loop/test_warden_spawns_at_ramp_end_with_explicit_biomass,test_warden_hp_scale_is_baked_at_spawnpatch each key throughTunables.applyand observe it on the real call path. Thengodot --headless --path . -s res://tools/remote_config/dump_manifest.gd, confirmgrep -c ClipDirector remote-config/manifest/build-*.json | tail -1reports 4 in the newest file, andgit checkout -- remote-config/manifest/ && git clean -f remote-config/manifest/(manifests are committed by the BUILD-bump commit, not by feature branches). -
Step 9: Document the batch. Append to
docs/architecture/remote-config.md:
## 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` | 5.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` |
The liveness probe reports all four NO-DIFF because it runs Survival: they are scenario-limited andshipped on the wiring tests above. `CLIP_SWARM_DENSITY` scales `Sim.BIOMASS_TRIGGER` and the wave'sbiomass target together (the `BIOMASS_TRIGGER <= BIOMASS_TARGET` cross-rule still holds); it does notlift `Sim.SOFT_ENEMY_CAP`.-
Step 10: Run the full suite.
GUT_LOG="$TMPDIR/bh-gut-app-clip.log" bash scripts/check-test-count.sh. Expected: 0 failing; the three determinism baselines,test_tunables_registry*, LiveScope and tunable-docs tests green. Alsonode --test control/test/*.test.mjs→ pass. -
Step 11: Commit.
git add sim/clip_director.gd sim/sim.gd net/tunables_registry.gd net/live_scope.gd tools/tunable_docs/docs.d/app_clip.json tools/tunable_docs/generate.mjs control/src/tunable_docs.js docs/architecture/remote-config.md tests/test_clip_director.gdandgit commit --file=<msg file>with messagefeat(sim): ClipDirector run shape + 4 App Clip console tunables.
Task 2: Curated showpiece offer pool
Section titled “Task 2: Curated showpiece offer pool”Files:
- Modify:
sim/clip_director.gd,sim/upgrade_system.gd(roll_upgrade_choices, ~line 74) - Create:
tests/test_clip_offers.gd
Interfaces:
-
Consumes:
ClipDirector(Task 1);UpgradeSystem.is_weapon_active(sim, pilot, wid) -> bool,UpgradeSystem.mod_eligible(sim, pilot, u: Dictionary) -> bool,UpgradeSystem.is_dud_crystal(sim, pilot, id: String) -> bool,UpgradeSystem.apply_upgrade(sim, pilot, id),UpgradeSystem.WEAPON_ORDER(all existing). -
Produces:
const POOL_WEAPONS: Array[String],const POOL_MODS: Array[String],const POOL_CRYSTALS: Array[String],func roll_offers(sim: Sim, pilot: PlayerState, n: int) -> Array[String],func eligible_pool(sim: Sim, pilot: PlayerState) -> Array[String]. -
Step 1: Write the failing test
tests/test_clip_offers.gd:
extends GutTest
# App Clip curated showpiece pool: with a ClipDirector attached, every level-up card comes from# ClipDirector's pool, drawn on upgrade_rng (never the spawn rng), applied through the real apply_upgrade.
func _sim() -> Sim: var s := Sim.new(77, SimContentFixture.db()) s.enable_survival() s.clip_director = ClipDirector.new() return s
func _pool() -> Array[String]: var out: Array[String] = [] for w: String in ClipDirector.POOL_WEAPONS: out.append("weapon:" + w) out.append_array(ClipDirector.POOL_MODS) out.append_array(ClipDirector.POOL_CRYSTALS) return out
func test_pool_ids_are_real_content() -> void: var s := _sim() var mod_ids: Array = s.content.upgrades().map(func(u: Dictionary) -> String: return String(u["id"])) for id: String in ClipDirector.POOL_MODS: assert_has(mod_ids, id, "%s is an offerable bible mod" % id) for w: String in ClipDirector.POOL_WEAPONS: assert_has(UpgradeSystem.WEAPON_ORDER, w, "%s is a grantable weapon" % w)
func test_every_offer_is_from_the_pool_across_a_whole_build() -> void: var s := _sim() var pool := _pool() var seen := {} for _lv in 12: var offers := s.upgrade_system.roll_upgrade_choices(s, s.player, 3) assert_gt(offers.size(), 0, "never an empty panel") for id: String in offers: assert_has(pool, id, "%s is a curated card" % id) seen[id] = true s.upgrade_system.apply_upgrade(s, s.player, offers[0]) assert_gt(seen.size(), 4, "the pool really rotates")
func test_offers_draw_upgrade_rng_never_the_spawn_rng() -> void: var s := _sim() var spawn_before := s.rng.get_state() var up_before := s.upgrade_rng.get_state() s.upgrade_system.roll_upgrade_choices(s, s.player, 3) assert_eq(s.rng.get_state(), spawn_before) assert_ne(s.upgrade_rng.get_state(), up_before)
func test_dud_crystals_and_owned_weapons_drop_out() -> void: var s := _sim() var pool: Array[String] = s.clip_director.eligible_pool(s, s.player) assert_false(pool.has("crystals:lightning=20"), "no lightning weapon owned yet: a dud") assert_true(pool.has("weapon:thunder_wake")) s.upgrade_system.apply_upgrade(s, s.player, "weapon:thunder_wake") pool = s.clip_director.eligible_pool(s, s.player) assert_false(pool.has("weapon:thunder_wake"), "owned") assert_true(pool.has("crystals:lightning=20"), "Thunder Wake owned: its evolve grant is live")
func test_survival_offers_are_untouched_without_a_director() -> void: var a := Sim.new(77, SimContentFixture.db()) a.enable_survival() var b := Sim.new(77, SimContentFixture.db()) b.enable_survival() assert_eq(a.upgrade_system.roll_upgrade_choices(a, a.player, 3), b.upgrade_system.roll_upgrade_choices(b, b.player, 3))-
Step 2: Run it to verify it fails. Expected:
POOL_WEAPONS/eligible_poolnot found. -
Step 3: Add the pool to
sim/clip_director.gd. Afterconst WARDEN_BIOMASS: int = 400insert:
# Curated showpiece pool (CHRIS-TUNABLE: picked in implementation with Chris, spec "Open items").# Every card is a visible jump: a new weapon, a headline mod, or a crystal grant big enough to cross a# WeaponThresholds evolve/upgrade line at once. Ineligible cards (owned weapon, full slots, a crystal# no owned weapon uses) drop out at roll time, so the pool tracks the build.const POOL_WEAPONS: Array[String] = ["nova", "orbit", "beam", "thunder_wake"]const POOL_MODS: Array[String] = ["fire-rate", "split", "pierce", "crit"]const POOL_CRYSTALS: Array[String] = [ "crystals:light=18", # Nova -> Supernova, Beam evolve "crystals:void=18", # Orbit evolve, Blaster evolve "crystals:lightning=20", # Thunder Wake evolve "crystals:fire=12", # Nova radius + rate "crystals:kinetic=12", # Blaster power + multishot "crystals:aether=5,kinetic=10", # Rift Bolt power + pierce]and at the end of the file:
# UpgradeSystem.roll_upgrade_choices delegates here when a ClipDirector is attached. Same rng stream# (upgrade_rng) as every other offer; Fisher-Yates over the eligible curated cards, first n.# Meta weapon locks are ignored on purpose: the clip flies a fresh save, and the pool IS the unlock list.func roll_offers(sim: Sim, pilot: PlayerState, n: int) -> Array[String]: var pool := eligible_pool(sim, pilot) for i in range(pool.size() - 1, 0, -1): var j := sim.upgrade_rng.randi_range(0, i) var t := pool[i] pool[i] = pool[j] pool[j] = t return pool.slice(0, n)
func eligible_pool(sim: Sim, pilot: PlayerState) -> Array[String]: var out: Array[String] = [] var us := sim.upgrade_system if pilot.arsenal.active_weapon_ids.size() < sim.max_weapon_slots: for wid: String in POOL_WEAPONS: if pilot.arsenal.weapon_by_id.get(wid) != null and not us.is_weapon_active(sim, pilot, wid): out.append("weapon:" + wid) for u: Dictionary in sim.content.upgrades(): if POOL_MODS.has(String(u.get("id", ""))) and us.mod_eligible(sim, pilot, u): out.append(String(u["id"])) for id: String in POOL_CRYSTALS: if not us.is_dud_crystal(sim, pilot, id): out.append(id) return outNote for the reviewer: crystals:light=18 is NOT a dud on a fresh run because the always-built Rift Bolt (aim) uses light; the test relies on lightning being the dud.
- Step 4: Delegate from
sim/upgrade_system.gd. First lines offunc roll_upgrade_choices(sim: Sim, pilot: PlayerState, n: int) -> Array[String]:
if sim.clip_director != null: return sim.clip_director.roll_offers(sim, pilot, n) # App Clip: curated showpiece pool only-
Step 5: Run the test to verify it passes. Expected: 5/5. Then the full suite (
check-test-count.sh): 0 failing. -
Step 6: Commit
sim/clip_director.gd sim/upgrade_system.gd tests/test_clip_offers.gd—feat(sim): App Clip curated showpiece offer pool.
Task 3: iOS-Clip / iOS-Clip-Dev export presets
Section titled “Task 3: iOS-Clip / iOS-Clip-Dev export presets”Files:
- Modify:
platform/tvos/export_presets.cfg(appendpreset.7,preset.8),tests/test_release_presets.gd,scripts/bump-build.sh,scripts/release/release.sh:8,docs/reference/builds-and-releases.md:10,docs/architecture/dev-tools.md:272,.claude/skills/bh-appstore-release/SKILL.md(lines 18, 41),.claude/skills/bh-merge-reviewed-branch/SKILL.md(line 106),.factory/(generated)
Interfaces:
-
Produces: presets named exactly
iOS-Clip(featuresapp_clip) andiOS-Clip-Dev(featuresdev,dev_filelog,app_clip), consumed by Tasks 11a-12c (--export-pack "iOS-Clip-Dev"/"iOS-Clip").OS.has_feature("app_clip")is true only in those builds (Task 7 relies on it). -
Step 1: Write the failing tests in
tests/test_release_presets.gd. Add the helper after_presets():
func _is_clip(p: Dictionary) -> bool: return "app_clip" in String(p.features).split(",")Change the roster assertion to:
assert_eq(names, ["tvOS", "iOS", "tvOS-Dev", "iOS-Dev", "Android", "Android-Dev", "macOS", "iOS-Clip", "iOS-Clip-Dev"])In test_tvos_and_android_presets_exclude_sentry_and_ios_presets_keep_it, change the condition line to:
# The App Clip presets drop Sentry too: the clip has no crash SDK (size budget, spec 2026-10-14). if String(p.name).begins_with("tvOS") or p.platform == "Android" or p.platform == "macOS" or _is_clip(p):Append:
# App Clip (spec 2026-10-14-app-clip-warden-design.md): only the two clip presets carry app_clip (it routes# boot straight into the Warden run), they keep the Warden's theme and spawn growl but drop every other# music track, and they target iOS 17 (Apple's App Clip floor for the 100 MB digital-invocation limit).func test_only_the_clip_presets_carry_app_clip() -> void: var clips: Array = _presets().filter(func(p): return _is_clip(p)).map(func(p): return p.name) assert_eq(clips, ["iOS-Clip", "iOS-Clip-Dev"])
func test_clip_presets_strip_music_but_keep_the_warden() -> void: var cf := ConfigFile.new() cf.load(PRESETS) for p in _presets(): if not _is_clip(p): continue var parts: Array = Array(String(p.exclude).split(",")).map(func(x: String) -> String: return x.strip_edges()) for must in ["audio/music/area_*", "audio/menu_theme.mp3", "audio/survival_loop.mp3", "audio/boss_theme.mp3"]: assert_true(must in parts, "%s must exclude %s" % [p.name, must]) for keep in ["audio/music/boss_warden.mp3", "audio/sfx_v2/spawn_warden.wav", "audio/sfx_v2/death_warden.wav"]: assert_false(keep in parts, "%s must keep %s" % [p.name, keep]) # Match each exclude glob against the kept file: an exact-string check would miss `audio/sfx_v2/spawn_*`. for pat in parts: assert_false(keep.match(pat), "%s: exclude '%s' would strip %s" % [p.name, pat, keep]) for wide in ["audio/sfx_v2/*", "audio/music/*"]: assert_false(wide in parts, "%s must not exclude all of %s" % [p.name, wide]) for i in range(_presets().size()): if _is_clip(_presets()[i]): assert_eq(str(cf.get_value("preset.%d.options" % i, "application/min_ios_version", "")), "17.0")-
Step 2: Run it to verify it fails (
-gselect=test_release_presets.gd). Expected: roster mismatch + the two new tests failing. -
Step 3: Append the presets to
platform/tvos/export_presets.cfg. Copyapplication/short_versionandapplication/versionfrom the existingiOS-Devpreset’s current values (do NOT invent them;bump-build.sh --syncin Step 5 re-syncsversion). Use the same option set asiOS-Dev’s[preset.3.options], changing onlymin_ios_versionand keeping the STORE bundle id (the Dev id is an xcodebuild override):
[preset.7]
name="iOS-Clip"platform="iOS"runnable=falseadvanced_options=falsededicated_server=falsecustom_features="app_clip"export_filter="all_resources"include_filter=""exclude_filter="tests/*, telemetry/*, tools/*, addons/gut/*, audio/candidates/*, addons/sentry/*, sentry_dsn.json, audio/menu_theme.mp3, audio/survival_loop.mp3, audio/boss_theme.mp3, audio/music/area_*, audio/music/boss_eye.mp3, audio/music/boss_funzo.mp3, audio/music/boss_graviton.mp3, audio/boss2_*, audio/eye_*, audio/funzo_*, audio/graviton_*, audio/special_*, audio/king_hit*, audio/sfx_v2/boss2_*, audio/sfx_v2/death_boss2*, audio/sfx_v2/death_eye*, audio/sfx_v2/death_funzo*, audio/sfx_v2/death_graviton*, audio/sfx_v2/eye_*, audio/sfx_v2/funzo_*, audio/sfx_v2/graviton_*, audio/sfx_v2/king_hit*, audio/sfx_v2/special_*"export_path="build-ios/BulletHeaven.xcodeproj"patches=PackedStringArray()encryption_include_filters=""encryption_exclude_filters=""seed=0encrypt_pck=falseencrypt_directory=falsescript_export_mode=2
[preset.7.options]
<the whole body of [preset.3.options] (iOS-Dev, 26 keys on 2026-10-15), copied byte for byte, then change ONLY application/min_ios_version to "17.0">
[preset.8]
name="iOS-Clip-Dev"platform="iOS"runnable=falseadvanced_options=falsededicated_server=falsecustom_features="dev,dev_filelog,app_clip"export_filter="all_resources"include_filter=""exclude_filter="<identical to preset.7>"export_path="build-ios/BulletHeaven.xcodeproj"patches=PackedStringArray()encryption_include_filters=""encryption_exclude_filters=""seed=0encrypt_pck=falseencrypt_directory=falsescript_export_mode=2
[preset.8.options]
<byte-identical to the [preset.7.options] body>(The <...> markers are mechanical copy instructions, not open choices. Recipe: awk '/^\[preset.3.options\]/{f=1;next} /^\[preset/{f=0} f' platform/tvos/export_presets.cfg prints the body to copy; it must keep bundle_identifier="digital.lumara.bulletheaven" (the STORE id; Dev ids come from the xcodebuild BH_APP_ID override), short_version / version equal to the other Apple presets (1.1 / 328 on 2026-10-15; copy the live values) so apple_short_version and bump-build.sh agree, shader_baker/enabled=true, and export_project_only=true. Then verify: grep -c '^application/min_ios_version="17.0"' platform/tvos/export_presets.cfg = 2.)
- Step 4:
scripts/bump-build.sh: header comment lines becomeBUILD += 1, write it into all nine presets (7 Apple + 2 Android), print Nandwrite the CURRENT BUILD into all nine presets (no increment); the count check becomes:
[ "$(grep -c "^application/version=\"$new\"" "$P")" = 7 ] || { echo "::error:: expected 7 Apple presets at version $new" >&2; exit 1; }-
Step 5: Verify.
bash scripts/bump-build.sh --sync(no increment; must print the current BUILD and leavegit diff sim/constants.gdempty),node --test scripts/test/bump-build-whats-new.test.mjs(5/5),-gselect=test_release_presets.gd(all pass, 16 tests). -
Step 6: Ripple “5 Apple presets” -> 7. Edit by TEXT (line numbers drift; verified live 2026-10-15):
scripts/release/release.sh:8(“all seven presets” -> “all nine presets”),docs/reference/builds-and-releases.md:10(“all 5 Apple presets’” -> “all 7 Apple presets’”),docs/architecture/dev-tools.md:272(“five Apple presets” -> “seven Apple presets”),.claude/skills/bh-appstore-release/SKILL.md(:18 “all 5 Apple presets’” -> “all 7 Apple presets’”; :41 “all 5 Apple presets == BUILD” -> “all 7 Apple presets == BUILD” and “all five Apple presets” -> “all seven Apple presets”),.claude/skills/bh-merge-reviewed-branch/SKILL.md(:106 “(5 Apple presets: iOS, iOS-Dev, tvOS, tvOS-Dev, macOS)” -> “(7 Apple presets: iOS, iOS-Dev, tvOS, tvOS-Dev, macOS, iOS-Clip, iOS-Clip-Dev)”). The earlier plan draft had these two skill files’ targets swapped. Re-grep:/usr/bin/grep -rn "5 Apple presets\|five Apple presets\|seven presets" docs scripts .claude/skills | /usr/bin/grep -v superpowers→ only historical docs (docs/RELEASE-1.1.md,docs/overnight-swarm-report-2026-10-14.md) remain; leave those. Thennode scripts/port-claude-to-factory.mjsandnode --test scripts/test/skills-parity.test.mjs→ pass. -
Step 7: Commit all of the above incl.
.factory/—build(ios): iOS-Clip + iOS-Clip-Dev export presets (feature app_clip).
Task 4: ClipRewards handoff file
Section titled “Task 4: ClipRewards handoff file”Files:
- Create:
meta/clip_rewards.gd,tests/fake_dc_app_clip.gd,tests/test_clip_rewards.gd
Interfaces:
-
Produces:
class_name ClipRewards extends RefCounted;const FILE := "clip_rewards.json",const NATIVE_SINGLETON := "DcAppClip",const SKIN_ID := "beta";static func group_dir() -> String;static func present_install_overlay() -> bool;static func path_in(dir: String) -> String;static func write_earned(dir: String, now_unix: int) -> bool;static func read(dir: String) -> Dictionary;static func consume(dir: String, grant: Callable, now_unix: int) -> bool(callsgrant.call(SKIN_ID)). Test doubleclass_name FakeDcAppClip extends Objectwithvar container: String,var overlay_calls: int. -
Consumes (at runtime, Task 5): singleton
DcAppClipwithgroup_container_path() -> Stringandpresent_install_overlay() -> bool. -
Step 1: Write the test double
tests/fake_dc_app_clip.gd:
class_name FakeDcAppClipextends Object
# Test double for the iOS DcAppClip singleton (dc_haptics GDExtension; absent headless). An Object like# the real one: Godot errors on registering a RefCounted singleton, so the owner must free() it.
var container := ""var overlay_calls := 0
func group_container_path() -> String: return container
func present_install_overlay() -> bool: overlay_calls += 1 return true- Step 2: Write the failing test
tests/test_clip_rewards.gd:
extends GutTest
# App Clip -> full app handoff file (meta/clip_rewards.gd), through a temp dir and an injected grant.
var _dir := ""var _fake: FakeDcAppClip = nullvar _granted: Array[String] = []
func before_each() -> void: _dir = OS.get_temp_dir().path_join("bh-gut-cliprewards-%d" % OS.get_process_id()) DirAccess.make_dir_recursive_absolute(_dir) if FileAccess.file_exists(ClipRewards.path_in(_dir)): DirAccess.remove_absolute(ClipRewards.path_in(_dir)) _granted.clear()
func after_each() -> void: if _fake != null: Engine.unregister_singleton(ClipRewards.NATIVE_SINGLETON) _fake.free() _fake = null
func _grant(id: String) -> void: _granted.append(id)
func test_clip_writes_the_earned_flag() -> void: assert_true(ClipRewards.write_earned(_dir, 1760000000)) var d := ClipRewards.read(_dir) assert_eq(d.get("beta_skin"), true) assert_eq(int(d.get("earned_at", 0)), 1760000000)
func test_full_app_grants_once_and_marks_consumed() -> void: ClipRewards.write_earned(_dir, 1760000000) assert_true(ClipRewards.consume(_dir, _grant, 1760000100)) assert_eq(_granted, ["beta"] as Array[String]) assert_eq(int(ClipRewards.read(_dir).get("consumed_at", 0)), 1760000100) assert_false(ClipRewards.consume(_dir, _grant, 1760000200), "a consumed file grants nothing") assert_eq(_granted.size(), 1)
func test_a_replayed_clip_rewrites_and_regrants_harmlessly() -> void: ClipRewards.write_earned(_dir, 1) ClipRewards.consume(_dir, _grant, 2) ClipRewards.write_earned(_dir, 3) # Fight again -> a fresh end state assert_true(ClipRewards.consume(_dir, _grant, 4)) assert_eq(_granted, ["beta", "beta"] as Array[String], "MetaState.grant_skin is idempotent")
func test_missing_malformed_or_false_files_grant_nothing() -> void: assert_false(ClipRewards.consume(_dir, _grant, 1), "no file") var f := FileAccess.open(ClipRewards.path_in(_dir), FileAccess.WRITE) f.store_string("{not json") f.close() assert_false(ClipRewards.consume(_dir, _grant, 1), "malformed") f = FileAccess.open(ClipRewards.path_in(_dir), FileAccess.WRITE) f.store_string(JSON.stringify({"beta_skin": false, "earned_at": 1})) f.close() assert_false(ClipRewards.consume(_dir, _grant, 1), "beta_skin false") assert_true(_granted.is_empty())
func test_no_container_is_a_no_op() -> void: assert_eq(ClipRewards.group_dir(), "", "no DcAppClip singleton headless") assert_false(ClipRewards.write_earned("", 1)) assert_false(ClipRewards.consume("", _grant, 1)) assert_false(ClipRewards.present_install_overlay())
func test_native_bridge_is_read_through_the_singleton() -> void: _fake = FakeDcAppClip.new() _fake.container = _dir Engine.register_singleton(ClipRewards.NATIVE_SINGLETON, _fake) assert_eq(ClipRewards.group_dir(), _dir) assert_true(ClipRewards.present_install_overlay()) assert_eq(_fake.overlay_calls, 1)-
Step 3: Run it to verify it fails (after
--import). Expected:ClipRewardsnot declared. -
Step 4: Create
meta/clip_rewards.gd:
class_name ClipRewardsextends RefCounted
# App Clip -> full app handoff (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md,# "Native bridge"). The clip writes <App Group container>/clip_rewards.json at its end state, win or# lose: {"beta_skin": true, "earned_at": <unix>}. The full app reads it on launch, grants the Beta skin# into MetaState and marks the file consumed ("consumed_at"). Granting is idempotent on both sides# (MetaState.grant_skin), so a crash between grant and mark only re-grants harmlessly.## The container comes from the DcAppClip singleton (platform/tvos/addons/dc_haptics, iOS only); it# reads the App Group id from the Info.plist key BHAppGroup, which scripts/release/add-app-clip-target.sh# writes into BOTH targets. Anywhere else group_dir() is "" and every call is a no-op. Tests pass their# own dir and grant Callable, never the real container or user://meta.json.
const FILE := "clip_rewards.json"const NATIVE_SINGLETON := "DcAppClip"const SKIN_ID := "beta"
static func _native() -> Object: if not Engine.has_singleton(NATIVE_SINGLETON): return null return Engine.get_singleton(NATIVE_SINGLETON)
# The shared App Group container ("" off iOS, or when the entitlement / BHAppGroup key is missing).static func group_dir() -> String: var n := _native() if n == null or not n.has_method("group_container_path"): return "" return String(n.call("group_container_path"))
# Apple's SKOverlay App Clip install card. True when it was asked to show (clip builds only).static func present_install_overlay() -> bool: var n := _native() if n == null or not n.has_method("present_install_overlay"): return false return bool(n.call("present_install_overlay"))
static func path_in(dir: String) -> String: return dir.path_join(FILE)
static func write_earned(dir: String, now_unix: int) -> bool: return dir != "" and _write(dir, {"beta_skin": true, "earned_at": now_unix})
static func read(dir: String) -> Dictionary: if dir == "" or not FileAccess.file_exists(path_in(dir)): return {} var j := JSON.new() # an instance parse: a malformed file returns an error code, no engine error if j.parse(FileAccess.get_file_as_string(path_in(dir))) != OK: return {} return j.data if j.data is Dictionary else {}
# True when the file held an unconsumed reward: `grant` was called with SKIN_ID and the file now# carries consumed_at. A missing, malformed, already-consumed or beta_skin:false file grants nothing.static func consume(dir: String, grant: Callable, now_unix: int) -> bool: var d := read(dir) if d.is_empty() or not (d.get("beta_skin", false) is bool and bool(d["beta_skin"])) or d.has("consumed_at"): return false grant.call(SKIN_ID) d["consumed_at"] = now_unix _write(dir, d) return true
static func _write(dir: String, d: Dictionary) -> bool: var f := FileAccess.open(path_in(dir), FileAccess.WRITE) if f == null: return false f.store_string(JSON.stringify(d)) f.close() return true(JSON.parse_string is deliberately NOT used: on a malformed file it pushes an engine error that GUT reports as an unexpected error.)
-
Step 5: Run the test to verify it passes (6/6), then the full suite.
-
Step 6: Commit
meta/clip_rewards.gd tests/fake_dc_app_clip.gd tests/test_clip_rewards.gd—feat(meta): ClipRewards App Clip handoff file.
Task 5: DcAppClip native bridge in the dc_haptics GDExtension
Section titled “Task 5: DcAppClip native bridge in the dc_haptics GDExtension”Follows commit 385915bc (DcStoreReview): a new class compiled into the same library, registered as a singleton at SCENE level, unregistered in reverse order. SConstruct already globs src/*.mm and links Foundation/StoreKit (+UIKit on iOS); build.sh’s stamp hashes src/*.h src/*.mm, so a new source file rebuilds automatically. No SConstruct or build.sh edit is needed.
Files:
- Create:
platform/tvos/addons/dc_haptics/src/dc_app_clip.h,platform/tvos/addons/dc_haptics/src/dc_app_clip.mm - Modify:
platform/tvos/addons/dc_haptics/src/register_types.mm
Interfaces:
-
Produces: singleton
"DcAppClip"withgroup_container_path() -> String(reads Info.plistBHAppGroup, “” when missing/unprovisioned/off iOS) andpresent_install_overlay() -> bool(SKOverlay withSKOverlayAppClipConfiguration, bottom position; false in the full app, which has noNSAppClipkey, off iOS, or with no foreground scene). Consumed byClipRewards(Task 4). -
Step 1: Create
src/dc_app_clip.h:
// DcAppClip: the App Clip <-> full app bridge (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md,// "Native bridge"). Built into the DcHaptics GDExtension library like DcStoreReview, so it reuses that// addon's build and export wiring; registered in BOTH the full app and the clip. GDScript reaches it only// through Engine.get_singleton("DcAppClip"); meta/clip_rewards.gd owns the handoff file format.#pragma once
#include <godot_cpp/classes/object.hpp>#include <godot_cpp/variant/string.hpp>
namespace godot {
class DcAppClip : public Object { GDCLASS(DcAppClip, Object);
protected: static void _bind_methods();
public: // Absolute path of the shared App Group container named by Info.plist BHAppGroup // (written by scripts/release/add-app-clip-target.sh), or "" when missing / not entitled / not iOS. String group_container_path(); // Apple's SKOverlay App Clip install card over the active scene. True when it was asked to show; // false in the full app (no clip config), off iOS, or with no foreground-active scene. bool present_install_overlay();};
} // namespace godot- Step 2: Create
src/dc_app_clip.mm:
#import <Foundation/Foundation.h>#import <StoreKit/StoreKit.h>#include <TargetConditionals.h>#if TARGET_OS_IOS#import <UIKit/UIKit.h>#endif
#include "dc_app_clip.h"
#include <godot_cpp/core/class_db.hpp>
using namespace godot;
void DcAppClip::_bind_methods() { ClassDB::bind_method(D_METHOD("group_container_path"), &DcAppClip::group_container_path); ClassDB::bind_method(D_METHOD("present_install_overlay"), &DcAppClip::present_install_overlay);}
String DcAppClip::group_container_path() { NSString *group = [NSBundle.mainBundle objectForInfoDictionaryKey:@"BHAppGroup"]; if (![group isKindOfClass:[NSString class]] || group.length == 0) { return String(); } NSURL *url = [NSFileManager.defaultManager containerURLForSecurityApplicationGroupIdentifier:group]; if (url == nil) { return String(); // the App Group entitlement is missing or not provisioned for this bundle id } return String::utf8(url.path.UTF8String);}
bool DcAppClip::present_install_overlay() {#if TARGET_OS_IOS if ([NSBundle.mainBundle objectForInfoDictionaryKey:@"NSAppClip"] == nil) { return false; // only the clip offers its parent app; the full app never shows this } UIWindowScene *scene = nil; for (UIScene *s in UIApplication.sharedApplication.connectedScenes) { if (s.activationState == UISceneActivationStateForegroundActive && [s isKindOfClass:[UIWindowScene class]]) { scene = (UIWindowScene *)s; break; } } if (scene == nil) { return false; } SKOverlayAppClipConfiguration *cfg = [[SKOverlayAppClipConfiguration alloc] initWithPosition:SKOverlayPositionBottom]; SKOverlay *overlay = [[SKOverlay alloc] initWithConfiguration:cfg]; [overlay presentInScene:scene]; return true;#else return false;#endif}- Step 3: Register it in
src/register_types.mm. Add#include "dc_app_clip.h"after#include "dc_store_review.h"; addstatic DcAppClip *dc_app_clip_singleton = nullptr;after the DcStoreReview static; at the end ofinitialize_dc_haptics(after the DcStoreReview registration):
GDREGISTER_CLASS(DcAppClip); dc_app_clip_singleton = memnew(DcAppClip); Engine::get_singleton()->register_singleton("DcAppClip", dc_app_clip_singleton);and at the START of uninitialize_dc_haptics’s body (after the level check, before the DcStoreReview block — reverse registration order):
if (dc_app_clip_singleton != nullptr) { Engine::get_singleton()->unregister_singleton("DcAppClip"); memdelete(dc_app_clip_singleton); dc_app_clip_singleton = nullptr; }-
Step 4: Build.
bash platform/tvos/addons/dc_haptics/build.sh > "$TMPDIR/bh-dch.log" 2>&1; echo $?; grep -c "warning" "$TMPDIR/bh-dch.log"; tail -2 "$TMPDIR/bh-dch.log". Expected: exit 0,Compiling shared src/dc_app_clip.mmfor all four configs, 0 warnings,dc_haptics: built. Check the symbols:strings platform/tvos/addons/dc_haptics/out/ios/libdc_haptics.ios.release | /usr/bin/grep -E "^(DcAppClip|BHAppGroup|group_container_path|present_install_overlay)$"→ all four lines. (Verified on the plan prototype 2026-10-15: clean build, 0 warnings.) -
Step 5: Verify registration on macOS (no device). In a scratch dir: copy
platform/tvos/addons/dc_haptics/binanddc_haptics.gdextensioninto<scratch>/addons/dc_haptics/, write<scratch>/project.godotcontaining[application]\nconfig/name="x", and<scratch>/check.gd:
extends SceneTreefunc _init() -> void: print("DcAppClip singleton: ", Engine.has_singleton("DcAppClip")) if Engine.has_singleton("DcAppClip"): var n: Object = Engine.get_singleton("DcAppClip") print("path=[", n.call("group_container_path"), "] overlay=", n.call("present_install_overlay")) print("DcStoreReview: ", Engine.has_singleton("DcStoreReview")) quit()Run cd <scratch> && godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s res://check.gd 2>&1 | tail -4. Expected: DcAppClip singleton: true, path=[] overlay=false, DcStoreReview: true (verified with stock Godot 4.7.1 on the prototype).
- Step 6: Commit the three source files —
feat(native): DcAppClip bridge (App Group path + SKOverlay) in dc_haptics. (bin/,out/are gitignored.)
Task 6: ClipFlow UI (countdown, hint, end card)
Section titled “Task 6: ClipFlow UI (countdown, hint, end card)”Files:
- Create:
ui/clip_flow.gd,tests/test_clip_flow.gd
Interfaces:
-
Consumes:
ClipDirector.Outcome(Task 1); existingGlassPanel,NeonTheme,TokenBind,Tokens,AudioManager.BOSS_MUSIC,AreaDefs.DIMENSION_IDS,ShipBonuses.ORDER. -
Produces:
class_name ClipFlow extends CanvasLayer; signalsfight_again_requested,install_requested; constsCOUNTDOWN_S := 3.0,HINT_S := 5.0,BTN_W,WIN_LINE,LOSE_LINE,SUB_LINE,HINT_LINE;var install_button: Button,var fight_button: Button;static func highlights() -> Array[String];static func headline_for(outcome: int) -> String;func start(),func countdown_text() -> String,func freezes_sim() -> bool,func hint_showing() -> bool,func ended() -> bool,func advance(delta: float),func show_end(outcome: int),func headline_text() -> String,func fit_to(area: Rect2),func card_rect() -> Rect2. -
Step 1: Write the failing test
tests/test_clip_flow.gd:
extends GutTest
# ClipFlow (App Clip chrome): countdown -> hint -> end card copy, the highlights counted from the game's# own tables, and the end card's fit at the real device profiles (CLAUDE.md "UI fit tests").
const DEVICES: Array[String] = ["tv", "iphone", "ipad"]var _saved_platform := ""
func before_all() -> void: Tokens.ensure_loaded() _saved_platform = LiveTokens.platform()
func after_each() -> void: Platform.force_safe_insets_pt = Vector4(-1, -1, -1, -1) Platform.force_screen_scale = 0.0 LiveTokens.set_platform(_saved_platform)
func _flow() -> ClipFlow: var f := ClipFlow.new() add_child_autofree(f) f.set_process(false) # the test drives advance() itself return f
func test_countdown_freezes_then_releases_the_sim() -> void: var f := _flow() f.start() assert_eq(f.countdown_text(), "3") assert_true(f.freezes_sim()) f.advance(1.0) assert_eq(f.countdown_text(), "2") f.advance(2.0) assert_false(f.freezes_sim(), "countdown over: the run starts") assert_true(f.hint_showing(), "the controls hint follows the countdown")
func test_hint_hides_after_hint_s() -> void: var f := _flow() f.start() f.advance(ClipFlow.COUNTDOWN_S) f.advance(ClipFlow.HINT_S - 0.1) assert_true(f.hint_showing()) f.advance(0.2) assert_false(f.hint_showing())
func test_end_card_copy_win_and_lose() -> void: var f := _flow() f.show_end(ClipDirector.Outcome.WIN) assert_eq(f.headline_text(), "Warden destroyed. Beta skin unlocked.") assert_true(f.ended()) assert_true(f.freezes_sim(), "the card holds the sim") f.show_end(ClipDirector.Outcome.LOSE) assert_eq(f.headline_text(), "So close. Beta skin unlocked anyway.")
func test_highlights_come_from_the_game_tables() -> void: var h := ClipFlow.highlights() assert_eq(h.size(), 3) assert_eq(h[0], "%d more bosses" % (AudioManager.BOSS_MUSIC.size() - 1)) assert_eq(h[1], "%d Dimensions to warp through" % AreaDefs.DIMENSION_IDS.size()) assert_eq(h[2], "%d hulls and a permanent shop" % ShipBonuses.ORDER.size())
func test_start_after_end_resets_for_fight_again() -> void: var f := _flow() f.show_end(ClipDirector.Outcome.LOSE) f.start() assert_false(f.ended()) assert_eq(f.countdown_text(), "3")
func test_buttons_emit_their_signals() -> void: var f := _flow() watch_signals(f) f.install_button.pressed.emit() f.fight_button.pressed.emit() assert_signal_emitted(f, "install_requested") assert_signal_emitted(f, "fight_again_requested")
func test_end_card_fits_every_device() -> void: for dev: String in DEVICES: var pr := ShopLayoutFixture.profile(dev) var d: Dictionary = ShopLayoutFixture.DEVICES[dev] Platform.force_safe_insets_pt = d["insets_pt"] Platform.force_screen_scale = float(d["scale"]) LiveTokens.set_platform("tv" if dev == "tv" else _saved_platform) TokenBind.flush_now() var vp := SubViewport.new() vp.size = Vector2i((d["px"] as Vector2).round()) vp.size_2d_override = Vector2i((pr["vp"] as Vector2).round()) vp.size_2d_override_stretch = true add_child_autofree(vp) var f := ClipFlow.new() f.set_process(false) vp.add_child(f) await get_tree().process_frame f.show_end(ClipDirector.Outcome.WIN) await get_tree().process_frame await get_tree().process_frame var view := Rect2(Vector2.ZERO, pr["vp"] as Vector2) var r := f.card_rect() var shown := Rect2(f._center.position + (r.position - f._center.position) * f._center.scale, r.size * f._center.scale) assert_true(view.encloses(shown), "%s: card %s inside %s" % [dev, shown, view]) assert_true(f.install_button.size.x >= ClipFlow.BTN_W, "%s: button not squeezed" % dev)-
Step 2: Run it to verify it fails. Expected:
ClipFlownot declared. -
Step 3: Create
ui/clip_flow.gd:
class_name ClipFlowextends CanvasLayer
# App Clip run chrome (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md, "Player# experience"): the 3-2-1 countdown, one controls hint line for the first HINT_S seconds, and the end card# (win or lose: the Beta skin line, the "continue playing" highlights, Apple's install overlay, Fight# again). main.gd creates it only in clip mode and skips sim ticks while freezes_sim() is true. Render# side only: it reads ClipDirector.outcome, never writes the sim.
signal fight_again_requestedsignal install_requested
const COUNTDOWN_S := 3.0const HINT_S := 5.0const EDGE := 8.0const BTN_W := 220.0const BTN_H := 48.0const BTN_GLOW_PAD := GlassPanel.GLOW_REACH + GlassPanel.GLOW_LEN * 2.0
# Copy. WIN/LOSE lines are the spec's verbatim; HINT and the sub line are CHRIS-REVIEWABLE copy.const WIN_LINE := "Warden destroyed. Beta skin unlocked."const LOSE_LINE := "So close. Beta skin unlocked anyway."const SUB_LINE := "Your Beta skin is waiting in the full game."const HINT_LINE := "Move to dodge. Your weapons fire on their own."
var _countdown_left := 0.0var _hint_left := 0.0var _ended := falsevar _count_label: Labelvar _hint_label: Labelvar _veil: ColorRectvar _center: CenterContainervar _card: GlassPanelvar _headline: Labelvar install_button: Buttonvar fight_button: Button
# "Continue playing" highlights, counted from the game's own tables so the card never overclaims# (marketing veracity rule): bosses beyond the Warden that a run can meet, Dimensions, hulls.static func highlights() -> Array[String]: var bosses := AudioManager.BOSS_MUSIC.size() - 1 # one theme per boss a run meets; minus the Warden return [ "%d more bosses" % bosses, "%d Dimensions to warp through" % AreaDefs.DIMENSION_IDS.size(), "%d hulls and a permanent shop" % ShipBonuses.ORDER.size(), ] as Array[String]
static func headline_for(outcome: int) -> String: return WIN_LINE if outcome == ClipDirector.Outcome.WIN else LOSE_LINE
func _init() -> void: name = "ClipFlow" layer = 30 _count_label = Label.new() _count_label.set_anchors_preset(Control.PRESET_FULL_RECT) _count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _count_label.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(_count_label, &"font_size", &"font.5xl") TokenBind.theme_color(_count_label, &"font_color", &"text.bright") _count_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_count_label) _hint_label = Label.new() _hint_label.text = HINT_LINE _hint_label.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) _hint_label.grow_horizontal = Control.GROW_DIRECTION_BOTH _hint_label.grow_vertical = Control.GROW_DIRECTION_BEGIN _hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _hint_label.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(_hint_label, &"font_size", &"font.base") TokenBind.theme_color(_hint_label, &"font_color", &"text.secondary") _hint_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_hint_label) _veil = ColorRect.new() _veil.set_anchors_preset(Control.PRESET_FULL_RECT) TokenBind.prop(_veil, &"color", func() -> Color: return Color(GlassPanel.veil_color(), 0.55)) _veil.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_veil) _center = CenterContainer.new() _center.set_anchors_preset(Control.PRESET_FULL_RECT) _center.theme = NeonTheme.get_theme() add_child(_center) _card = GlassPanel.new() _card.focus = 0.2 _card.margin = 24.0 _center.add_child(_card) var box := VBoxContainer.new() TokenBind.theme_constant(box, &"separation", func() -> int: return Tokens.space(3)) _card.add_child(box) _headline = _line(box, "", &"font.2xl", &"text.bright") _line(box, SUB_LINE, &"font.base", &"accent.main") for h: String in highlights(): _line(box, h, &"font.sm", &"text.body") var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER TokenBind.theme_constant(row, &"separation", func() -> int: return Tokens.space(4)) box.add_child(row) install_button = _button("Get Dark Cosmos", &"accent.main") install_button.pressed.connect(func() -> void: install_requested.emit()) row.add_child(install_button) fight_button = _button("Fight again", &"text.secondary") fight_button.pressed.connect(func() -> void: fight_again_requested.emit()) row.add_child(fight_button) _set_end_visible(false) _count_label.visible = false _hint_label.visible = false
func _line(parent: Node, text: String, size: StringName, color: StringName) -> Label: var l := Label.new() l.text = text l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER l.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(l, &"font_size", size) TokenBind.theme_color(l, &"font_color", color) parent.add_child(l) return l
# Same construction as ResultsPanel._glass_button: a clear GlassPanel plate behind a flat Button.func _button(text: String, accent: StringName) -> Button: var b := Button.new() b.text = text b.focus_mode = Control.FOCUS_ALL b.custom_minimum_size = Vector2(BTN_W, BTN_H) b.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(b, &"font_size", &"font.lg") TokenBind.theme_color(b, &"font_color", &"text.bright") for st in ["normal", "hover", "pressed", "focus"]: b.add_theme_stylebox_override(st, StyleBoxEmpty.new()) var skin := GlassPanel.new() TokenBind.prop(skin, &"accent", accent) skin.margin = 0.0 skin.glow_pad = BTN_GLOW_PAD skin.mouse_filter = Control.MOUSE_FILTER_IGNORE skin.show_behind_parent = true b.add_child(skin, false, Node.INTERNAL_MODE_FRONT) var fit := func() -> void: skin.position = Vector2.ZERO skin.size = b.size b.resized.connect(fit) fit.call() b.focus_entered.connect(func() -> void: skin.focus = 1.0) b.focus_exited.connect(func() -> void: skin.focus = 0.0) b.mouse_entered.connect(func() -> void: b.grab_focus()) return b
func _set_end_visible(on: bool) -> void: _veil.visible = on _center.visible = on
# A fresh clip run: countdown first (the sim waits), then the hint line.func start() -> void: _ended = false _countdown_left = COUNTDOWN_S _hint_left = 0.0 _set_end_visible(false) _hint_label.visible = false _count_label.visible = true _count_label.text = countdown_text()
func countdown_text() -> String: return str(int(ceil(_countdown_left))) if _countdown_left > 0.0 else ""
func freezes_sim() -> bool: return _countdown_left > 0.0 or _ended
func hint_showing() -> bool: return _hint_label.visible
func ended() -> bool: return _ended
func advance(delta: float) -> void: if _countdown_left > 0.0: _countdown_left = maxf(_countdown_left - delta, 0.0) _count_label.text = countdown_text() if _countdown_left == 0.0: _count_label.visible = false _hint_left = HINT_S _hint_label.visible = true elif _hint_left > 0.0: _hint_left = maxf(_hint_left - delta, 0.0) if _hint_left == 0.0: _hint_label.visible = false
func _process(delta: float) -> void: advance(delta)
func show_end(outcome: int) -> void: _ended = true _countdown_left = 0.0 _hint_left = 0.0 _count_label.visible = false _hint_label.visible = false _headline.text = headline_for(outcome) _set_end_visible(true) if is_inside_tree(): fit_to(get_viewport().get_visible_rect().grow(-EDGE)) install_button.grab_focus.call_deferred()
func headline_text() -> String: return _headline.text
# Scale the CenterContainer down where a canvas is too small (ResultsPanel.fit_to's rule).func fit_to(area: Rect2) -> void: var need := _card.get_combined_minimum_size() + Vector2.ONE * (GlassPanel.GLOW_PAD + EDGE) * 2.0 var k := clampf(minf(area.size.x / maxf(need.x, 1.0), area.size.y / maxf(need.y, 1.0)), 0.5, 1.0) _center.pivot_offset = _center.size * 0.5 _center.scale = Vector2(k, k) _center.position = area.get_center() - _center.size * 0.5
func card_rect() -> Rect2: return _card.get_global_rect()Copy note for the reviewer: the counts the card shows today are 3 more bosses, 7 Dimensions, 6 hulls (computed, not hard-coded). HINT_LINE, SUB_LINE and the “Get Dark Cosmos” label are Chris-reviewable.
-
Step 4: Run the test to verify it passes (7/7). Then
-gselect=test_ui_literal_ratchet.gd(pass) and the full suite. -
Step 5: Commit
ui/clip_flow.gd tests/test_clip_flow.gd—feat(ui): ClipFlow countdown, hint and end card.
Task 7: main.gd clip mode — boot route, run setup, end flow
Section titled “Task 7: main.gd clip mode — boot route, run setup, end flow”Files:
- Modify:
main.gd(var block ~line 83;_readybootmatch~605;boot_route~645;_on_mode_chosen~1107;_sim_freeze_reason~1401;_can_p2_join~1430;_new_run~1537 and ~1576;_physics_processtick loop ~2190;_post_tick~2316;_show_results_when_drawn~2425) - Create:
tests/test_clip_main.gd
Interfaces:
-
Consumes:
ClipDirector(Tasks 1-2),ClipRewards(Task 4),ClipFlow(Task 6), preset featureapp_clip(Task 3). -
Produces:
static func boot_route(tutorial_done, headless, prewarm_available, replay_autostart := false, replay_playback := false, app_clip := false) -> Stringreturning"clip_via_prewarm"/"clip"for clip builds; mode string"clip";var clip_flow: ClipFlow;func _setup_clip_run(),func _clip_holds() -> bool,func _end_clip_run(),func _clip_fight_again();const CLIP_MUSIC := "boss_warden"; telemetryrun_endreasons"clip_win"/"clip_loss"(mode"clip"). -
Step 1: Write the failing test
tests/test_clip_main.gd:
extends GutTest
# The App Clip run through the real main: boot routing, the clip run's setup, the countdown freeze,# and a loss ending in the Beta-skin handoff + the clip end card (not the Survival results).
const Main := preload("res://main.gd")var _dir := ""var _fake: FakeDcAppClip
func before_each() -> void: _dir = OS.get_temp_dir().path_join("bh-gut-clipmain-%d" % OS.get_process_id()) DirAccess.make_dir_recursive_absolute(_dir) DirAccess.remove_absolute(ClipRewards.path_in(_dir)) _fake = FakeDcAppClip.new() _fake.container = _dir Engine.register_singleton(ClipRewards.NATIVE_SINGLETON, _fake)
func after_each() -> void: if Engine.has_singleton(ClipRewards.NATIVE_SINGLETON): Engine.unregister_singleton(ClipRewards.NATIVE_SINGLETON) _fake.free() DirAccess.remove_absolute(ClipRewards.path_in(_dir)) Tunables.reset()
func test_boot_route_clip() -> void: assert_eq(Main.boot_route(true, false, true, false, false, true), "clip_via_prewarm") assert_eq(Main.boot_route(false, false, false, false, false, true), "clip", "even with the tutorial unfinished") assert_eq(Main.boot_route(true, true, true, false, false, true), "run", "headless still auto-runs") assert_eq(Main.boot_route(true, false, true), "menu", "the full app is unchanged")
func _boot_clip() -> Node: var m: Node = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("clip") return m
func test_clip_run_attaches_the_director_and_is_human_flown() -> void: var m := await _boot_clip() assert_not_null(m.sim.clip_director) assert_not_null(m.clip_flow) assert_false(m._auto, "the clip is flown by the player, never the attract autopilot") assert_true(m._fresh_save_run) assert_eq(m._sim_freeze_reason(), "clip", "the countdown holds the sim") m.clip_flow.advance(ClipFlow.COUNTDOWN_S) assert_eq(m._sim_freeze_reason(), "")
func test_loss_writes_the_handoff_and_shows_the_clip_card() -> void: var m := await _boot_clip() m.clip_flow.advance(ClipFlow.COUNTDOWN_S) m.sim.player.dev_invuln = false m.sim.player.hp = 0.0 m._physics_process(1.0 / 60.0) assert_eq(m.sim.clip_director.outcome, ClipDirector.Outcome.LOSE) assert_true(m._clip_ended) assert_not_null(m.death_sequence, "a loss still plays the explosion") assert_eq(ClipRewards.read(_dir).get("beta_skin"), true, "the Beta skin is earned on a loss too") assert_eq(_fake.overlay_calls, 1, "Apple's install overlay is offered at the end state") m._show_results_when_drawn() await get_tree().process_frame assert_true(m.clip_flow.ended()) assert_eq(m.clip_flow.headline_text(), ClipFlow.LOSE_LINE) assert_false(m.results.visible, "the Survival results never show in the clip")
func test_fight_again_starts_a_fresh_clip_run() -> void: var m := await _boot_clip() m.clip_flow.show_end(ClipDirector.Outcome.LOSE) var old_sim = m.sim m._clip_fight_again() assert_ne(m.sim, old_sim) assert_not_null(m.sim.clip_director) assert_eq(m._sim_freeze_reason(), "clip", "a new countdown") assert_false(m._clip_ended)-
Step 2: Run it to verify it fails. Expected:
boot_routetakes at most 5 arguments /clip_flownot found. -
Step 3: Edit
main.gd(each block below is exact; anchor lines quoted):
# (a) var block, directly after `var _story_won: bool = false ...`:# App Clip (spec 2026-10-14-app-clip-warden-design.md): the clip's countdown / hint / end card, created on# the first clip run (null in the full app), and a latch so a run's end state is handled once.var clip_flow: ClipFlow = nullvar _clip_ended: bool = falseconst CLIP_MUSIC := "boss_warden" # the clip ships ONE music track (iOS-Clip preset exclude_filter)# (b) _ready(): the boot `match boot_route(...)` call gains a 6th argument. Change its last argument line# not _replay_play.is_empty() and not _capturing):# to not _replay_play.is_empty() and not _capturing, OS.has_feature("app_clip")):# and add two arms directly after the `"run":` arm: "clip_via_prewarm": _boot_prewarm_mode = "clip" _booting_prewarm = true PrewarmPass.boot_then(self, _finish_boot_prewarm) "clip": _on_mode_chosen("clip")# (c) boot_route(): signature and first branch after the headless checkstatic func boot_route(tutorial_done: bool, headless: bool, prewarm_available: bool, replay_autostart: bool = false, replay_playback: bool = false, app_clip: bool = false) -> String: if headless: return "run" if app_clip: # the App Clip build (feature tag app_clip): straight into the Warden run, no menu/tutorial return "clip_via_prewarm" if prewarm_available else "clip" # ... existing branches unchanged# (d) _on_mode_chosen(): the music block and the run start become if mode == "survival" or mode == "tutorial": _start_gameplay_music() elif mode == "clip": audio.play_music(CLIP_MUSIC) else: audio.stop_music() _mode = mode _new_run(mode == "clip") # the clip is always flown by the player, never the attract autopilot _maybe_show_survival_intro()# (e) _sim_freeze_reason(): directly before its final `return ""` if _clip_holds(): return "clip" # App Clip: the 3-2-1 countdown, or the end card, is up# (f) _can_p2_join(): return not V01_LOCK_COOP and mode != "tutorial" and mode != "clip" and sim.pilots.size() < 2# (g) new functions, directly after _can_p2_join():# App Clip run setup (_new_run, clip mode): Survival rules on Home with a ClipDirector attached, and# the countdown. The ClipFlow layer is created once and survives Fight again.func _setup_clip_run() -> void: sim.enable_survival() sim.clip_director = ClipDirector.new() _clip_ended = false if clip_flow == null: clip_flow = ClipFlow.new() clip_flow.fight_again_requested.connect(_clip_fight_again) clip_flow.install_requested.connect(func() -> void: ClipRewards.present_install_overlay()) add_child(clip_flow) clip_flow.start()
func _clip_holds() -> bool: return _mode == "clip" and clip_flow != null and clip_flow.freezes_sim()
# Win or lose, once per run: write the Beta-skin handoff into the App Group, report the run end, and# (on a win) show the card now. Apple's install overlay is offered at the same moment.func _end_clip_run() -> void: _clip_ended = true ClipRewards.write_earned(ClipRewards.group_dir(), int(Time.get_unix_time_from_system())) remote_config.mark_run_ended() var won := sim.clip_director.outcome == ClipDirector.Outcome.WIN _send_run_end("clip_win" if won else "clip_loss") audio.stop_music() if won: audio.victory() clip_flow.show_end(ClipDirector.Outcome.WIN) ClipRewards.present_install_overlay()
func _clip_fight_again() -> void: audio.play_music(CLIP_MUSIC) _new_run(true)# (h) _new_run(): the _fresh_save_run assignment gains a clip clause _fresh_save_run = fresh_save_wanted(_learning_this_run, replay_human_wanted(_mode, net_start, not _replay_play.is_empty() or _capturing), _replay_play) \ or _mode == "clip" # the clip flies a fresh save on the default hull (Cobalt)# and directly after# if _mode == "survival" and not net_start: # NetCoopRun.build_sim already did it, on every peer# sim.enable_survival() if _mode == "clip": _setup_clip_run()# (i) _physics_process(): the tick loop's first line if sim.game_over or (sim.clip_director != null and sim.clip_director.ended()): break# (j) _post_tick(): directly BEFORE `if sim.game_over:` if _mode == "clip" and sim.clip_director != null and sim.clip_director.ended() and not _clip_ended: _end_clip_run() if not sim.game_over: return # WIN: no death sequence, the end card is already up # LOSE returns before the `if sim.game_over:` block below, so repeat its two clean-ups or the frozen # low-HP red veil and any showing dialogue box stay behind the end card. if screen_fx != null: screen_fx.set_suppressed(true) if dialogue_box != null: dialogue_box.clear() _begin_death_sequence() # LOSE: the ship explodes, then _show_results_when_drawn shows the clip card return# (k) _show_results_when_drawn(): directly BEFORE `results.show_results(...)` if _mode == "clip" and clip_flow != null: clip_flow.show_end(sim.clip_director.outcome if sim.clip_director != null else ClipDirector.Outcome.LOSE) returnThe clip never banks gold, never runs ReviewPrompt, never offers a Dimension, and never shows the results panel. _send_run_end already pushes run_end with the current _mode, so clip runs land in telemetry as mode "clip".
-
Step 4: Run the test to verify it passes (4/4), then
-gselect=test_prewarm_boot.gd,-gselect=test_ui_literal_ratchet.gdand the full suite (check-test-count.sh). Expected: 0 failing; the existingboot_routecallers are unaffected (new parameter defaults to false). -
Step 5: Windowed smoke run (no device). Godot has no command-line flag to add a feature tag, so: in the worktree, temporarily change the boot call site’s last argument from
OS.has_feature("app_clip")totrue, runperl -e 'alarm 120; exec @ARGV' godot --path . --rendering-method mobile > "$TMPDIR/bh-clip-smoke.log" 2>&1and watch: “3-2-1”, the hint line, the swarm, the Warden at ~45 s, the end card (die once with the dev console or let the Warden kill you). Thengit checkout -- main.gdis WRONG (it drops the task’s edits): revert only that argument by hand,git diff main.gd | grep -c 'app_clip")'must show the call site restored, andgit checkout -- project.godot audio/bus_layout.tres.grep -E "SCRIPT ERROR|ERROR" "$TMPDIR/bh-clip-smoke.log" | head→ none new. Report what you saw. -
Step 6: Commit
main.gd tests/test_clip_main.gd—feat(main): App Clip mode (boot route, countdown, Warden end flow, handoff write).
Task 8: Full app consumes the handoff and grants the Beta skin
Section titled “Task 8: Full app consumes the handoff and grants the Beta skin”Files:
- Modify:
main.gd(_ready, directly aftermeta = MetaStore.load_state()and the lines that apply prefs from it, ~line 447-452) - Create:
tests/test_clip_handoff_main.gd
Interfaces:
-
Consumes:
MetaState.grant_skin(id: String) -> boolandMetaState.owned_skins: Array[String](Beta-skin plan),ClipRewards.consume(Task 4),MetaStore.save_state(meta)(existing; no-ops whenMetaStore.disk_io_enabledis false in tests),MetaStore.last_load_compatible(existing static,meta/meta_store.gd:41; false when a save exists but this build could not read it, andMetaStorethen never writes over it). -
Produces:
func _consume_clip_rewards(dir: String, now_unix: int = -1) -> boolon main. -
Step 1: Precheck the dependency.
git fetch origin && git grep -n "func grant_skin" origin/main -- sim/meta_state.gd && git grep -n "var owned_skins" origin/main -- sim/meta_state.gd. If either is missing: STOP this task, report “blocked on the Beta-skin plan (MetaState.grant_skin / owned_skins not on origin/main)”, and continue with Task 9. If present:git merge origin/maininto the branch first. -
Step 2: Write the failing test
tests/test_clip_handoff_main.gd:
extends GutTest
# The full app's side of the App Clip handoff: on launch, an unconsumed clip_rewards.json grants the# Beta skin into MetaState exactly once (MetaState.grant_skin is idempotent; the file is marked consumed).
var _dir := ""
func before_each() -> void: _dir = OS.get_temp_dir().path_join("bh-gut-cliphandoff-%d" % OS.get_process_id()) DirAccess.make_dir_recursive_absolute(_dir) DirAccess.remove_absolute(ClipRewards.path_in(_dir))
func after_each() -> void: DirAccess.remove_absolute(ClipRewards.path_in(_dir)) MetaStore.last_load_compatible = true
func _main() -> Node: var m: Node = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame return m
func test_an_earned_clip_reward_grants_the_beta_skin_once() -> void: var m := await _main() ClipRewards.write_earned(_dir, 100) assert_true(m._consume_clip_rewards(_dir, 200)) assert_has(m.meta.owned_skins, ClipRewards.SKIN_ID) assert_eq(int(ClipRewards.read(_dir).get("consumed_at", 0)), 200) assert_false(m._consume_clip_rewards(_dir, 300), "a consumed file grants nothing") assert_eq(m.meta.owned_skins.count(ClipRewards.SKIN_ID), 1)
func test_no_file_grants_nothing() -> void: var m := await _main() assert_false(m._consume_clip_rewards(_dir, 1)) assert_does_not_have(m.meta.owned_skins, ClipRewards.SKIN_ID)
func test_no_app_group_is_a_no_op() -> void: var m := await _main() assert_false(m._consume_clip_rewards("", 1), "headless / Android / tvOS: no container")
# A save this build could not read must never be overwritten by a clip grant (MetaStore's rule): the file# stays unconsumed so a later, compatible build still grants the skin.func test_an_unreadable_save_is_never_overwritten_and_the_reward_stays_unconsumed() -> void: var m := await _main() ClipRewards.write_earned(_dir, 100) MetaStore.last_load_compatible = false assert_false(m._consume_clip_rewards(_dir, 200)) assert_does_not_have(m.meta.owned_skins, ClipRewards.SKIN_ID) assert_eq(int(ClipRewards.read(_dir).get("consumed_at", 0)), 0, "left for a build that can read the save") MetaStore.last_load_compatible = true assert_true(m._consume_clip_rewards(_dir, 300), "granted once the save is readable")-
Step 3: Run it to verify it fails. Expected:
_consume_clip_rewardsnot found. -
Step 4: Implement in
main.gd. In_ready, after the block that applies prefs from the freshly loadedmeta(afterComfortFx.reduce = meta.reduce_fx):
# App Clip handoff (spec 2026-10-14-app-clip-warden-design.md): a Beta skin earned in the clip waits in # the shared App Group; grant it once. The clip itself never consumes its own file. if not OS.has_feature("app_clip"): _consume_clip_rewards(ClipRewards.group_dir())and a new function near _setup_clip_run:
# True when an unconsumed clip reward was granted (and the save written). dir "" = no App Group.# An unreadable save (MetaStore.last_load_compatible == false) is never overwritten: do nothing, leave the# file unconsumed for a later build that can read it.func _consume_clip_rewards(dir: String, now_unix: int = -1) -> bool: if not MetaStore.last_load_compatible: return false var now := now_unix if now_unix >= 0 else int(Time.get_unix_time_from_system()) var granted := ClipRewards.consume(dir, func(id: String) -> bool: return meta.grant_skin(id), now) if granted: MetaStore.save_state(meta) return granted-
Step 5: Run the test to verify it passes (4/4), then the full suite.
-
Step 6: Commit
main.gd tests/test_clip_handoff_main.gd—feat(main): full app grants the Beta skin from the App Clip handoff.
Task 9: Xcode clip target script (+ MetricKit array-form fix)
Section titled “Task 9: Xcode clip target script (+ MetricKit array-form fix)”The pbxproj editor is the Ruby xcodeproj gem 1.27.0 that ships inside Homebrew CocoaPods ($(brew --prefix cocoapods)/libexec/gems/xcodeproj-*). Chosen because it is already on the Mac (no system gem, no pip pbxproj), is the same library CocoaPods and fastlane use to edit Xcode projects, and edits Godot’s generated project in place; XcodeGen was rejected because it regenerates the whole project from a spec and would replace Godot’s export. The gem always re-serialises OTHER_LDFLAGS as an array, which breaks the current apply-ios-metrickit.sh (“no OTHER_LDFLAGS to weak-link MetricKit into”), so the fix is part of this task.
Files:
- Create:
platform/tvos/patches/ios-clip/add_clip_target.rb,platform/tvos/patches/ios-clip/DarkCosmosClip.entitlements,scripts/release/add-app-clip-target.sh(chmod +x) - Modify:
scripts/release/apply-ios-metrickit.sh(the last three lines, 32-34),scripts/test/apply-ios-metrickit.test.mjs(append),platform/tvos/patches/README.md,.gitignore
Interfaces:
-
Produces:
scripts/release/add-app-clip-target.sh [shell dir](defaultplatform/tvos/build-ios), idempotent; requires<shell>/DarkCosmosClip.pck; adds target + shared schemeDarkCosmosClip; parent and clip bundle ids derive from build settingBH_APP_ID(defaultdigital.lumara.bulletheaven; Dev passesBH_APP_ID=digital.lumara.bulletheaven.devon the xcodebuild command line, which moves BOTH targets;PRODUCT_BUNDLE_IDENTIFIER=on the command line must never be used on a shell with the clip target, it would give the clip the parent’s id). Info.plist keyBHAppGroup = group.$(BH_APP_ID)in both plists. -
Step 1: Write the failing MetricKit tests. Append to
scripts/test/apply-ios-metrickit.test.mjs(it already definesPBX,LDFLAGS,shell(),run(),count()):
// App Clip: scripts/release/add-app-clip-target.sh saves the pbxproj through the xcodeproj gem, which// re-serialises OTHER_LDFLAGS as an array (verified on a real build-ios copy, 2026-10-15).const ARRAY_LDFLAGS = [ "\t\t\t\tOTHER_LDFLAGS = (", "\t\t\t\t\t\"$(LD_CLASSIC_$(XCODE_VERSION_ACTUAL))\",", "\t\t\t\t\t\"-Wl,-U,_dc_haptics_init\",", "\t\t\t\t);",].join("\n");
function arrayShell(withMetricKit) { const dir = shell(); const flags = withMetricKit ? ARRAY_LDFLAGS.replace("\t\t\t\t);", "\t\t\t\t\t\"-weak_framework\",\n\t\t\t\t\tMetricKit,\n\t\t\t\t);") : ARRAY_LDFLAGS; const pb = PBX.split(LDFLAGS).join(flags); writeFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), pb); return dir;}
test("array-form OTHER_LDFLAGS (after the clip script) gain MetricKit once and stay idempotent", () => { const dir = arrayShell(false); run(dir); const pb = readFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), "utf8"); assert.equal(count(pb, "\t\t\t\t\tMetricKit,"), 2, "both array OTHER_LDFLAGS weak-link MetricKit"); assert.equal(count(pb, "\"-weak_framework\","), 2); run(dir); assert.equal(readFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), "utf8"), pb);});
test("array-form OTHER_LDFLAGS that already carry MetricKit are left alone", () => { const dir = arrayShell(true); const before = readFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), "utf8").replace(/dummy\.cpp/g, "dummy.mm").replace("sourcecode.cpp.cpp", "sourcecode.cpp.objcpp"); run(dir); assert.equal(readFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), "utf8"), before);});(If the file’s existing helper names differ from PBX / LDFLAGS / shell / run / count, read the top of the file and use its names; the prototype used exactly these on 2026-10-15.)
-
Step 2: Run them to verify they fail.
node --test scripts/test/apply-ios-metrickit.test.mjs→ the two new tests fail (“no OTHER_LDFLAGS to weak-link MetricKit into”). -
Step 3: Fix
scripts/release/apply-ios-metrickit.sh. Replace its last three lines:
sed -i '' '/OTHER_LDFLAGS = /{/MetricKit/!s/";$/ -weak_framework MetricKit";/;}' "$PB"$G -q 'OTHER_LDFLAGS = .*-weak_framework MetricKit' "$PB" || die "no OTHER_LDFLAGS to weak-link MetricKit into"[ "$($G 'OTHER_LDFLAGS = ' "$PB" | $G -vc MetricKit || true)" = 0 ] || die "an OTHER_LDFLAGS lacks -weak_framework MetricKit"with:
# Two serialisations: Godot's export writes OTHER_LDFLAGS as one quoted string; the xcodeproj gem# (scripts/release/add-app-clip-target.sh) rewrites it as a ( "...", ... ) array. Handle both.awk '/OTHER_LDFLAGS = \($/ { inarr=1; has=0; print; next }inarr && /^[[:space:]]*MetricKit,$/ { has=1 }inarr && /^[[:space:]]*\);$/ { if (!has) { ind=$0; sub(/\);.*$/, "", ind); print ind "\t\"-weak_framework\","; print ind "\tMetricKit," } inarr=0}/OTHER_LDFLAGS = ".*";$/ && !/MetricKit/ { sub(/";$/, " -weak_framework MetricKit\";") }{ print }' "$PB" > "$PB.tmp" && mv "$PB.tmp" "$PB"# Every OTHER_LDFLAGS (string or array) must carry it; none at all means the export changed shape.awk '/OTHER_LDFLAGS = \($/ { n++; inarr=1; has=0; next }inarr && /^[[:space:]]*MetricKit,$/ { has=1 }inarr && /^[[:space:]]*\);$/ { if (has) ok++; inarr=0; next }/OTHER_LDFLAGS = / { n++; if ($0 ~ /-weak_framework MetricKit/) ok++ }END { if (n == 0) exit 2; if (ok != n) exit 1 }' "$PB" || die "an OTHER_LDFLAGS lacks -weak_framework MetricKit (or there is none to weak-link into)"-
Step 4: Run
node --test scripts/test/apply-ios-metrickit.test.mjs→ all pass (10/10 on the prototype). -
Step 5: Create
platform/tvos/patches/ios-clip/DarkCosmosClip.entitlements:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>com.apple.developer.on-demand-install-capable</key> <true/> <key>com.apple.developer.parent-application-identifiers</key> <array> <string>$(AppIdentifierPrefix)$(BH_APP_ID)</string> </array> <key>com.apple.security.application-groups</key> <array> <string>group.$(BH_APP_ID)</string> </array></dict></plist>- Step 6: Create
platform/tvos/patches/ios-clip/add_clip_target.rb:
# Adds the Dark Cosmos App Clip target to an exported iOS shell (spec 2026-10-14-app-clip-warden-design.md,# "Xcode: scripted"). Run ONLY through scripts/release/add-app-clip-target.sh, which also writes the plists.# Idempotent: a second run finds every piece by name and only re-asserts settings.# Usage: ruby add_clip_target.rb <path/to/BulletHeaven.xcodeproj>require 'xcodeproj'
CLIP = 'DarkCosmosClip'.freezePARENT = 'BulletHeaven'.freezeSTORE_ID = 'digital.lumara.bulletheaven'.freezeCLIP_TYPE = 'com.apple.product-type.application.on-demand-install-capable'.freeze
path = ARGV.fetch(0)proj = Xcodeproj::Project.open(path)parent = proj.targets.find { |t| t.name == PARENT } or abort("add_clip_target: no #{PARENT} target in #{path}")abort('add_clip_target: tvOS shell, the App Clip is iOS only') if parent.sdk == 'appletvos'
ref = lambda do |p| proj.files.find { |f| f.path == p } or abort("add_clip_target: no file reference #{p} (did the export change?)")end
# Parent: the bundle id goes through BH_APP_ID so one command-line override (BH_APP_ID=...dev) moves# BOTH targets; PRODUCT_BUNDLE_IDENTIFIER on the command line would give the clip the parent's id.parent.build_configurations.each do |c| s = c.build_settings s['BH_APP_ID'] = STORE_ID s['PRODUCT_BUNDLE_IDENTIFIER'] = '$(BH_APP_ID)'end
clip = proj.targets.find { |t| t.name == CLIP }unless clip clip = proj.new_target(:application, CLIP, :ios, '17.0') # new_target adds a Foundation.framework link; the shell links nothing by SDK name, so drop it. clip.frameworks_build_phase.files.dup.each { |bf| bf.remove_from_project if bf.display_name == 'Foundation.framework' }endclip.product_type = CLIP_TYPEclip.product_reference.path = "#{CLIP}.app"clip.product_reference.name = "#{CLIP}.app"
parent_cfg = parent.build_configurations.map { |c| [c.name, c.build_settings] }.to_hclip.build_configurations.each do |c| base = parent_cfg.fetch(c.name) s = c.build_settings s.clear base.each { |k, v| s[k] = v.is_a?(Array) || v.is_a?(String) ? v.dup : v } s['BH_APP_ID'] = STORE_ID s['PRODUCT_BUNDLE_IDENTIFIER'] = '$(BH_APP_ID).Clip' s['PRODUCT_NAME'] = CLIP s['EXECUTABLE_NAME'] = CLIP s['INFOPLIST_FILE'] = "BulletHeaven/#{CLIP}-Info.plist" s['CODE_SIGN_ENTITLEMENTS'] = "BulletHeaven/#{CLIP}.entitlements" s['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0' s['SDKROOT'] = 'iphoneos'end
add_once = lambda do |phase, file_ref, settings = nil| next if phase.files_references.include?(file_ref) bf = phase.add_file_reference(file_ref, true) bf.settings = settings if settingsend
# Sources: the shell's dummy.mm (Godot's plugin init stubs + the MetricKit sink) and dummy.swift# (pulls in the Swift runtime libgodot needs). Same files as the parent; never copies.%w[dummy.mm dummy.swift].each { |p| add_once.call(clip.source_build_phase, ref.call(p)) }
# Frameworks: the SAME engine library and MoltenVK as the parent; dc_haptics (DcHaptics, DcStoreReview,# DcAppClip) linked AND embedded. No Sentry: the clip preset excludes the addon.godot = proj.files.find { |f| f.path == 'BulletHeaven.xcframework' } or abort('add_clip_target: no BulletHeaven.xcframework')molten = proj.files.find { |f| f.path == 'MoltenVK.xcframework' } or abort('add_clip_target: no MoltenVK.xcframework')haptics = proj.files.find { |f| f.path.to_s.end_with?('libdc_haptics.ios.release.xcframework') } \ or abort('add_clip_target: no libdc_haptics xcframework (run platform/tvos/addons/dc_haptics/build.sh, then a full iOS export)')[godot, molten, haptics].each { |f| add_once.call(clip.frameworks_build_phase, f) }embed = clip.copy_files_build_phases.find { |p| p.name == 'Embed Frameworks' } || clip.new_copy_files_build_phase('Embed Frameworks')embed.dst_subfolder_spec = '10'embed.dst_path = ''add_once.call(embed, haptics, { 'ATTRIBUTES' => %w[CodeSignOnCopy RemoveHeadersOnCopy] })
# Resources: the clip's own pck (Godot loads <bundle resource dir>/<executable>.pck, i.e.# DarkCosmosClip.pck: core/config/project_settings.cpp), the shared icon catalog, launch screen,# strings and privacy manifest. NOT the dylibs folder (21 MB of Sentry/haptics xcframework copies the# runtime never reads: GDExtensions load from Frameworks/).pck = proj.files.find { |f| f.path == "#{CLIP}.pck" } || proj.main_group.new_file("#{CLIP}.pck")pck.last_known_file_type = 'file'res = ['Images.xcassets', 'Launch Screen.storyboard', 'PrivacyInfo.xcprivacy'].map { |p| ref.call(p) }strings = proj.objects.find { |o| o.isa == 'PBXVariantGroup' && o.name == 'InfoPlist.strings' }res << strings if stringsres << pckres.each { |f| add_once.call(clip.resources_build_phase, f) }
# Parent embeds the clip in AppClips/ and depends on it.parent.add_dependency(clip) unless parent.dependencies.any? { |d| d.target == clip }ac = parent.copy_files_build_phases.find { |p| p.name == 'Embed App Clips' } || parent.new_copy_files_build_phase('Embed App Clips')ac.dst_subfolder_spec = '16'ac.dst_path = '$(CONTENTS_FOLDER_PATH)/AppClips'add_once.call(ac, clip.product_reference, { 'ATTRIBUTES' => %w[RemoveHeadersOnCopy] })
proj.save
# Shared scheme so `xcodebuild -scheme DarkCosmosClip` builds the clip alone (Dev install path).scheme = Xcodeproj::XCScheme.newscheme.configure_with_targets(clip, nil, launch_target: true)scheme.launch_action.build_configuration = 'Release'scheme.save_as(path, CLIP, true)puts "add_clip_target: ok (#{CLIP} in #{path})"- Step 7: Create
scripts/release/add-app-clip-target.sh(chmod +x):
#!/usr/bin/env bash# App Clip (spec 2026-10-14-app-clip-warden-design.md, "Xcode: scripted"): add the DarkCosmosClip# target to an exported iOS shell. The export regenerates the shell, so re-run after ANY full iOS export,# like apply-ios-metrickit.sh (either order: apply-ios-metrickit.sh accepts the array-form OTHER_LDFLAGS# the xcodeproj gem writes). Idempotent. Needs <shell>/DarkCosmosClip.pck (export the "iOS-Clip" or# "iOS-Clip-Dev" pack first). Run it AFTER the build script's own Info.plist edits (add_plist_keys,# ios-local-network-key.sh): the clip's plist starts as a copy of the parent's.# Usage: scripts/release/add-app-clip-target.sh [shell dir] (default: platform/tvos/build-ios)set -euo pipefailROOT=$(cd "$(dirname "$0")/../.." && pwd)SHELL_DIR=${1:-$ROOT/platform/tvos/build-ios}PATCH=$ROOT/platform/tvos/patches/ios-clipPROJ=$SHELL_DIR/BulletHeaven.xcodeprojPB=$PROJ/project.pbxprojD=$SHELL_DIR/BulletHeavenPLB=/usr/libexec/PlistBuddydie() { echo "add-app-clip-target: $*" >&2; exit 1; }[ -f "$PB" ] || die "no $PB"if /usr/bin/grep -q "SDKROOT = appletvos" "$PB"; then die "$SHELL_DIR is a tvOS shell; the App Clip is iOS only"; fi[ -f "$SHELL_DIR/DarkCosmosClip.pck" ] || die "no $SHELL_DIR/DarkCosmosClip.pck: export the iOS-Clip pack first"# The pbxproj editor is the xcodeproj gem that ships inside Homebrew's CocoaPods (no system gem, no# pip pbxproj on this Mac). GEM_PATH must keep Ruby's default dir too, or rexml goes missing.CP=$(brew --prefix cocoapods 2>/dev/null)/libexec[ -d "$CP/gems" ] && ls -d "$CP"/gems/xcodeproj-* >/dev/null 2>&1 || die "xcodeproj gem not found under $CP (brew install cocoapods)"# Plists: the clip's Info.plist starts as a copy of the parent's (same export, same keys), minus LAN# co-op, plus NSAppClip and the App Group the bridge reads (BHAppGroup).cp "$PATCH/DarkCosmosClip.entitlements" "$D/DarkCosmosClip.entitlements"CI=$D/DarkCosmosClip-Info.plistcp "$D/BulletHeaven-Info.plist" "$CI"for k in NSLocalNetworkUsageDescription NSBonjourServices; do $PLB -c "Delete :$k" "$CI" >/dev/null 2>&1 || true; done$PLB -c "Delete :NSAppClip" "$CI" >/dev/null 2>&1 || true$PLB -c "Add :NSAppClip dict" -c "Add :NSAppClip:NSAppClipRequestEphemeralUserNotification bool false" \ -c "Add :NSAppClip:NSAppClipRequestLocationConfirmation bool false" "$CI"for P in "$CI" "$D/BulletHeaven-Info.plist"; do $PLB -c "Delete :BHAppGroup" "$P" >/dev/null 2>&1 || true $PLB -c 'Add :BHAppGroup string group.$(BH_APP_ID)' "$P"done# Parent: the same App Group, so the full app can read the clip's clip_rewards.json.PE=$D/BulletHeaven.entitlements$PLB -c "Delete :com.apple.security.application-groups" "$PE" >/dev/null 2>&1 || true$PLB -c "Add :com.apple.security.application-groups array" -c 'Add :com.apple.security.application-groups:0 string group.$(BH_APP_ID)' "$PE"GEM_PATH="$CP:$(ruby -e 'print Gem.default_dir')" ruby "$PATCH/add_clip_target.rb" "$PROJ"/usr/bin/grep -q "com.apple.product-type.application.on-demand-install-capable" "$PB" || die "clip target missing after the edit"/usr/bin/grep -q 'dstPath = "$(CONTENTS_FOLDER_PATH)/AppClips"' "$PB" || die "parent does not embed the clip in AppClips/"[ -f "$PROJ/xcshareddata/xcschemes/DarkCosmosClip.xcscheme" ] || die "no shared DarkCosmosClip scheme"echo "add-app-clip-target: ok ($SHELL_DIR)"-
Step 8: Verify on a throwaway COPY of the real shell (never on the main checkout’s
platform/tvos/build-ios; it is the live Dev shell). The worktree has no shell, so first seed a private one:[ -d platform/tvos/build-ios ] || cp -R /Users/chris/Claude/bullet-heaven/platform/tvos/build-ios platform/tvos/build-ios(copy, never symlink; read-only on the main checkout; skillbh-worktree-device-install). ThenS=$TMPDIR/bh-clip-shell; rm -rf "$S"; cp -cR platform/tvos/build-ios "$S"; cp platform/tvos/build-ios/BulletHeaven.pck "$S/DarkCosmosClip.pck"(a stand-in pck; size irrelevant here), then:bash scripts/release/apply-ios-metrickit.sh "$S" && bash scripts/release/add-app-clip-target.sh "$S"→ bothok.- Idempotence:
cp "$S/BulletHeaven.xcodeproj/project.pbxproj" "$TMPDIR/pb1"; bash scripts/release/add-app-clip-target.sh "$S"; bash scripts/release/apply-ios-metrickit.sh "$S"; cmp "$TMPDIR/pb1" "$S/BulletHeaven.xcodeproj/project.pbxproj"→ identical. xcodebuild -list -project "$S/BulletHeaven.xcodeproj"→ targetsBulletHeaven,DarkCosmosClip; schemes includeDarkCosmosClip.- Unsigned clip build:
(cd "$S" && xcodebuild -project BulletHeaven.xcodeproj -scheme DarkCosmosClip -configuration Release -sdk iphoneos -destination generic/platform=iOS -derivedDataPath "$S/dd" CODE_SIGNING_ALLOWED=NO build > "$TMPDIR/clip-build.log" 2>&1; echo $?)→ 0. Thenplutil -p "$S/dd/Build/Products/Release-iphoneos/DarkCosmosClip.app/Info.plist" | /usr/bin/grep -E "CFBundleIdentifier|NSAppClip|BHAppGroup|MinimumOSVersion"→digital.lumara.bulletheaven.Clip, the NSAppClip dict,group.digital.lumara.bulletheaven,17.0;ls "$S/dd/Build/Products/Release-iphoneos/DarkCosmosClip.app/Frameworks"→ onlylibdc_haptics.ios.release.framework. - Unsigned parent build with the Dev id: same command with
-scheme BulletHeaven BH_APP_ID=digital.lumara.bulletheaven.dev→ 0;BulletHeaven.app/AppClips/DarkCosmosClip.appexists with iddigital.lumara.bulletheaven.dev.Clip, parent iddigital.lumara.bulletheaven.dev, bothBHAppGroup=group.digital.lumara.bulletheaven.dev. rm -rf "$S". (All six verified on the 2026-10-15 prototype; signing / App Group provisioning is NOT verified here, see Task 12a/12c.)
-
Step 9:
.gitignore— addplatform/tvos/build-ios-clip*/next to the existingplatform/tvos/build-ios/line (the Dev clip shell and its DerivedData, Task 11a). -
Step 10: Document the patch in
platform/tvos/patches/README.md— add table rows:
| `ios-clip/add_clip_target.rb` + `ios-clip/DarkCosmosClip.entitlements` | `../build-ios/` (and the Dev clip shell `../build-ios-clip/`) | App Clip (plan `docs/superpowers/plans/2026-10-15-app-clip-warden.md`): adds the `DarkCosmosClip` target (on-demand-install-capable, iOS 17, embedded in the parent's `AppClips/`), the shared `DarkCosmosClip` scheme, `BH_APP_ID` (one override moves both bundle ids), `BHAppGroup` + the App Group entitlement on both targets. Applied ONLY by `scripts/release/add-app-clip-target.sh` (idempotent; needs `<shell>/DarkCosmosClip.pck`). The pbxproj editor is the `xcodeproj` gem inside Homebrew CocoaPods. |and note under the iOS sink paragraph: “apply-ios-metrickit.sh accepts both the string and the array form of OTHER_LDFLAGS (the xcodeproj gem writes arrays).”
- Step 11: Commit —
build(ios): scripted App Clip Xcode target (xcodeproj gem) + MetricKit array-form LDFLAGS.
Task 10: Clip size gate
Section titled “Task 10: Clip size gate”Files:
- Create:
scripts/release/check-clip-size.mjs,scripts/test/check-clip-size.test.mjs,scripts/test/fixtures/clip-thinning-report.txt
Interfaces:
-
Produces:
node scripts/release/check-clip-size.mjs --report "<dir>/App Thinning Size Report.txt"(release gate) and--app <DarkCosmosClip.app>(Dev pre-check). Exit 0 under/at 95 MB, 1 over, 2 usage; throws (exit 1) when a report has noDarkCosmosClipvariant. ExportsLIMIT_MB,CLIP_NAME,toMB(text),clipVariantSizes(report),appBundleMB(app). -
Step 1: Create the fixture
scripts/test/fixtures/clip-thinning-report.txt. Its format follows Xcode’s “App Thinning Size Report” (oneVariant:block per variant, anApp size: X compressed, Y uncompressedline). It is a SYNTHETIC stand-in until Task 12c replaces it with the clip section of a real report:
App Thinning Size Report for All Variants of BulletHeaven
Variant: BulletHeaven-5C2F.ipaSupported variant descriptors: [device: iPhone16,2, os-version: 17.0]App + On Demand Resources size: 120.4 MB compressed, 182.7 MB uncompressedApp size: 120.4 MB compressed, 182.7 MB uncompressedOn Demand Resources size: Zero KB compressed, Zero KB uncompressed
Variant: DarkCosmosClip-9A11.ipaSupported variant descriptors: [device: iPhone16,2, os-version: 17.0]App + On Demand Resources size: 61.2 MB compressed, 91.8 MB uncompressedApp size: 61.2 MB compressed, 91.8 MB uncompressedOn Demand Resources size: Zero KB compressed, Zero KB uncompressed
Variant: DarkCosmosClip-77B0.ipaSupported variant descriptors: [device: iPad13,4, os-version: 17.0]App + On Demand Resources size: 61.0 MB compressed, 90.9 MB uncompressedApp size: 61.0 MB compressed, 90.9 MB uncompressedOn Demand Resources size: Zero KB compressed, Zero KB uncompressed- Step 2: Write the failing test
scripts/test/check-clip-size.test.mjs:
import { test } from "node:test";import assert from "node:assert/strict";import { readFileSync, writeFileSync, mkdtempSync } from "node:fs";import { execFileSync } from "node:child_process";import { tmpdir } from "node:os";import { join, dirname } from "node:path";import { fileURLToPath } from "node:url";import { toMB, clipVariantSizes, LIMIT_MB } from "../release/check-clip-size.mjs";
// App Clip size gate (plan 2026-10-15-app-clip-warden.md, Task 10).const here = dirname(fileURLToPath(import.meta.url));const SCRIPT = join(here, "..", "release", "check-clip-size.mjs");const FIXTURE = readFileSync(join(here, "fixtures", "clip-thinning-report.txt"), "utf8");const run = (args) => execFileSync("node", [SCRIPT, ...args], { encoding: "utf8", stdio: "pipe" });const write = (text) => { const d = mkdtempSync(join(tmpdir(), "bh-clipsz-")); const p = join(d, "r.txt"); writeFileSync(p, text); return p; };const largest = () => Math.max(...clipVariantSizes(FIXTURE).map((v) => v.mb));
test("units", () => { assert.equal(toMB("91.8 MB"), 91.8); assert.equal(toMB("850 KB"), 0.85); assert.equal(toMB("1.2 GB"), 1200); assert.equal(toMB("Zero KB"), 0); assert.throws(() => toMB("lots"));});
test("reads only the clip variants, uncompressed App size", () => { const sizes = clipVariantSizes(FIXTURE); assert.ok(sizes.length >= 1); for (const v of sizes) assert.match(v.variant, /DarkCosmosClip/);});
test("a report with no clip variant fails instead of passing", () => { assert.throws(() => clipVariantSizes(FIXTURE.replace(/DarkCosmosClip/g, "Other")), /no DarkCosmosClip variant/);});
test("the gate is 95 MB", () => assert.equal(LIMIT_MB, 95));
test("CLI passes the fixture and fails the same report pushed over the gate", () => { assert.ok(largest() <= LIMIT_MB, "fixture is under the gate"); assert.match(run(["--report", write(FIXTURE)]), /check-clip-size: ok/); const over = FIXTURE.split("91.8 MB uncompressed").join("95.1 MB uncompressed"); const overClip = over.split(/^Variant:/m).some((b) => b.includes("DarkCosmosClip") && /95\.1 MB uncompressed/.test(b)); assert.ok(overClip, "sanity: a clip variant now reads 95.1 MB"); assert.throws(() => run(["--report", write(over)]), (e) => e.status === 1 && /95\.1 MB/.test(e.stderr));});-
Step 3: Run it to verify it fails.
node --test scripts/test/check-clip-size.test.mjs→ module not found. -
Step 4: Create
scripts/release/check-clip-size.mjs:
#!/usr/bin/env node// App Clip size gate (spec 2026-10-14-app-clip-warden-design.md, "Size gate"): Apple caps an iOS 17+// App Clip at 100 MB uncompressed (thinned, digital invocations); we fail at 95 MB for headroom.// node scripts/release/check-clip-size.mjs --report "<export dir>/App Thinning Size Report.txt"// the release gate: the largest uncompressed App size of any DarkCosmosClip variant.// node scripts/release/check-clip-size.mjs --app <path/to/DarkCosmosClip.app>// the Dev pre-check: the bundle on disk with its executable stripped (an unthinned bundle is// never smaller than a thinned variant, so passing here means the thinned clip passes too).import { readFileSync, readdirSync, statSync, mkdtempSync, copyFileSync, rmSync } from "node:fs";import { execFileSync } from "node:child_process";import { join, basename } from "node:path";import { tmpdir } from "node:os";import { fileURLToPath } from "node:url";
export const LIMIT_MB = 95;export const CLIP_NAME = "DarkCosmosClip";const UNIT = { KB: 1e-3, MB: 1, GB: 1e3 };
// "1.2 MB" / "850 KB" / "Zero KB" -> MBexport function toMB(text) { const m = String(text).trim().match(/^(Zero|[\d.,]+)\s*(KB|MB|GB)$/i); if (!m) throw new Error(`unparseable size "${text}"`); const n = m[1].toLowerCase() === "zero" ? 0 : Number(m[1].replace(/,/g, "")); return n * UNIT[m[2].toUpperCase()];}
// Every clip variant's uncompressed "App size", in MB. Throws when the report has no clip variant// (a gate that silently finds nothing to measure must not pass).export function clipVariantSizes(report, clipName = CLIP_NAME) { const out = []; for (const block of report.split(/^Variant:/m).slice(1)) { const head = block.split("\n", 1)[0]; if (!head.includes(clipName)) continue; const m = block.match(/^App size:\s*(.+?)\s+compressed,\s*(.+?)\s+uncompressed/m); if (!m) throw new Error(`clip variant "${head.trim()}" has no "App size:" line`); out.push({ variant: head.trim(), mb: toMB(m[2]) }); } if (out.length === 0) throw new Error(`no ${clipName} variant in the App Thinning Size Report`); return out;}
function dirBytes(p) { const st = statSync(p); if (!st.isDirectory()) return st.size; return readdirSync(p).reduce((sum, n) => sum + dirBytes(join(p, n)), 0);}
export function appBundleMB(app) { const exe = join(app, basename(app, ".app")); const tmp = mkdtempSync(join(tmpdir(), "bh-clip-size-")); try { const copy = join(tmp, "exe"); copyFileSync(exe, copy); execFileSync("xcrun", ["strip", copy]); // archives strip; a `build` product does not return (dirBytes(app) - statSync(exe).size + statSync(copy).size) / 1e6; } finally { rmSync(tmp, { recursive: true, force: true }); }}
function main(argv) { const [mode, path] = argv; let worst; if (mode === "--report") { const sizes = clipVariantSizes(readFileSync(path, "utf8")); worst = sizes.reduce((a, b) => (b.mb > a.mb ? b : a)); console.log(`clip variants: ${sizes.length}, largest ${worst.mb.toFixed(1)} MB (${worst.variant})`); worst = worst.mb; } else if (mode === "--app") { worst = appBundleMB(path); console.log(`clip bundle (executable stripped): ${worst.toFixed(1)} MB`); } else { console.error("usage: check-clip-size.mjs --report <App Thinning Size Report.txt> | --app <DarkCosmosClip.app>"); process.exit(2); } if (worst > LIMIT_MB) { console.error(`::error:: App Clip is ${worst.toFixed(1)} MB uncompressed, over the ${LIMIT_MB} MB gate (Apple's cap is 100 MB)`); process.exit(1); } console.log(`check-clip-size: ok (${worst.toFixed(1)} MB <= ${LIMIT_MB} MB)`);}
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));-
Step 5: Run the test to verify it passes.
node --test scripts/test/check-clip-size.test.mjs→ 5/5. When Task 12c swaps in a real report, change the"91.8 MB uncompressed"literal in the last test to a clip variant’s real value; do not weaken the assertion. -
Step 6:
--appmode smoke. If Task 9 Step 8’s throwaway build still exists,node scripts/release/check-clip-size.mjs --app "$TMPDIR/bh-clip-shell/dd/Build/Products/Release-iphoneos/DarkCosmosClip.app"; with the full-size stand-in pck it must FAIL (the prototype measured 156.0 MB with the 99 MB full pck: correct). Otherwise skip; Task 11a exercises it on the real clip pck. -
Step 7: Commit —
build(ios): App Clip 95 MB size gate (thinning report + bundle pre-check).
Task 11a: Dev install path (install-clip-dev.sh + the install-dev.sh clip guard)
Section titled “Task 11a: Dev install path (install-clip-dev.sh + the install-dev.sh clip guard)”Files:
- Create:
scripts/dev/install-clip-dev.sh(chmod +x) - Modify:
scripts/dev/install-dev.sh(iOS branch: clip-target guard, Step 2b)
Interfaces:
- Consumes: Tasks 3, 5, 9, 10 (
iOS-Clip-Devpreset,DcAppClip,add-app-clip-target.sh,check-clip-size.mjs --app). - Produces:
scripts/dev/install-clip-dev.sh <iphone|ipad> [--with-parent]; aninstall-dev.shthat cannot mis-id an embedded clip.
Background (verified 2026-10-15): the shell’s BulletHeaven/dylibs/addons/dc_haptics/.../libdc_haptics.ios.release is a COPY made by the last FULL iOS export (12 Oct; it does not even contain DcStoreReview). A pack-only Dev install never refreshes it, so without a sync step neither the clip nor the parent Dev app would contain DcAppClip. Both Dev scripts therefore sync the freshly built xcframework into the shell.
-
Step 1: Sync the native lib in
scripts/dev/install-dev.sh. DONE early (2026-10-15, for the 1.1 ratings prompt):install-dev.shnow runsscripts/dev/refresh-shell-addons.sh <platform/tvos> <shell>, which re-syncs every addon xcframework the shell already embeds. Reuse it below. -
Step 2: Seed the shell, then create
scripts/dev/install-clip-dev.sh. The worktree has noplatform/tvos/build-ios:[ -d platform/tvos/build-ios ] || cp -R /Users/chris/Claude/bullet-heaven/platform/tvos/build-ios platform/tvos/build-ios(copy, never symlink; never write under the main checkout’splatform/tvos/). The script:
#!/usr/bin/env bash# Build + install the Dark Cosmos Dev App Clip (digital.lumara.bulletheaven.dev.Clip) on an iPhone/iPad# (plan docs/superpowers/plans/2026-10-15-app-clip-warden.md, Task 11a). Works on its OWN shell,# platform/tvos/build-ios-clip/ (an APFS clone of build-ios/, re-cloned every run), so install-dev.sh's# shell is never given the clip target: install-dev.sh overrides PRODUCT_BUNDLE_IDENTIFIER on the# command line, which in a shell with the clip target would give the clip the parent's id.# Usage: scripts/dev/install-clip-dev.sh <iphone|ipad> [--with-parent]# --with-parent also build + install the parent "Dark Cosmos Dev" app FROM THIS SHELL. Only this build# carries the App Group entitlement + DcAppClip, so it is the one to use for the# clip -> full app Beta-skin handoff test. It replaces the installed Dev app.# Never bumps BUILD (the clip ships inside the parent's build). Uses the shell's existing parent pack.# --with-parent therefore installs a parent Dev app at the source tree's BUILD with no bump: telemetry# cannot tell it from that BUILD's normal Dev install. For attribution, bump in the worktree first (skill# bh-worktree-device-install) and do not merge that bump; otherwise say so in the report.set -euo pipefailROOT=$(cd "$(dirname "$0")/../.." && pwd); cd "$ROOT". "$ROOT/scripts/lib/godot-export.sh" # godot_export + baked_canvas_check (known 4.8 exit crash, thin-bake guard). "$ROOT/scripts/lib/shell-engine.sh"usage() { echo "usage: install-clip-dev.sh <iphone|ipad> [--with-parent]"; exit 2; }case "${1:-}" in iphone) DEV=00008130-0012616E2250001C ;; ipad) DEV=00008103-000119240179001E ;; *) usage ;;esacTARGET=$1; shiftWITH_PARENT=0for opt in "$@"; do case "$opt" in --with-parent) WITH_PARENT=1 ;; *) usage ;; esac; doneDEV_ID=digital.lumara.bulletheaven.devCLIP_ID=$DEV_ID.ClipGODOT_BIN=${BH_IOS_GODOT:-$HOME/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64}SRC=$ROOT/platform/tvos/build-iosSHELL_DIR=$ROOT/platform/tvos/build-ios-clipDD=$ROOT/platform/tvos/build-ios-clip.DerivedData[ -f "$SRC/BulletHeaven.xcodeproj/project.pbxproj" ] || { echo "::error:: no iOS shell at $SRC: run scripts/dev/install-dev.sh iphone once first"; exit 1; }shell_engine_check "$GODOT_BIN" "$SRC" ios-arm64 "Regenerate the iOS shell with a full \"iOS-Dev\" export on that editor (platform/engine-patches/README.md, \"iOS on 4.8\")." || exit 1N=$(grep -oE '^const BUILD: int = [0-9]+' sim/constants.gd | grep -oE '[0-9]+$')SECRETS_FILE="$HOME/.sec""rets"[ -f "$SECRETS_FILE" ] || { echo "::error:: $SECRETS_FILE missing: ASC_KEY_ID / ASC_ISSUER_ID are needed to sign"; exit 1; }. "$SECRETS_FILE" >/dev/null 2>&1AUTH=(-allowProvisioningUpdates -authenticationKeyPath "$HOME/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8" -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID")# A windowed Godot export rewrites project.godot and audio/bus_layout.tres: snapshot + restore on ANY exit.SNAP=$(mktemp -d -t bh-clip-snap)GODOT_FILES=("$ROOT/platform/tvos/project.godot" "$ROOT/audio/bus_layout.tres")for f in "${GODOT_FILES[@]}"; do cp "$f" "$SNAP/$(basename "$f")"; donerestore_godot_files() { for f in "${GODOT_FILES[@]}"; do cmp -s "$f" "$SNAP/$(basename "$f")" || { cp "$SNAP/$(basename "$f")" "$f"; echo "restored $(basename "$f") (rewritten by the Godot export)"; } done}trap restore_godot_files EXIT# 1. A fresh clone of the Dev shell (APFS clone: instant, no extra disk until files diverge).rm -rf "$SHELL_DIR"cp -cR "$SRC" "$SHELL_DIR"rm -rf "$SHELL_DIR/DerivedData-dev"# 2. Native lib with DcAppClip, synced into the shell's copy (see install-dev.sh).bash "$ROOT/platform/tvos/addons/dc_haptics/build.sh"bash "$ROOT/scripts/dev/refresh-shell-addons.sh" "$ROOT/platform/tvos" "$SHELL_DIR"strings "$SHELL_DIR/BulletHeaven/dylibs/addons/dc_haptics/bin/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release" \ | /usr/bin/grep -qx DcAppClip || { echo "::error:: the shell's dc_haptics lib has no DcAppClip"; exit 1; }# 3. The clip pack (windowed + mobile renderer: the shader baker needs a Metal RenderingDevice).EXPORT_LOG=$(mktemp -t bh-clip-export)(cd platform/tvos && godot_export pack "$EXPORT_LOG" "$GODOT_BIN" --path . --rendering-method mobile \ --export-pack "iOS-Clip-Dev" build-ios-clip/DarkCosmosClip.pck) || { tail -20 "$EXPORT_LOG"; exit 1; }restore_godot_filesgrep -q "Started Baking shaders" "$EXPORT_LOG" || { echo "::error:: SHADER BAKER DID NOT RUN (log: $EXPORT_LOG)"; exit 1; }if grep -q "limited to SPIR-V" "$EXPORT_LOG"; then echo "::error:: bake has no .metallib: xcodebuild -downloadComponent MetalToolchain"; exit 1; fibaked_canvas_check "$SHELL_DIR/DarkCosmosClip.pck" || exit 1 # a thin bake = on-device shader compiles in a 2-minute demogrep -iE "^ERROR|SCRIPT ERROR" "$EXPORT_LOG" | head -5 || trueecho "clip pck: $(du -m "$SHELL_DIR/DarkCosmosClip.pck" | cut -f1) MB"# 4. Shell patches: launch screen (live usage: ONE arg, the shell dir; it dies under set -e on anything else), MetricKit, the clip target.bash "$ROOT/scripts/release/apply-launch-screen.sh" "$SHELL_DIR"bash "$ROOT/scripts/release/apply-ios-metrickit.sh" "$SHELL_DIR"bash "$ROOT/scripts/release/add-app-clip-target.sh" "$SHELL_DIR"# 5. Build. BH_APP_ID moves BOTH targets to the Dev ids (never PRODUCT_BUNDLE_IDENTIFIER here).SCHEME=DarkCosmosClip; [ "$WITH_PARENT" = 1 ] && SCHEME=BulletHeaven(cd "$SHELL_DIR" && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project BulletHeaven.xcodeproj \ -scheme "$SCHEME" -configuration Release -destination "id=$DEV" -derivedDataPath "$DD" "${AUTH[@]}" \ BH_APP_ID=$DEV_ID INFOPLIST_KEY_CFBundleDisplayName="Dark Cosmos Dev" CURRENT_PROJECT_VERSION=$N \ CODE_SIGN_IDENTITY="Apple Development" ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Dev build > clip-build.log 2>&1) \ || { tail -30 "$SHELL_DIR/clip-build.log"; exit 1; }P=$DD/Build/Products/Release-iphoneosCLIP_APP=$P/DarkCosmosClip.appnode "$ROOT/scripts/release/check-clip-size.mjs" --app "$CLIP_APP"id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$CLIP_APP/Info.plist")[ "$id" = "$CLIP_ID" ] || { echo "::error:: built clip is '$id', not $CLIP_ID"; exit 1; }if [ "$WITH_PARENT" = 1 ]; then APP=$P/BulletHeaven.app pid=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Info.plist") [ "$pid" = "$DEV_ID" ] || { echo "::error:: built parent is '$pid', not $DEV_ID: refusing to install over the store app"; exit 1; } [ -d "$APP/AppClips/DarkCosmosClip.app" ] || { echo "::error:: parent does not embed the clip"; exit 1; } xcrun devicectl device install app --device "$DEV" "$APP" xcrun devicectl device process launch --device "$DEV" "$DEV_ID" || echo "(launch failed: benign if the device is asleep)" echo "==> Dark Cosmos Dev (with App Clip, BUILD $N) installed on $TARGET"else xcrun devicectl device install app --device "$DEV" "$CLIP_APP" xcrun devicectl device process launch --device "$DEV" "$CLIP_ID" || echo "(launch failed: benign if the device is asleep)" echo "==> Dark Cosmos Dev App Clip (BUILD $N) installed on $TARGET"fiNote: apply-launch-screen.sh has landed; its usage is apply-launch-screen.sh <shell dir> (one argument), called unconditionally. baked_canvas_check comes from scripts/lib/godot-export.sh (the thin-bake guard added after this plan was first written).
- Step 2b: Guard
scripts/dev/install-dev.shagainst a clip-bearing shell. OnceBH_WITH_CLIP=1 build-ios.sh(Task 11b) has added theDarkCosmosCliptarget toplatform/tvos/build-ios,install-dev.sh’sPRODUCT_BUNDLE_IDENTIFIER=$DEV_IDoverride would give the embedded clip the parent’s id and break the next Dev install. Make that structural, not a note: in the iOS branch, directly before thexcodebuildthat uses"${OVR[@]}"(after theapply-launch-screen.shline), add:
# App Clip guard: a shell that carries the DarkCosmosClip target takes its ids from BH_APP_ID (one override # moves BOTH targets); PRODUCT_BUNDLE_IDENTIFIER on the command line would give the clip the parent's id. if /usr/bin/grep -q DarkCosmosClip "$ROOT/platform/tvos/build-ios/BulletHeaven.xcodeproj/project.pbxproj"; then OVR=("${OVR[@]/PRODUCT_BUNDLE_IDENTIFIER=$DEV_ID/BH_APP_ID=$DEV_ID}") echo "install-dev: shell has the App Clip target: using BH_APP_ID=$DEV_ID (clip id $DEV_ID.Clip)" fiCheck after the build, next to the existing CFBundleIdentifier check: when the guard fired, $APP/AppClips/DarkCosmosClip.app/Info.plist must read $DEV_ID.Clip. Static check: bash -n scripts/dev/install-dev.sh; with a throwaway pbxproj containing the word DarkCosmosClip the substitution turns the PRODUCT_BUNDLE_IDENTIFIER= element into BH_APP_ID= (try it in a subshell with a copy of the OVR=(...) line).
-
Step 3: Static checks (no device).
bash -n scripts/dev/install-clip-dev.sh(syntax),bash scripts/dev/install-clip-dev.sh bogus; echo $?→ usage, exit 2. Do NOT run a real install from an agent; that is Task 12a [DEVICE]. -
Step 4: Commit
scripts/dev/install-clip-dev.sh scripts/dev/install-dev.sh—build(ios): App Clip Dev install (install-clip-dev.sh) + install-dev clip-target guard.
Task 11b: Opt-in release path, docs, What’s new
Section titled “Task 11b: Opt-in release path, docs, What’s new”Files:
- Modify:
scripts/release/env.sh(archive_export_upload),scripts/release/build-ios.sh,platform/tvos/CLAUDE.md,docs/architecture/dev-tools.md(§ Build channels),control/src/whats_new.js
Interfaces:
- Consumes: Tasks 9, 10, 11a.
- Produces:
BH_WITH_CLIP=1 scripts/release/build-ios.sh(opt-in; default unchanged).
Note (F12): godot_export now deletes .godot/exported before every export, so the BH_WITH_CLIP clip pack re-bakes from scratch after the full iOS export; this is build time only.
- Step 1: Opt-in release path. In
scripts/release/env.sh, insidearchive_export_upload, directly after thexcodebuild -exportArchive ... | tail -1line and before theNO_UPLOADline, add:
if [ -n "${BH_CLIP_GATE:-}" ]; then clip_size_gate || return 1; fiand add this function after archive_export_upload:
# App Clip size gate (plan 2026-10-15-app-clip-warden.md): a second, thinned export of the SAME archive# writes "App Thinning Size Report.txt"; check-clip-size.mjs fails the release above 95 MB. cwd = build dir.clip_size_gate() { rm -rf thin-out thin-options.plist /usr/libexec/PlistBuddy -c "Add :method string release-testing" -c "Add :thinning string <thin-for-all-variants>" \ -c "Add :teamID string 2WGASQK278" -c "Add :signingStyle string automatic" thin-options.plist >/dev/null xcodebuild -exportArchive -archivePath BulletHeaven.xcarchive -exportOptionsPlist thin-options.plist \ -exportPath thin-out "${xcb_auth[@]}" > thin.log 2>&1 || { tail -20 thin.log; return 1; } node "$TVOS/../../scripts/release/check-clip-size.mjs" --report "thin-out/App Thinning Size Report.txt"}In scripts/release/build-ios.sh, directly after the diff <(plutil -p "$PL") ... line and before archive_export_upload:
# App Clip (plan 2026-10-15-app-clip-warden.md): OFF until Chris approves shipping it (target 1.1, drop rule).# BH_WITH_CLIP=1 exports the clip pack, adds the DarkCosmosClip target and gates its thinned size.if [ "${BH_WITH_CLIP:-0}" = 1 ]; then godot_export pack /tmp/bh-ios-clip-export.log "$BH_IOS_GODOT" --path . --rendering-method mobile --export-pack "iOS-Clip" build-ios/DarkCosmosClip.pck \ || { tail -20 /tmp/bh-ios-clip-export.log; exit 1; } git checkout -- project.godot grep -q "Started Baking shaders" /tmp/bh-ios-clip-export.log || { echo "CLIP SHADER BAKER DID NOT RUN"; exit 1; } baked_canvas_check build-ios/DarkCosmosClip.pck || exit 1 # same thin-bake guard as the parent pack above bash "$TVOS/../../scripts/release/add-app-clip-target.sh" "$TVOS/build-ios" export BH_CLIP_GATE=1fibash -n both files. The BH_WITH_CLIP block adds the clip target to the LIVE platform/tvos/build-ios shell; Task 11a Step 2b makes install-dev.sh safe against that, and Task 12c runs it from a SEPARATE seeded worktree anyway. Note for the reviewer: method release-testing (Xcode 15+ name for ad hoc) and the report filename are [Likely]; Task 12c verifies them on the first real archive.
- Step 2: Docs.
platform/tvos/CLAUDE.md: add a short “App Clip” paragraph — theDarkCosmosCliptarget is added ONLY byscripts/release/add-app-clip-target.sh; Dev installs usescripts/dev/install-clip-dev.shon the separatebuild-ios-clip/shell; never passPRODUCT_BUNDLE_IDENTIFIERto xcodebuild on a shell with the clip target (useBH_APP_ID;install-dev.shnow switches automatically); release is opt-inBH_WITH_CLIP=1; the shell’s dc_haptics lib is a full-export copy that the Dev scripts rsync.docs/architecture/dev-tools.md§ Build channels: add the clip channel (ids, App Group per channel, install command, Local Experience testing, size gate).control/src/whats_new.js: add one item to thebuild: nullentry (create the entry per the file’s convention if absent):
{ text: 'App Clip (Dev only, not in the store app): a separate "Dark Cosmos Dev" App Clip build. It boots straight into a 3-2-1 countdown on Cobalt, a 45 s swarm with about 5x XP and level-up cards only from a short showpiece list, then the Warden. Win or lose, the end card says the Beta skin is unlocked, offers Apple\'s install card and "Fight again". Judge the pacing (does the ramp feel like rapid power growth, is the Warden winnable in about 75 s), the card wording, and the showpiece list.', where: 'scripts/dev/install-clip-dev.sh iphone; Console: Tunables, App Clip (ClipDirector.CLIP_*)' },Then node --test control/test/*.test.mjs → pass.
-
Step 3: Full gate.
bash scripts/gate.sh→ green (GUT, determinism, node tests incl. the new ones, docs-site). -
Step 4: Commit —
build(ios): App Clip opt-in release path (BH_WITH_CLIP) + docs.
Task 12a: Dev signing, App Group and size on the device [DEVICE] [CHRIS]
Section titled “Task 12a: Dev signing, App Group and size on the device [DEVICE] [CHRIS]”Run by the controller with Chris, never by a background agent while a human playtest is live. Phase A, right after Task 11a. Without Task 7 the clip boots into the normal menu: that is enough here.
- Step 1: Portal / signing. First signed build:
scripts/dev/install-clip-dev.sh iphone. Expected: automatic signing registers App IDdigital.lumara.bulletheaven.dev.Clip(the spike did) and the App Groupgroup.digital.lumara.bulletheaven.dev. If signing fails on the App Group, Chris registers it in the Developer portal (Identifiers → App Groups) and enables it on both Dev App IDs; record what was needed indocs/architecture/dev-tools.md. The clip install printscheck-clip-size: ok(bundle pre-check) — record the MB. - Step 2: Entitlements.
codesign -d --entitlements :- platform/tvos/build-ios-clip.DerivedData/Build/Products/Release-iphoneos/DarkCosmosClip.appmust showcom.apple.security.application-groups(the Dev group),com.apple.developer.on-demand-install-capableand the parent application identifier. The clip launches on the iPhone.
Task 12b: Gameplay, stall, baked-iPhone checks and the handoff [DEVICE] [CHRIS]
Section titled “Task 12b: Gameplay, stall, baked-iPhone checks and the handoff [DEVICE] [CHRIS]”Phase B, after Tasks 1, 2, 4, 6 and 7, on a fresh scripts/dev/install-clip-dev.sh iphone. Same rules as 12a. Uninstalling wipes the Dev app’s save: say so before Step 4.
- Step 1: Clip run. On the iPhone the clip launches into the countdown (no menu, no tutorial, no Godot grey), hint line for 5 s, swarm, 4-5 level-ups from the curated pool, Warden at ~45 s with its growl and the Warden theme, end card on win AND on a deliberate loss, Apple’s install overlay appears, “Fight again” restarts. Live-tune via the Remote Console (
ClipDirector.CLIP_*) with Chris until a typical player wins in ~75 s; persist approved values as a remote-config patch only with Chris’s OK, then bake them as the new defaults insim/clip_director.gdin a follow-up commit. (F9, tuning question: the Warden spawns into a full ~200-body ramp field with no wind-down, unlike Survival’s empty-arena boss; watch whether that feels fair.) - Step 2: Fresh-install stall check with skill
bh-fresh-install-stall-checkon the clip (shaders: the clip pack is baked separately). - Step 3: Baked-iPhone draw check (A17 baker bug, godot#124088): menu-less clip draws ship, swarm, Warden, end card text. If blank labels: uninstall + clean reinstall first (stale on-device state), then suspect the baker.
- Step 4: Curated pool sign-off [CHRIS]. Chris confirms or edits
ClipDirector.POOL_*(spec “Open items”); updatetests/test_clip_offers.gdif ids change. - Step 5: Handoff (needs Task 8 merged). Uninstall the Dev app (
xcrun devicectl device uninstall app --device 00008130-0012616E2250001C digital.lumara.bulletheaven.dev), install + play the clip to an end state, thenscripts/dev/install-clip-dev.sh iphone --with-parent, launch the full Dev app: the Beta skin is owned (hull picker skin row from the Beta-skin plan). Relaunch: no second grant. Also test Local Experiences: Settings → Developer → Local Experiences → registerhttps://darkcosmos.lumara.digital/clip(or the site URL Chris picks) for bundledigital.lumara.bulletheaven.dev.Clip; open the URL in Safari / Messages and confirm the clip card launches it.
Task 12c: Store archive and the real thinning report [CHRIS OK]
Section titled “Task 12c: Store archive and the real thinning report [CHRIS OK]”Phase A tail (right after Task 11b): this is the ONLY proof of store signing (the digital.lumara.bulletheaven.Clip App ID, the store App Group, the distribution profile, the release-testing thinning method [Likely]), so it runs early. Run it in a SEPARATE worktree seeded with the main checkout’s shells (BH_WITH_CLIP=1 adds the clip target to that worktree’s platform/tvos/build-ios, never to the Dev shell other sessions install from). If automatic signing cannot create the store App Group, Chris pre-registers it in the portal ([Likely] it can).
- Step 1: Real thinning report. One
NO_UPLOAD=1 BH_WITH_CLIP=1 scripts/release/build-ios.sh(Chris OK; it builds the store bundle locally, no upload). Confirmthin-out/App Thinning Size Report.txtexists, its clip variant blocks parse, and the largest clip variant is <= 95 MB. Replacescripts/test/fixtures/clip-thinning-report.txtwith the real report’s text (variant names/sizes as Xcode wrote them), re-runnode --test scripts/test/check-clip-size.test.mjs, commit. Ifrelease-testingis rejected, switchclip_size_gatetomethod developmentand re-run. If over 95 MB: next levers are the 3D shader cache (SceneForwardMobileShaderRD, ~3 MB) and the remaining SFX not used by the clip run; the large lever is a 3D-disabled engine build (spec “Headroom lever”).
Task 13: Outward-facing launch steps [CHRIS approval required — never automated]
Section titled “Task 13: Outward-facing launch steps [CHRIS approval required — never automated]”- Step 1: Chris approves turning the clip on for 1.1 (hard drop rule: only if green on his iPhone by the 2026-10-17 freeze, else 1.1.1): make
BH_WITH_CLIPdefault to 1 inscripts/release/build-ios.sh(or haverelease.shexport it) onmainfor the 1.1 release; update the App Clip item indocs/RELEASE-1.1.md(tick or move it to 1.1.1) and.claude/skills/bh-appstore-release/SKILL.md+node scripts/port-claude-to-factory.mjs. Update the What’s new text (“Dev only, not in the store app”) at the same time. - Step 2 (pre-submission blocker; Claude prepares the files during Phase B, Chris decides by 2026-10-16): App Store Connect → the app → App Clip: default App Clip experience metadata (header image 1800x1200, subtitle, action “Play”), prepared by Claude as files for Chris; Chris enters/approves them. Store App ID
digital.lumara.bulletheaven.Clipand App Groupgroup.digital.lumara.bulletheavenregistered on the store App IDs. - Step 3: Safari Smart App Banner with
app-clip-bundle-id=digital.lumara.bulletheaven.Clipon the Dark Cosmos site (~/Claude/bullet-heaven-site, deploy only via itsscripts/deploy-site.sh) and, if an advanced experience URL is used, theappclips:entry in itsapple-app-site-association. Chris’s OK before deploy. - Step 4 (pre-submission blocker; Claude prepares the privacy diff during Phase B, Chris decides by 2026-10-16): Privacy: the clip sends
run_endtelemetry with modeclipand has no Settings screen; checkmarketing/appstore/compliance.md(ASC privacy label) and the site privacy page still describe what the clip collects, or gate the clip’s telemetry off. Chris decides.
Self-review (done while writing; kept for the reviewer)
Section titled “Self-review (done while writing; kept for the reviewer)”- Spec coverage: Player experience 1 (launch screen) is the separate launch-screen plan; the clip shares the shell’s storyboard and
Images.xcassets, so it inherits it (Task 11a runsapply-launch-screen.shon the clip shell). 2 → Tasks 6-7. 3 → Tasks 1-2. 4 → Task 1 (Warden spawn, HP scale; growl is the existing render cue; theme = amendment 3). 5 → Tasks 4-7 (+ highlights from code tables). 6 → Tasks 6-7. Build/preset → Task 3 (3D shader-cache exclusion NOT done: not excludable by path filter; Task 12c lists it as the first lever). ClipDirector → Tasks 1-2. Native bridge → Tasks 4-5, 8. Xcode → Task 9 (+ Task 11a for Dev). Size gate → Tasks 10-11. Testing → GUT in Tasks 1, 2, 4, 6, 7, 8; determinism untouched (Task 1 Step 10); device items Tasks 12a-12c. Open items → Task 12b Step 4, Task 13. - Types/names used across tasks:
ClipDirector.Phase/Outcome,ended(),holds_waves(),update_spawns,post_tick,xp_mult,swarm_density,roll_offers,eligible_pool;ClipRewards.group_dir/present_install_overlay/path_in/write_earned/read/consume,SKIN_ID,NATIVE_SINGLETON;ClipFlow.start/advance/freezes_sim/show_end/headline_text/LOSE_LINE/COUNTDOWN_S/BTN_W,install_button,fight_button; mainclip_flow,_clip_ended,_setup_clip_run,_clip_holds,_end_clip_run,_clip_fight_again,_consume_clip_rewards; scriptsadd-app-clip-target.sh,check-clip-size.mjs,install-clip-dev.sh; build settingBH_APP_ID; plist keyBHAppGroup— consistent. - Prototype evidence (2026-10-15, throwaway worktree, since removed): Tasks 1-4, 6-7 code ran green in the full suite (568 scripts, 6328 passing, 0 failing, determinism untouched); Task 5 compiled for iOS + macOS with 0 warnings and registered on stock Godot 4.7.1; Task 9 verified on a copy of the real shell (idempotent, unsigned clip + parent builds succeed, correct ids/plists/embedding); Task 10 5/5 with a synthetic fixture. Task 8 is not prototyped (needs
grant_skin); Tasks 11a/11b’s scripts and all device steps are unverified.