Launch screen + boot splash — implementation plan
Launch Screen + Boot Splash Implementation Plan
Section titled “Launch Screen + Boot Splash 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. In this repo, also consultbh-sdd-concurrentbefore Task 1.
Goal: Replace the grey Godot launch screen (iOS storyboard, tvOS has none) and the stock Godot engine boot logo with one black Dark Cosmos splash, so app launch reads as one continuous picture from the native launch screen through the engine boot to the menu.
Architecture: One tracked splash PNG (platform/tvos/splash/boot_splash.png) is BOTH the Godot application/boot_splash/image (engine side) and the iOS launch-storyboard image (native side). The two sides use the same black and the same aspect-fit placement, so the handoff shows the same picture in the same place. A generator script renders the art (plus the two tvOS launch-image sizes) from existing repo assets. A new idempotent script, scripts/release/apply-launch-screen.sh, installs the native side into the gitignored Xcode shells (iOS: storyboard + content-hashed imageset; tvOS: LaunchImage.launchimage + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME). build-ios.sh, build-tvos.sh and install-dev.sh run it on every build, following the apply-ios-metrickit.sh pattern, so nobody has to do it by hand.
Tech Stack: Godot 4.8 fork project settings (platform/tvos/project.godot), bash, Xcode asset catalogs / storyboard XML, Python 3 + Pillow (art generator, same as marketing/template/make_press.py), node --test (gate step 5, scripts/test/*.test.mjs).
Spec: docs/superpowers/specs/2026-10-14-app-clip-warden-design.md, section “Launch screen + boot splash” only (also player-experience step 1, “Launch”). The App Clip, ClipDirector and Beta skin are NOT in this plan.
Global Constraints
Section titled “Global Constraints”- Spec: “Replace the iOS
Launch Screen.storyboard(currently grey 0.14 + Godot’s stockSplashImage) with a black background + Dark Cosmos key art; tracked copy inpatches/.” - Spec: “Set
application/boot_splash/*(image + black bg) inplatform/tvos/project.godotto match, so the native → engine handoff is seamless. Also tvOS’s launch image.” - Spec player experience: “branded black launch screen with key art, seamless handoff to a matching engine boot splash. No grey, no Godot logo.”
- Black means exactly
(0, 0, 0)everywhere: storyboardbackgroundColor,application/boot_splash/bg_color=Color(0, 0, 0, 1), and every border pixel of every splash PNG. Pure black is the only value that matches across sRGB / P3 / the engine’s output path without colour-space drift. - The splash art is opaque 8-bit RGB (no alpha). Nothing drawn within 6% of any edge (tvOS overscan, and it keeps the borders pure black).
- Root
project.godot(web path) is NOT changed. Onlyplatform/tvos/project.godot(iOS, tvOS, macOS, Android presets all read it). application/boot_splash/imagemust stay a literalres://path, neveruid://.main/main.cpp(fork, ~line 3966) silently drops auid://boot splash when the UID cache is not loaded yet and shows the default Godot logo instead.platform/tvos/project.godotcomments must use;, never#(root CLAUDE.md: a#comment once swallowed the next section).- The Xcode shells (
platform/tvos/build/,platform/tvos/build-ios/) are gitignored. Every change to them goes through a tracked source + an idempotent script that the build scripts run. Never a manual step, nevercpby hand. - Do not touch
scripts/release/patch-tvos-shell.sh’s guard that refuses a tvOS pbxproj referencingLaunch Screen.storyboard. tvOS gets a launch IMAGE, not a storyboard. - Implementers work in their own worktree:
git worktree add .claude/worktrees/launch-splash -b launch-splash origin/main(run from/Users/chris/Claude/bullet-heaven). Never branch inside the main checkout. - Gate:
scripts/check-test-count.shfor GUT,scripts/gate.shfor the full gate (it runsnode --test scripts/test/*.test.mjs). Never pull a raw GUT run into context. - Any Godot invocation (headless
--importincluded) can rewriteplatform/tvos/project.godotandaudio/bus_layout.tres. Checkgit statusafter each one andgit checkout --both before committing anything you did not mean to change. - Skill edits: edit
.claude/skills/<name>/SKILL.md, then runnode scripts/port-claude-to-factory.mjsand commit both sides (.factory/is generated). - The controller commits. Implementers stage and report. They do not run
git commitunless the controller tells them to.
File map
Section titled “File map”| File | New/Modify | Responsibility |
|---|---|---|
tools/splash/make_splash.py |
Create | Renders the one splash composition at 3 sizes from render/ship_sprites/ship3d_cobalt.png + fonts/Orbitron-VariableFont_wght.ttf. |
platform/tvos/splash/boot_splash.png (+ .import) |
Create (generated) | 2400x1350. The engine boot splash AND the iOS launch-screen image. |
platform/tvos/patches/launch/.gdignore |
Create | Keeps Godot from importing the native-only launch assets. |
platform/tvos/patches/launch/LaunchImage.launchimage/launch_1920x1080.png, launch_3840x2160.png |
Create (generated) | tvOS launch image, 1x / 2x. |
platform/tvos/patches/launch/LaunchImage.launchimage/Contents.json |
Create | tvOS asset-catalog descriptor. |
platform/tvos/patches/launch/Launch Screen.storyboard |
Create | iOS launch storyboard TEMPLATE (@SPLASH_IMAGE@ token), black, aspect-fit. |
scripts/release/apply-launch-screen.sh |
Create | Idempotent installer for both shells. |
platform/tvos/project.godot |
Modify ([application]) |
boot_splash/* keys. |
scripts/release/build-ios.sh, scripts/release/build-tvos.sh, scripts/dev/install-dev.sh |
Modify | Run the installer. |
scripts/test/splash-art.test.mjs |
Create | PNG sizes / opacity / pure-black borders + project.godot keys. |
scripts/test/apply-launch-screen.test.mjs |
Create | Installer against fixture iOS + tvOS shells; the wiring into the 3 build scripts. |
platform/tvos/patches/README.md, platform/tvos/CLAUDE.md, .claude/skills/bh-deploy/SKILL.md, .claude/skills/bh-appstore-release/SKILL.md (+ .factory/ mirror) |
Modify | Document the new patch. |
Verified facts this plan relies on (2026-10-15)
Section titled “Verified facts this plan relies on (2026-10-15)”- Fork
~/Claude/godot-tvos/godot-48(branchtvos-4.8dev,a8d5c0bb62):platform/ios/export/export_plugin.cpp: a full iOS export writesSplashImage.imageset/splash@2x.pngand@3x.pngfromapplication/boot_splash/image(when the preset’sstoryboard/custom_image@*are empty, as ours are) and the storyboard background fromapplication/boot_splash/bg_color. The storyboardcontentModecomes from the deprecatedapplication/boot_splash/fullsize(so a full export would writecenter). That is one reason the installer script overwrites the storyboard instead of relying on the export.get_custom_project_settings()injectsios/launch_screen_image_modefromboot_splash/stretch_mode(1= Keep →scaleAspectFit).drivers/apple_embedded/os_apple_embedded.mm::calculate_boot_screen_rectuses it to place the engine boot image with the same rect rule as the storyboard. So aspect-fit on both sides gives the same placement.editor/export/editor_export_platform.cpp::get_forced_export_filesalways packs the raw boot-splash PNG, whatever the export filter.platform/tvos/export/export_plugin.cpp: tvOS export writes NO launch storyboard and NO launch image ($plist_launch_screen_name→ empty).
- The current iOS shell storyboard is grey
0.14,contentMode="center",image="SplashImage", pinned to the view edges (not the safe area).Info.plisthasUILaunchStoryboardName = Launch Screen. The pbxproj bundles it as90DD2D9E24B36E8000717FE1 /* Launch Screen.storyboard in Resources */. - tvOS shell pbxproj has two
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";lines (Debug/Release), noLAUNCHIMAGE, andSDKROOT = appletvos. - The game is landscape-only (iPhone
LandscapeLeft, iPadLandscapeRight). platform/tvos/project.godotdefault_clear_color=Color(0.03, 0.03, 0.06, 1). That is the first engine frame AFTER the splash. It is deliberately not changed here (see Risks).- No existing Dark Cosmos logo/wordmark asset.
marketing/appstore/press/hero.pngis a gameplay screenshot with HUD (unsuitable). The splash is composed fromrender/ship_sprites/ship3d_cobalt.png(512x512 RGBA; Cobalt is the clip’s hull) + the Orbitron wordmark in brand cyan(90, 230, 255)(asmarketing/template/make_press.pyuses).
Task 1: Splash art generator + generated PNGs
Section titled “Task 1: Splash art generator + generated PNGs”Files:
- Create:
tools/splash/make_splash.py - Create (generated):
platform/tvos/splash/boot_splash.png,platform/tvos/patches/launch/LaunchImage.launchimage/launch_1920x1080.png,platform/tvos/patches/launch/LaunchImage.launchimage/launch_3840x2160.png - Create:
platform/tvos/patches/launch/.gdignore(empty) - Test:
scripts/test/splash-art.test.mjs
Interfaces:
-
Produces:
platform/tvos/splash/boot_splash.png(2400x1350 RGB), and the two tvOS PNGs at the exact paths above. Tasks 2 and 3 consume these paths. -
Step 1: Create the worktree (skip if the controller already made it)
cd /Users/chris/Claude/bullet-heaven && git fetch origin && git worktree add .claude/worktrees/launch-splash -b launch-splash origin/maincd /Users/chris/Claude/bullet-heaven/.claude/worktrees/launch-splashAll later paths are relative to this worktree.
- Step 2: Write the failing test
scripts/test/splash-art.test.mjs
import { test } from "node:test";import assert from "node:assert/strict";import { readFileSync, existsSync } from "node:fs";import { inflateSync } from "node:zlib";import { join, dirname } from "node:path";import { fileURLToPath } from "node:url";
// Launch screen + boot splash (spec 2026-10-14-app-clip-warden-design.md). The splash art must be// opaque RGB with PURE black borders: the iOS storyboard, Godot's boot_splash/bg_color and the// image's own background meet at the letterbox edge, and any non-zero border pixel shows as a seam.
const here = dirname(fileURLToPath(import.meta.url));const ROOT = join(here, "..", "..");const TVOS = join(ROOT, "platform", "tvos");const ART = [ ["splash/boot_splash.png", 2400, 1350], ["patches/launch/LaunchImage.launchimage/launch_1920x1080.png", 1920, 1080], ["patches/launch/LaunchImage.launchimage/launch_3840x2160.png", 3840, 2160],];
// Minimal PNG decoder: 8-bit RGB, non-interlaced, all five filter types. Anything else throws,// which is itself a failure (the art must be exactly this format).function decodeRgbPng(buf) { assert.equal(buf.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", "PNG signature"); let off = 8, w = 0, h = 0, bd = 0, ct = 0, il = 0; const idat = []; while (off < buf.length) { const len = buf.readUInt32BE(off); const type = buf.toString("ascii", off + 4, off + 8); const data = buf.subarray(off + 8, off + 8 + len); if (type === "IHDR") { w = data.readUInt32BE(0); h = data.readUInt32BE(4); bd = data[8]; ct = data[9]; il = data[12]; } else if (type === "IDAT") idat.push(data); else if (type === "IEND") break; off += 12 + len; } if (bd !== 8 || ct !== 2 || il !== 0) throw new Error(`want 8-bit RGB non-interlaced, got depth=${bd} colortype=${ct} interlace=${il}`); const raw = inflateSync(Buffer.concat(idat)); const bpp = 3, stride = w * bpp, px = Buffer.alloc(h * stride); for (let y = 0; y < h; y++) { const f = raw[y * (stride + 1)], src = y * (stride + 1) + 1, dst = y * stride; for (let x = 0; x < stride; x++) { const a = x >= bpp ? px[dst + x - bpp] : 0; const b = y > 0 ? px[dst - stride + x] : 0; const c = x >= bpp && y > 0 ? px[dst - stride + x - bpp] : 0; let v = raw[src + x]; if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1; else if (f === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; } else if (f !== 0) throw new Error(`bad filter ${f} on row ${y}`); px[dst + x] = v & 255; } } return { w, h, px };}
for (const [rel, W, H] of ART) { test(`${rel}: ${W}x${H} opaque RGB, every pixel in the outer 6% is pure black`, () => { const p = join(TVOS, rel); assert.ok(existsSync(p), `missing ${p}: run python3 tools/splash/make_splash.py`); const { w, h, px } = decodeRgbPng(readFileSync(p)); assert.deepEqual([w, h], [W, H]); const mx = Math.floor(w * 0.06), my = Math.floor(h * 0.06); let lit = 0; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (x >= mx && x < w - mx && y >= my && y < h - my) continue; const i = (y * w + x) * 3; if (px[i] | px[i + 1] | px[i + 2]) lit++; } } assert.equal(lit, 0, `${lit} non-black pixel(s) in the 6% border`); // And there IS art (guards an all-black render). let any = 0; for (let i = 0; i < px.length; i += 3) if (px[i] | px[i + 1] | px[i + 2]) { any++; if (any > 1000) break; } assert.ok(any > 1000, "the splash has visible art"); });}- Step 3: Run it and confirm it fails
Run: node --test scripts/test/splash-art.test.mjs
Expected: 3 failures, each missing .../platform/tvos/...png: run python3 tools/splash/make_splash.py.
- Step 4: Write the generator
tools/splash/make_splash.py
#!/usr/bin/env python3"""Dark Cosmos launch / boot splash art.
Spec: docs/superpowers/specs/2026-10-14-app-clip-warden-design.md, "Launch screen + boot splash".One composition, rendered at three sizes: platform/tvos/splash/boot_splash.png 2400x1350 Godot application/boot_splash/image AND the iOS launch-storyboard image (installed by scripts/release/apply-launch-screen.sh) platform/tvos/patches/launch/LaunchImage.launchimage/launch_1920x1080.png tvOS launch image 1x platform/tvos/patches/launch/LaunchImage.launchimage/launch_3840x2160.png tvOS launch image 2xOpaque RGB on pure black (0, 0, 0), nothing inside the outer 6%: the storyboard, Godot'sboot_splash/bg_color and the image background must be the same black, or the letterbox shows a seam.Pinned by scripts/test/splash-art.test.mjs. Re-run after changing any constant below, then committhe PNGs. Usage: python3 tools/splash/make_splash.py"""import osfrom PIL import Image, ImageDraw, ImageFont
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))SHIP = os.path.join(ROOT, "render", "ship_sprites", "ship3d_cobalt.png")FONT = os.path.join(ROOT, "fonts", "Orbitron-VariableFont_wght.ttf")TVOS = os.path.join(ROOT, "platform", "tvos")OUTS = [ (os.path.join(TVOS, "splash", "boot_splash.png"), 2400, 1350), (os.path.join(TVOS, "patches", "launch", "LaunchImage.launchimage", "launch_1920x1080.png"), 1920, 1080), (os.path.join(TVOS, "patches", "launch", "LaunchImage.launchimage", "launch_3840x2160.png"), 3840, 2160),]BG = (0, 0, 0)CYAN = (90, 230, 255) # brand cyan, marketing/template/make_press.pyWORDMARK = "DARK COSMOS"WEIGHT = 800# Layout as fractions of canvas HEIGHT so every size is the same picture.SHIP_H = 0.40 # ship sprite heightSHIP_CY = 0.40 # ship centre yWORD_PX = 0.085 # wordmark font sizeWORD_Y = 0.68 # wordmark top yTRACK = 0.12 # letter spacing, fraction of font sizeSAFE = 0.06 # keep-out border, fraction of each dimension
def font(px): f = ImageFont.truetype(FONT, px) try: f.set_variation_by_axes([WEIGHT]) except (OSError, ValueError): pass return f
def render(w, h): img = Image.new("RGB", (w, h), BG) ship = Image.open(SHIP).convert("RGBA") sh = round(h * SHIP_H) ship = ship.resize((sh, round(ship.height * sh / ship.width)), Image.LANCZOS) sx, sy = (w - ship.width) // 2, round(h * SHIP_CY) - ship.height // 2 img.paste(ship, (sx, sy), ship) fnt = font(round(h * WORD_PX)) d = ImageDraw.Draw(img) track = fnt.size * TRACK widths = [d.textlength(ch, font=fnt) for ch in WORDMARK] total = sum(widths) + track * (len(WORDMARK) - 1) x, y = (w - total) / 2, round(h * WORD_Y) for ch, cw in zip(WORDMARK, widths): d.text((x, y), ch, font=fnt, fill=CYAN) x += cw + track # Hard guarantee for the border rule (anti-aliasing / glow can spill a pixel or two). bx, by = int(w * SAFE), int(h * SAFE) blank = ImageDraw.Draw(img) blank.rectangle([0, 0, w - 1, by - 1], fill=BG) blank.rectangle([0, h - by, w - 1, h - 1], fill=BG) blank.rectangle([0, 0, bx - 1, h - 1], fill=BG) blank.rectangle([w - bx, 0, w - 1, h - 1], fill=BG) bbox = img.getbbox() assert bbox and bbox[0] >= bx and bbox[1] >= by, f"art too close to the edge: {bbox}" return img
def main(): for path, w, h in OUTS: os.makedirs(os.path.dirname(path), exist_ok=True) render(w, h).save(path, "PNG", optimize=True) print(f"wrote {os.path.relpath(path, ROOT)} {w}x{h}")
if __name__ == "__main__": main()Note: the test’s border is floor(w*0.06); the generator blanks int(w*0.06). Same value.
- Step 5: Generate the art and add the
.gdignore
python3 tools/splash/make_splash.py: > platform/tvos/patches/launch/.gdignoreExpected: three wrote ... lines. If PIL reports the art is too close to the edge, lower SHIP_H/WORD_PX slightly and re-run. Do not loosen the border rule.
- Step 6: Run the test, confirm it passes
Run: node --test scripts/test/splash-art.test.mjs
Expected: # pass 3, # fail 0.
- Step 7: Look at it
Read platform/tvos/splash/boot_splash.png with the Read tool. Check that the ship is centred above a centred, legible “DARK COSMOS” wordmark, nothing is clipped, and there is no visible halo box around the ship. Report the image in the task report.
- Step 8: Stage (controller commits)
git add tools/splash/make_splash.py platform/tvos/splash/boot_splash.png platform/tvos/patches/launch scripts/test/splash-art.test.mjs- Step 9 (CONTROLLER / HUMAN CHECKPOINT): Chris approves the look
Controller shows Chris platform/tvos/splash/boot_splash.png. If he wants a different source (the app-icon ship from platform/tvos/icon.png, a tools/ship_preview SHIP_VARIANT=hero3d render, a tagline, a different layout), only the constants and inputs in make_splash.py change. Re-run Steps 5-7. Tasks 2-4 don’t depend on the art’s content. Don’t let this checkpoint block dispatching Tasks 2-4.
Task 2: Engine boot splash settings
Section titled “Task 2: Engine boot splash settings”Files:
- Modify:
platform/tvos/project.godot([application]section, currently lines 3-7) - Create (generated by import):
platform/tvos/splash/boot_splash.png.import - Test:
scripts/test/splash-art.test.mjs(add a test)
Interfaces:
-
Consumes:
platform/tvos/splash/boot_splash.png(Task 1). -
Produces:
application/boot_splash/image="res://splash/boot_splash.png",bg_color=Color(0, 0, 0, 1),stretch_mode=1(Keep, maps to iOSscaleAspectFit). Task 3’s storyboard usesscaleAspectFitto match. -
Step 1: Add the failing test (append to
scripts/test/splash-art.test.mjs)
test("platform/tvos/project.godot boot splash: our image, pure black, aspect-fit, res:// path", () => { const text = readFileSync(join(TVOS, "project.godot"), "utf8"); const app = text.split(/^\[application\]$/m)[1].split(/^\[/m)[0]; const key = (k) => (app.match(new RegExp(`^${k.replace(/\//g, "\\/")}=(.*)$`, "m")) || [])[1]; // A literal res:// path: main.cpp drops a uid:// splash when the UID cache is not loaded yet // and shows the stock Godot logo instead. assert.equal(key("boot_splash/image"), '"res://splash/boot_splash.png"'); assert.equal(key("boot_splash/bg_color"), "Color(0, 0, 0, 1)"); assert.equal(key("boot_splash/stretch_mode"), "1", "Keep = iOS scaleAspectFit, the storyboard's mode"); assert.equal(key("boot_splash/show_image"), undefined, "default true; never hide the image"); assert.ok(existsSync(join(TVOS, "splash", "boot_splash.png"))); // Root project.godot is the web path and stays on Godot's defaults. assert.ok(!readFileSync(join(ROOT, "project.godot"), "utf8").includes("boot_splash/"));});- Step 2: Run it, confirm it fails
Run: node --test scripts/test/splash-art.test.mjs
Expected: the new test fails at boot_splash/image (undefined !== '"res://splash/boot_splash.png"').
- Step 3: Import the new PNG BEFORE editing project.godot (the import rewrites
project.godot, so the order matters)
perl -e 'alarm 1200; exec @ARGV' ~/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64 --headless --path platform/tvos --import > "$TMPDIR/bh-splash-import.log" 2>&1; echo "exit $?"git status --shortA fresh worktree imports the whole tvOS project the first time, which takes several minutes. Then revert everything the import touched, keeping only the new .import file:
git checkout -- platform/tvos/project.godot audio/bus_layout.tresgit diff --name-only -- '*.import' | xargs -r git checkout --git status --short # expect only untracked: platform/tvos/splash/boot_splash.png.import (plus Task 1's staged files)Expected: platform/tvos/splash/boot_splash.png.import exists and its source_file="res://splash/boot_splash.png". (xargs -r is accepted by macOS xargs.)
- Step 4: Add the settings to
platform/tvos/project.godot
Under [application], after the existing config/features=... line, insert exactly:
; Launch screen + boot splash (spec 2026-10-14-app-clip-warden-design.md): the same art and the same; pure black as the iOS launch storyboard (scripts/release/apply-launch-screen.sh), aspect-fit on both; sides, so the native -> engine handoff shows no grey and no Godot logo. Keep a res:// path here, not; uid:// (main.cpp drops a uid:// splash before the UID cache loads). Pinned by scripts/test/splash-art.test.mjs.boot_splash/bg_color=Color(0, 0, 0, 1)boot_splash/image="res://splash/boot_splash.png"boot_splash/stretch_mode=1Use ; comments only.
- Step 5: Run the test, confirm it passes
Run: node --test scripts/test/splash-art.test.mjs
Expected: # pass 4, # fail 0.
- Step 6: Boot check (headless parse of the project with the new settings)
perl -e 'alarm 300; exec @ARGV' ~/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64 --headless --path platform/tvos --quit > "$TMPDIR/bh-splash-boot.log" 2>&1; echo "exit $?"/usr/bin/grep -i "boot splash\|splash" "$TMPDIR/bh-splash-boot.log" || echo "no splash errors"git checkout -- audio/bus_layout.tres 2>/dev/null; git diff --stat platform/tvos/project.godotExpected: no Non-existing or invalid boot splash line. If the run rewrote project.godot, git diff shows more than your 7 added lines. In that case restore with git checkout -- platform/tvos/project.godot and re-apply Step 4. The 4.8 editor exiting 134 after quitting is the known benign crash.
- Step 7: Gate
Run: scripts/gate.sh
Expected: green. If GUT fails, read $TMPDIR/bh-gut-last.log (set GUT_LOG=$TMPDIR/bh-gut-launch.log if another worktree is gating concurrently).
- Step 8: Stage
git add platform/tvos/project.godot platform/tvos/splash/boot_splash.png.import scripts/test/splash-art.test.mjsTask 3: apply-launch-screen.sh — the native launch screen installer
Section titled “Task 3: apply-launch-screen.sh — the native launch screen installer”Files:
- Create:
platform/tvos/patches/launch/Launch Screen.storyboard(template) - Create:
platform/tvos/patches/launch/LaunchImage.launchimage/Contents.json - Create:
scripts/release/apply-launch-screen.sh(chmod +x) - Test:
scripts/test/apply-launch-screen.test.mjs
Interfaces:
-
Consumes:
platform/tvos/splash/boot_splash.png, the twolaunch_*.png(Task 1). -
Produces:
bash scripts/release/apply-launch-screen.sh <shell dir>. The shell dir holdsBulletHeaven.xcodeproj. Exit 0 printsapply-launch-screen: ok (...). Non-zero means a shell it refuses to patch. Detects tvOS bySDKROOT = appletvosin the pbxproj. Task 4 wires this into the build scripts. -
Step 1: Write the failing test
scripts/test/apply-launch-screen.test.mjs
import { test } from "node:test";import assert from "node:assert/strict";import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "node:fs";import { execFileSync } from "node:child_process";import { createHash } from "node:crypto";import { tmpdir } from "node:os";import { join, dirname, relative } from "node:path";import { fileURLToPath } from "node:url";
// Launch screen + boot splash (spec 2026-10-14-app-clip-warden-design.md):// scripts/release/apply-launch-screen.sh against fixture iOS and tvOS Xcode shells (the lines below// are copied from platform/tvos/build-ios and platform/tvos/build, 2026-10-15).
const here = dirname(fileURLToPath(import.meta.url));const ROOT = join(here, "..", "..");const SCRIPT = join(ROOT, "scripts", "release", "apply-launch-screen.sh");const SPLASH = join(ROOT, "platform", "tvos", "splash", "boot_splash.png");const TEMPLATE = join(ROOT, "platform", "tvos", "patches", "launch", "Launch Screen.storyboard");const LAUNCHIMAGE = join(ROOT, "platform", "tvos", "patches", "launch", "LaunchImage.launchimage");
const IOS_PBX = [ "\t\t90DD2D9E24B36E8000717FE1 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 90DD2D9D24B36E8000717FE1 /* Launch Screen.storyboard */; };", "\t\t90DD2D9D24B36E8000717FE1 = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = \"Launch Screen.storyboard\"; sourceTree = \"<group>\"; };", "\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;", "\t\t\t\tSDKROOT = iphoneos;",].join("\n") + "\n";const TV_PBX = [ "\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = \"App Icon & Top Shelf Image\";", "\t\t\t\tSDKROOT = appletvos;", "\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = \"App Icon & Top Shelf Image\";", "\t\t\t\tSDKROOT = appletvos;",].join("\n") + "\n";const PLIST = (launch) => `<?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>${launch ? "<key>UILaunchStoryboardName</key><string>Launch Screen</string>" : ""}</dict></plist>`;const GREY_SB = '<document><color key="backgroundColor" red="0.14000000059605" green="0.14000000059605" blue="0.14000000059605" alpha="1.0"/><imageView image="SplashImage"/></document>\n';
function shell(kind, { plistLaunch = true, pbx } = {}) { const dir = mkdtempSync(join(tmpdir(), `bh-ls-${kind}-`)); const xc = join(dir, "BulletHeaven", "Images.xcassets"); mkdirSync(join(dir, "BulletHeaven.xcodeproj"), { recursive: true }); mkdirSync(xc, { recursive: true }); writeFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), pbx ?? (kind === "ios" ? IOS_PBX : TV_PBX)); writeFileSync(join(dir, "BulletHeaven", "BulletHeaven-Info.plist"), PLIST(kind === "ios" && plistLaunch)); const stock = kind === "ios" ? "SplashImage.imageset" : "SplashImage_d41d8cd98f00b204e9800998ecf8427e.imageset"; mkdirSync(join(xc, stock)); writeFileSync(join(xc, stock, "Contents.json"), "{}\n"); if (kind === "ios") writeFileSync(join(dir, "BulletHeaven", "Launch Screen.storyboard"), GREY_SB); return dir;}const run = (dir) => execFileSync("bash", [SCRIPT, dir], { encoding: "utf8", stdio: "pipe" });const read = (...p) => readFileSync(join(...p), "utf8");function snapshot(dir) { const out = {}; const walk = (d) => { for (const n of readdirSync(d)) { const p = join(d, n); if (statSync(p).isDirectory()) walk(p); else out[relative(dir, p)] = readFileSync(p).toString("base64"); } }; walk(dir); return out;}const sha12 = createHash("sha256").update(readFileSync(SPLASH)).digest("hex").slice(0, 12);const IMAGE = `SplashImage_dc${sha12}`;
test("tracked storyboard template: black, aspect-fit, the image token in both places", () => { const t = readFileSync(TEMPLATE, "utf8"); assert.equal(t.split("@SPLASH_IMAGE@").length - 1, 2, "imageView image= and the resources <image name=>"); assert.match(t, /<color key="backgroundColor" red="0" green="0" blue="0" alpha="1"/); assert.match(t, /contentMode="scaleAspectFit"/); assert.ok(!t.includes("0.14"), "no Godot grey");});
test("iOS: installs the black storyboard and the boot splash as a content-hashed imageset", () => { const dir = shell("ios"); run(dir); const sb = read(dir, "BulletHeaven", "Launch Screen.storyboard"); assert.ok(sb.includes(`image="${IMAGE}"`), "storyboard points at the hashed imageset"); assert.ok(!sb.includes("@SPLASH_IMAGE@") && !sb.includes("0.14")); const xc = join(dir, "BulletHeaven", "Images.xcassets"); assert.deepEqual(readdirSync(xc).filter((n) => n.startsWith("SplashImage")), [`${IMAGE}.imageset`], "stock SplashImage removed"); for (const f of ["splash@2x.png", "splash@3x.png"]) { assert.ok(readFileSync(join(xc, `${IMAGE}.imageset`, f)).equals(readFileSync(SPLASH)), `${f} is byte-identical to the engine boot splash`); } const contents = JSON.parse(read(xc, `${IMAGE}.imageset`, "Contents.json")); assert.deepEqual(contents.images.map((i) => i.filename).filter(Boolean), ["splash@2x.png", "splash@3x.png"]);});
test("iOS: idempotent", () => { const dir = shell("ios"); run(dir); const first = snapshot(dir); run(dir); assert.deepEqual(snapshot(dir), first);});
test("iOS: refuses a shell whose Info.plist does not name the Launch Screen storyboard", () => { const dir = shell("ios", { plistLaunch: false }); assert.throws(() => run(dir)); assert.equal(read(dir, "BulletHeaven", "Launch Screen.storyboard"), GREY_SB, "untouched on refusal");});
test("iOS: refuses a shell whose pbxproj does not bundle the storyboard", () => { const dir = shell("ios", { pbx: "\t\t\t\tSDKROOT = iphoneos;\n" }); assert.throws(() => run(dir));});
test("tvOS: installs LaunchImage and names it in every build config, no storyboard", () => { const dir = shell("tvos"); run(dir); const pb = read(dir, "BulletHeaven.xcodeproj", "project.pbxproj"); const lines = pb.split("\n").filter((l) => l.includes("ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME")); assert.deepEqual(lines, ["\t\t\t\tASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;", "\t\t\t\tASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;"]); assert.ok(!pb.includes("Launch Screen.storyboard"), "tvOS never gets the iOS storyboard (patch-tvos-shell.sh refuses it)"); assert.ok(!existsSync(join(dir, "BulletHeaven", "Launch Screen.storyboard"))); const li = join(dir, "BulletHeaven", "Images.xcassets", "LaunchImage.launchimage"); for (const f of ["Contents.json", "launch_1920x1080.png", "launch_3840x2160.png"]) { assert.ok(readFileSync(join(li, f)).equals(readFileSync(join(LAUNCHIMAGE, f))), f); }});
test("tvOS: idempotent", () => { const dir = shell("tvos"); run(dir); const first = snapshot(dir); run(dir); assert.deepEqual(snapshot(dir), first);});
test("tvOS: fails loudly when the pbxproj has no app-icon setting to anchor on", () => { const dir = shell("tvos", { pbx: "\t\t\t\tSDKROOT = appletvos;\n" }); assert.throws(() => run(dir));});
test("tracked tvOS LaunchImage descriptor: full-screen landscape tv, 1x and 2x", () => { const c = JSON.parse(readFileSync(join(LAUNCHIMAGE, "Contents.json"), "utf8")); const rows = c.images.map((i) => [i.idiom, i.orientation, i.extent, i.scale, i.filename]); assert.deepEqual(rows, [ ["tv", "landscape", "full-screen", "1x", "launch_1920x1080.png"], ["tv", "landscape", "full-screen", "2x", "launch_3840x2160.png"], ]);});- Step 2: Run it, confirm it fails
Run: node --test scripts/test/apply-launch-screen.test.mjs
Expected: every test fails (missing template / script / Contents.json).
- Step 3: Create the storyboard template
platform/tvos/patches/launch/Launch Screen.storyboard
This is the export’s storyboard with black, aspect-fit, landscape authoring, and a token for the image name. The IDs are kept from the export.
<?xml version="1.0" encoding="UTF-8"?><!-- Dark Cosmos launch screen. TEMPLATE: scripts/release/apply-launch-screen.sh replaces @SPLASH_IMAGE@ with the content-hashed imageset name and writes the result over the export's grey Godot storyboard. Black + aspect-fit, pinned to the view edges: the engine boot splash (platform/tvos/project.godot boot_splash/*) draws the same image with the same rule, so the handoff does not move or flash. --><document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> <device id="retina6_1" orientation="landscape" appearance="dark"/> <dependencies> <deployment identifier="iOS"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <scenes> <!--View Controller--> <scene sceneID="EHf-IW-A2E"> <objects> <viewController id="01J-lp-oVM" sceneMemberID="viewController"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <rect key="frame" x="0.0" y="0.0" width="896" height="414"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="@SPLASH_IMAGE@" translatesAutoresizingMaskIntoConstraints="NO" id="tjZ-vn-Lsv"> <rect key="frame" x="0.0" y="0.0" width="896" height="414"/> </imageView> </subviews> <color key="backgroundColor" red="0" green="0" blue="0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> <constraint firstItem="tjZ-vn-Lsv" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="Ak7-I4-yrQ"/> <constraint firstAttribute="trailing" secondItem="tjZ-vn-Lsv" secondAttribute="trailing" id="Fon-JO-5cz"/> <constraint firstItem="tjZ-vn-Lsv" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="bkx-rj-PKc"/> <constraint firstAttribute="bottom" secondItem="tjZ-vn-Lsv" secondAttribute="bottom" id="yjq-MJ-tym"/> </constraints> <viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/> </view> </viewController> <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> </objects> <point key="canvasLocation" x="52.173913043478265" y="375"/> </scene> </scenes> <resources> <image name="@SPLASH_IMAGE@" width="800" height="450"/> </resources></document>(The <image ... width/height> resource size is IB design metadata in points: 2400x1350 at @3x.)
- Step 4: Create
platform/tvos/patches/launch/LaunchImage.launchimage/Contents.json
{ "images" : [ { "orientation" : "landscape", "idiom" : "tv", "extent" : "full-screen", "minimum-system-version" : "11.0", "filename" : "launch_1920x1080.png", "scale" : "1x" }, { "orientation" : "landscape", "idiom" : "tv", "extent" : "full-screen", "minimum-system-version" : "11.0", "filename" : "launch_3840x2160.png", "scale" : "2x" } ], "info" : { "author" : "xcode", "version" : 1 }}- Step 5: Write the script
scripts/release/apply-launch-screen.sh
#!/usr/bin/env bash# Branded launch screen (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md,# "Launch screen + boot splash"). Installs the native half of the launch into a gitignored Xcode shell;# the engine half is platform/tvos/project.godot boot_splash/* (same image, same pure black).# iOS: writes platform/tvos/patches/launch/Launch Screen.storyboard (black, aspect-fit) over the# export's grey Godot one, and installs platform/tvos/splash/boot_splash.png (byte-identical to# the engine boot splash) as SplashImage_dc<sha12>.imageset. The content hash in the name stops# iOS's launch-screen snapshot cache from showing an older image after an update; the export's# stock SplashImage*.imageset is removed.# tvOS: no launch storyboard (the fork exporter omits it and patch-tvos-shell.sh refuses one), so it# installs patches/launch/LaunchImage.launchimage and sets ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME# in every build config.# A full --export-release regenerates the shell, and --export-pack keeps it. So build-ios.sh, build-tvos.sh and# install-dev.sh run this every time, before xcodebuild. Idempotent; refuses a shell it does not recognise.# Usage: scripts/release/apply-launch-screen.sh <shell dir holding BulletHeaven.xcodeproj># Tests: scripts/test/apply-launch-screen.test.mjsset -euo pipefailROOT=$(cd "$(dirname "$0")/../.." && pwd)TVOS=$ROOT/platform/tvosL=$TVOS/patches/launchG=/usr/bin/grepdie() { echo "apply-launch-screen: $*" >&2; exit 1; }[ -n "${1:-}" ] || { echo "usage: apply-launch-screen.sh <shell dir>" >&2; exit 2; }SH=$(cd "$1" && pwd)PB=$SH/BulletHeaven.xcodeproj/project.pbxprojD=$SH/BulletHeavenXC=$D/Images.xcassets[ -f "$PB" ] || die "no $PB"[ -d "$XC" ] || die "no $XC"
if $G -q "SDKROOT = appletvos" "$PB"; then for f in Contents.json launch_1920x1080.png launch_3840x2160.png; do [ -f "$L/LaunchImage.launchimage/$f" ] || die "missing $L/LaunchImage.launchimage/$f" done n_icon=$($G -c "ASSETCATALOG_COMPILER_APPICON_NAME = " "$PB" || true) [ "$n_icon" -gt 0 ] || die "no ASSETCATALOG_COMPILER_APPICON_NAME in $PB to anchor the launch image on" rm -rf "$XC/LaunchImage.launchimage" cp -R "$L/LaunchImage.launchimage" "$XC/LaunchImage.launchimage" if ! $G -q "ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME" "$PB"; then perl -pi -e 'if (/^(\s*)ASSETCATALOG_COMPILER_APPICON_NAME = /) { $_ .= "${1}ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;\n" }' "$PB" fi n_launch=$($G -c "ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;" "$PB" || true) [ "$n_launch" = "$n_icon" ] || die "LAUNCHIMAGE_NAME is set in $n_launch of $n_icon build configs" echo "apply-launch-screen: ok (tvOS LaunchImage, $n_launch build configs)" exit 0fi
SPLASH=$TVOS/splash/boot_splash.pngTPL="$L/Launch Screen.storyboard"SB="$D/Launch Screen.storyboard"PL=$D/BulletHeaven-Info.plist[ -f "$SPLASH" ] || die "missing $SPLASH (python3 tools/splash/make_splash.py)"[ -f "$TPL" ] || die "missing $TPL"[ -f "$SB" ] || die "no export launch storyboard at $SB (not an iOS export?)"$G -q 'Launch Screen.storyboard in Resources' "$PB" || die "$PB does not bundle Launch Screen.storyboard"[ "$(plutil -extract UILaunchStoryboardName raw "$PL" 2>/dev/null || true)" = "Launch Screen" ] \ || die "$PL UILaunchStoryboardName is not \"Launch Screen\""NAME=SplashImage_dc$(shasum -a 256 "$SPLASH" | cut -c1-12)find "$XC" -maxdepth 1 -name 'SplashImage*.imageset' ! -name "$NAME.imageset" -exec rm -rf {} +mkdir -p "$XC/$NAME.imageset"cp "$SPLASH" "$XC/$NAME.imageset/splash@2x.png"cp "$SPLASH" "$XC/$NAME.imageset/splash@3x.png"cat > "$XC/$NAME.imageset/Contents.json" <<'JSON'{ "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "splash@2x.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "splash@3x.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }}JSONsed "s/@SPLASH_IMAGE@/$NAME/g" "$TPL" > "$SB"$G -q "image=\"$NAME\"" "$SB" || die "storyboard does not reference $NAME"if $G -q "@SPLASH_IMAGE@" "$SB"; then die "unreplaced @SPLASH_IMAGE@ token in $SB"; fiecho "apply-launch-screen: ok (iOS $NAME)"Then chmod +x scripts/release/apply-launch-screen.sh.
- Step 6: Run the tests, confirm they pass
Run: node --test scripts/test/apply-launch-screen.test.mjs
Expected: # pass 9, # fail 0.
- Step 7: Validate the storyboard compiles (needs Xcode, no device)
D=$(mktemp -d); sed 's/@SPLASH_IMAGE@/SplashImage_test/g' "platform/tvos/patches/launch/Launch Screen.storyboard" > "$D/Launch Screen.storyboard"xcrun ibtool --errors --warnings --output-format human-readable-text --compile "$D/LS.storyboardc" "$D/Launch Screen.storyboard"; echo "exit $?"Expected: exit 0 and no error. A warning about the missing image SplashImage_test is fine, because there is no asset catalog in this scratch dir.
- Step 8: Validate the tvOS launch image compiles (needs Xcode, no device)
D=$(mktemp -d); mkdir -p "$D/A.xcassets"; cp -R platform/tvos/patches/launch/LaunchImage.launchimage "$D/A.xcassets/"printf '{"info":{"author":"xcode","version":1}}' > "$D/A.xcassets/Contents.json"xcrun actool "$D/A.xcassets" --compile "$D/out" --platform appletvos --minimum-deployment-target 17.0 --target-device tv \ --launch-image LaunchImage --output-partial-info-plist "$D/partial.plist" --output-format human-readable-text; echo "exit $?"plutil -p "$D/partial.plist"Expected: exit 0, and partial.plist contains UILaunchImages with a UILaunchImageName entry. If actool rejects the launch image or emits no UILaunchImages, STOP and report it. Do not switch tvOS to a launch storyboard. (See Risks: tvOS launch-image support on the current SDK is the plan’s one unverified platform assumption.)
- Step 9: Stage
git add scripts/release/apply-launch-screen.sh "platform/tvos/patches/launch/Launch Screen.storyboard" platform/tvos/patches/launch/LaunchImage.launchimage/Contents.json scripts/test/apply-launch-screen.test.mjsTask 4: Wire the installer into every build path + docs
Section titled “Task 4: Wire the installer into every build path + docs”Files:
- Modify:
scripts/release/build-ios.sh(after theapply-ios-metrickit.shline, currently line 21) - Modify:
scripts/release/build-tvos.sh(afterbash "$TVOS/../../scripts/release/patch-tvos-shell.sh" build, currently line 28) - Modify:
scripts/dev/install-dev.sh(tvOS branch before itscd build, iOS branch after itsapply-ios-metrickit.shline, currently ~line 154) - Modify:
platform/tvos/patches/README.md,platform/tvos/CLAUDE.md,.claude/skills/bh-deploy/SKILL.md,.claude/skills/bh-appstore-release/SKILL.md, then regenerate.factory/ - Test:
scripts/test/apply-launch-screen.test.mjs(add the wiring test)
Interfaces:
-
Consumes:
scripts/release/apply-launch-screen.sh <shell dir>(Task 3). -
Step 1: Add the failing wiring test (append to
scripts/test/apply-launch-screen.test.mjs)
test("every build path runs the installer before it builds", () => { const src = (p) => readFileSync(join(ROOT, p), "utf8"); const CALL = "apply-launch-screen.sh"; const ios = src("scripts/release/build-ios.sh"); assert.ok(ios.includes(CALL) && ios.indexOf(CALL) < ios.indexOf("archive_export_upload"), "build-ios.sh: before archive"); const tv = src("scripts/release/build-tvos.sh"); assert.ok(tv.includes(CALL), "build-tvos.sh runs it"); assert.ok(tv.indexOf("patch-tvos-shell.sh\" build") < tv.indexOf(CALL) && tv.indexOf(CALL) < tv.indexOf("archive_export_upload"), "build-tvos.sh: after patch-tvos-shell.sh, before archive"); // install-dev.sh has exactly two xcodebuild calls (tvOS branch, then iOS branch); each must be preceded // by the installer in its own branch. const parts = src("scripts/dev/install-dev.sh").split("xcodebuild -scheme BulletHeaven"); assert.equal(parts.length, 3, "install-dev.sh: two xcodebuild calls"); assert.ok(parts[0].includes(`${CALL}" "$ROOT/platform/tvos/build"`), "tvOS branch runs it on build/"); assert.ok(parts[1].includes(`${CALL}" "$ROOT/platform/tvos/build-ios"`), "iOS branch runs it on build-ios/");});- Step 2: Run it, confirm it fails
Run: node --test scripts/test/apply-launch-screen.test.mjs
Expected: the new test fails on build-ios.sh: before archive.
- Step 3: Wire
build-ios.sh
After the line
bash "$TVOS/../../scripts/release/apply-ios-metrickit.sh" "$TVOS/build-ios" # telemetry v2: MetricKit sink (a full export regenerates dummy.cpp)add
bash "$TVOS/../../scripts/release/apply-launch-screen.sh" "$TVOS/build-ios" # black Dark Cosmos launch storyboard (the export writes Godot's grey one)- Step 4: Wire
build-tvos.sh
After the line
bash "$TVOS/../../scripts/release/patch-tvos-shell.sh" buildadd
bash "$TVOS/../../scripts/release/apply-launch-screen.sh" "$TVOS/build" # tvOS LaunchImage (the fork exporter writes none)- Step 5: Wire
install-dev.sh
In the tvOS branch, replace
restore_godot_files; restore_presets check_export cd buildwith
restore_godot_files; restore_presets check_export bash "$ROOT/scripts/release/apply-launch-screen.sh" "$ROOT/platform/tvos/build" # idempotent; export-pack keeps the shell, so an older shell gets the launch image here cd buildIn the iOS branch, after
bash "$ROOT/scripts/release/apply-ios-metrickit.sh" "$ROOT/platform/tvos/build-ios" # idempotent; export-pack keeps the shell, a past full export may not have had itadd
bash "$ROOT/scripts/release/apply-launch-screen.sh" "$ROOT/platform/tvos/build-ios" # idempotent; black launch storyboard + hashed splash imageset(The literal cd build appears only once in the tvOS branch. Confirm with /usr/bin/grep -n "cd build" scripts/dev/install-dev.sh before editing. The iOS branch uses cd build-ios.)
- Step 6: Run the tests, confirm they pass
Run: node --test scripts/test/apply-launch-screen.test.mjs scripts/test/install-dev-shell-engine.test.mjs scripts/test/install-dev-no-bake.test.mjs
Expected: all pass. The install-dev tests run the real script up to its dry-run seam. The new lines sit after that seam, so those tests must stay green unchanged.
- Step 7: Docs
platform/tvos/patches/README.md: add rows to the table:
| `launch/Launch Screen.storyboard` | `../build-ios/BulletHeaven/Launch Screen.storyboard` (rendered: `@SPLASH_IMAGE@` → `SplashImage_dc<sha12>`) | Black Dark Cosmos launch screen, aspect-fit, matching the engine boot splash (`project.godot` `boot_splash/*`). The image is `../splash/boot_splash.png`, installed as a content-hashed imageset so iOS's launch-screen cache can't show an older one. || `launch/LaunchImage.launchimage/` | `../build/BulletHeaven/Images.xcassets/LaunchImage.launchimage` + `ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME` | tvOS launch image (tvOS gets no storyboard). Art from `tools/splash/make_splash.py`. |and after the MetricKit paragraph:
The launch screen is applied by `scripts/release/apply-launch-screen.sh <shell dir>` (both shells; run by `build-ios.sh`, `build-tvos.sh` and `install-dev.sh`), never by `cp`. To change the art, edit `tools/splash/make_splash.py`, re-run it and commit the PNGs. The same file is the engine boot splash, so the two halves cannot drift.platform/tvos/CLAUDE.md, in the patches/ bullet, replace “the 3 hand-edited, non-regenerable files” with “the hand-edited, non-regenerable files” and append: “plus the launch-screen assets in patches/launch/ (applied by scripts/release/apply-launch-screen.sh; the engine boot splash in project.godot uses the same splash/boot_splash.png)”.
.claude/skills/bh-deploy/SKILL.md: after the line bash ../../scripts/release/apply-ios-metrickit.sh build-ios # ... add:
bash ../../scripts/release/apply-launch-screen.sh build-ios # idempotent: black Dark Cosmos launch storyboard (tvOS: run it on build).claude/skills/bh-appstore-release/SKILL.md: after the line at ~128 (“iOS: build-ios.sh now runs scripts/release/apply-ios-metrickit.sh …”) add:
Both platforms: build-ios.sh / build-tvos.sh also run scripts/release/apply-launch-screen.sh (black launch storyboard on iOS, LaunchImage on tvOS). If you export by hand, run it on the shell before archiving.Then:
node scripts/port-claude-to-factory.mjsnode --test scripts/test/skills-parity.test.mjs scripts/test/skills-paths.test.mjsExpected: both pass.
- Step 8: Gate
Run: scripts/gate.sh
Expected: green.
- Step 9: Stage
git add scripts/release/build-ios.sh scripts/release/build-tvos.sh scripts/dev/install-dev.sh scripts/test/apply-launch-screen.test.mjs platform/tvos/patches/README.md platform/tvos/CLAUDE.md .claude/skills/bh-deploy/SKILL.md .claude/skills/bh-appstore-release/SKILL.md .factory- Step 10 (CONTROLLER, at merge): What’s new item
This is player-visible. Add to the build: null entry in control/src/whats_new.js (create the entry if absent, per the file’s header):
{ text: 'Launch: the app now opens on a black Dark Cosmos screen (the Cobalt hull and the name) instead of a grey screen with the Godot logo, and the same picture stays in place while the game loads. Judge whether there is any grey or white flash, or any jump in the picture, between tapping the icon and the menu.', where: 'Launch the app from the home screen (after a fresh install too)' },Run node --test control/test/whats-new.test.mjs after.
Task 5: On-device verification (HUMAN + controller; not an implementer task)
Section titled “Task 5: On-device verification (HUMAN + controller; not an implementer task)”Runs after Tasks 1-4 are reviewed and merged, or from the branch via bh-worktree-device-install. Device installs use Chris’s devices. Don’t run them while a human A/B or playtest is live (root CLAUDE.md).
- Step 1 (controller, Mac): apply to the real shells and check the build artefacts
From the checkout that will install (main after merge, or the worktree’s private shell copy):
bash scripts/release/apply-launch-screen.sh platform/tvos/build-iosbash scripts/release/apply-launch-screen.sh platform/tvos/buildExpected: both print ok. Then do the normal Dev installs (Step 2) and, on each built .app:
plutil -p platform/tvos/build/DerivedData-dev/Build/Products/Release-appletvos/BulletHeaven.app/Info.plist | /usr/bin/grep -A6 UILaunchImagesxcrun assetutil --info platform/tvos/build/DerivedData-dev/Build/Products/Release-appletvos/BulletHeaven.app/Assets.car | /usr/bin/grep -i launch | headstrings platform/tvos/build-ios/BulletHeaven.pck | /usr/bin/grep -c "splash/boot_splash.png"Expected: tvOS Info.plist has UILaunchImages. Assets.car lists the launch image. The iOS pack contains splash/boot_splash.png (count ≥ 1).
- Step 2 (HUMAN, Chris confirms first: uninstall wipes the Dev app’s save): fresh install
Follow skill bh-fresh-install-stall-check end to end on iPhone, iPad and Apple TV: Metal-toolchain pre-flight, BUILD number past every live branch, devicectl device uninstall app, install-dev.sh <iphone|ipad|atv>, play, then query hitches. The fresh install matters twice here. It is the only state where iOS has no cached launch-screen snapshot, and the root CLAUDE.md says to uninstall and reinstall before blaming code for a missing or stale draw.
- Step 3 (HUMAN): handoff visual check, each device, cold launch
Force-quit, then launch from the home screen while recording. iPhone/iPad: Control Center screen recording. Apple TV: film the TV with a phone at 60 fps or slo-mo. Pass criteria, scrubbing frame by frame:
- No grey frame at any point (the old storyboard was grey
0.14). - No Godot logo at any point.
- The art does not jump in size or position between the native launch screen and the engine boot splash (iOS: storyboard
scaleAspectFit↔ enginestretch_mode=1). A small brightness change in the art at handoff is possible (see Risks). Note it, don’t fail on it. - Letterbox/pillarbox bars stay black with no seam.
- The cut from splash to the first menu frame (clear colour
0.03, 0.03, 0.06plus the menu backdrop) is acceptable to Chris. - Launch over the top of an older install too (no uninstall). If iOS still shows the old grey screen, power-cycle the device once. The hashed imageset name should prevent this. Record the result.
- Step 4 (HUMAN, optional): macOS and Android
Both presets read platform/tvos/project.godot, so they now show the black boot splash too. Glance at a bh-deploy-mac-dev launch. Android has no native splash change in this plan.
- Step 5 (controller): record the result
Tick the launch-screen item in docs/RELEASE-1.1.md (or the 1.2 list if it misses the 10-17 freeze) as ✅ DONE (commit), with the device and BUILD it was verified on.
Risks and spec gaps (for the controller and Chris)
Section titled “Risks and spec gaps (for the controller and Chris)”- tvOS launch image is the one unverified platform assumption. The fork’s tvOS exporter deliberately emits no launch storyboard (“tvOS rejects it”). This plan uses an asset-catalog
LaunchImage.launchimage(idiom: tv, 1x 1920x1080 / 2x 3840x2160). Task 3 Step 8 checks it withactoolbefore anything ships. If the current SDK refuses it, tvOS keeps the system’s black launch and gets only the engine boot splash. That is still no grey and no Godot logo, so the spec’s intent holds for tvOS. - First engine frame after the splash is
default_clear_color(0.03, 0.03, 0.06), not black. The spec says “black”. The splash is pure black so that it matches exactly across colour spaces. The step to the menu is ~8/255 navy. Changing the clear colour affects the whole game, so it is out of scope. Task 5 Step 3.5 asks Chris to judge it. - Art brightness may differ native vs engine. The engine’s Metal output path (P3/EDR,
hdr_alphawork) is not UIKit’s. The same PNG may read slightly brighter or dimmer once the engine takes over. Black stays black. Task 5 checks it by eye. - Splash art is a creative call the spec leaves open (“key art”). There is no existing logo or wordmark. The plan composes Cobalt hull + Orbitron wordmark. The Cobalt sprite is 512 px, so the 3840x2160 tvOS render upscales it ~1.7x (soft on a 4K TV). The fix is a higher-res source (
tools/ship_previewhero3d). Chris’s checkpoint is Task 1 Step 9. - Spec says the storyboard’s “tracked copy in
patches/”. The plan tracks a template with an image-name token, not a literal copy. The token lets the imageset carry a content hash so iOS’s launch-snapshot cache can’t keep showing old art. Godot’s own exporter hashes the name for the same reason. - Uncommitted
project.godotedits get wiped by build scripts.build-ios.shandbuild-tvos.shgit checkout -- project.godotafter their windowed export. Commit Task 2 before any device build from the branch. - App Clip reuse. When the clip target lands,
add-app-clip-target.shmust give the clip its own launch storyboard. The simplest route is to point it at the same renderedLaunch Screen.storyboardand hashed imageset. Note this for that plan; nothing here blocks it. - Release timing. The spec says this section “can ship earlier (e.g. 1.1)”. The 1.1 feature freeze is 2026-10-17, and per root CLAUDE.md the item goes on
docs/RELEASE-1.1.mdbefore work starts. That is Chris’s call.
Execution handoff
Section titled “Execution handoff”- Planned 2026-10-15 by a planning subagent; no code written, nothing committed by the planner (the controller commits the plan).
- Scope ruling from the dispatching controller: ONLY the spec’s “Launch screen + boot splash” section. Follow the
apply-ios-metrickit.shpattern (tracked source + idempotent script wired into the build scripts), never a manual step. - Discovered during planning: Godot’s own iOS exporter already derives the storyboard image/background from
application/boot_splash/*, butinstall-dev.shuses--export-pack, which never regenerates the shell, and the exporter’s storyboardcontentModereads the deprecatedboot_splash/fullsize. That is why the installer script overwrites the storyboard instead of relying on the export. - Needs Chris: the art (Task 1 Step 9), every device install / uninstall (Task 5), and whether this goes into 1.1 (freeze 2026-10-17) or 1.2.
- Next action: controller commits this plan, consults
bh-sdd-concurrent, creates the worktree, and dispatches Task 1.