Skip to content

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 consult bh-sdd-concurrent before 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.

  • Spec: “Replace the iOS Launch Screen.storyboard (currently grey 0.14 + Godot’s stock SplashImage) with a black background + Dark Cosmos key art; tracked copy in patches/.”
  • Spec: “Set application/boot_splash/* (image + black bg) in platform/tvos/project.godot to 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: storyboard backgroundColor, 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. Only platform/tvos/project.godot (iOS, tvOS, macOS, Android presets all read it).
  • application/boot_splash/image must stay a literal res:// path, never uid://. main/main.cpp (fork, ~line 3966) silently drops a uid:// boot splash when the UID cache is not loaded yet and shows the default Godot logo instead.
  • platform/tvos/project.godot comments 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, never cp by hand.
  • Do not touch scripts/release/patch-tvos-shell.sh’s guard that refuses a tvOS pbxproj referencing Launch 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.sh for GUT, scripts/gate.sh for the full gate (it runs node --test scripts/test/*.test.mjs). Never pull a raw GUT run into context.
  • Any Godot invocation (headless --import included) can rewrite platform/tvos/project.godot and audio/bus_layout.tres. Check git status after each one and git checkout -- both before committing anything you did not mean to change.
  • Skill edits: edit .claude/skills/<name>/SKILL.md, then run node scripts/port-claude-to-factory.mjs and commit both sides (.factory/ is generated).
  • The controller commits. Implementers stage and report. They do not run git commit unless the controller tells them to.
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 (branch tvos-4.8dev, a8d5c0bb62):
    • platform/ios/export/export_plugin.cpp: a full iOS export writes SplashImage.imageset/splash@2x.png and @3x.png from application/boot_splash/image (when the preset’s storyboard/custom_image@* are empty, as ours are) and the storyboard background from application/boot_splash/bg_color. The storyboard contentMode comes from the deprecated application/boot_splash/fullsize (so a full export would write center). That is one reason the installer script overwrites the storyboard instead of relying on the export.
    • get_custom_project_settings() injects ios/launch_screen_image_mode from boot_splash/stretch_mode (1 = Keep → scaleAspectFit). drivers/apple_embedded/os_apple_embedded.mm::calculate_boot_screen_rect uses 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_files always 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.plist has UILaunchStoryboardName = Launch Screen. The pbxproj bundles it as 90DD2D9E24B36E8000717FE1 /* Launch Screen.storyboard in Resources */.
  • tvOS shell pbxproj has two ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; lines (Debug/Release), no LAUNCHIMAGE, and SDKROOT = appletvos.
  • The game is landscape-only (iPhone LandscapeLeft, iPad LandscapeRight).
  • platform/tvos/project.godot default_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.png is a gameplay screenshot with HUD (unsuitable). The splash is composed from render/ship_sprites/ship3d_cobalt.png (512x512 RGBA; Cobalt is the clip’s hull) + the Orbitron wordmark in brand cyan (90, 230, 255) (as marketing/template/make_press.py uses).

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)

Terminal window
cd /Users/chris/Claude/bullet-heaven && git fetch origin && git worktree add .claude/worktrees/launch-splash -b launch-splash origin/main
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/launch-splash

All 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 2x
Opaque RGB on pure black (0, 0, 0), nothing inside the outer 6%: the storyboard, Godot's
boot_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 commit
the PNGs. Usage: python3 tools/splash/make_splash.py
"""
import os
from 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.py
WORDMARK = "DARK COSMOS"
WEIGHT = 800
# Layout as fractions of canvas HEIGHT so every size is the same picture.
SHIP_H = 0.40 # ship sprite height
SHIP_CY = 0.40 # ship centre y
WORD_PX = 0.085 # wordmark font size
WORD_Y = 0.68 # wordmark top y
TRACK = 0.12 # letter spacing, fraction of font size
SAFE = 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
Terminal window
python3 tools/splash/make_splash.py
: > platform/tvos/patches/launch/.gdignore

Expected: 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)
Terminal window
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.


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 iOS scaleAspectFit). Task 3’s storyboard uses scaleAspectFit to 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)
Terminal window
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 --short

A 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:

Terminal window
git checkout -- platform/tvos/project.godot audio/bus_layout.tres
git 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=1

Use ; 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)
Terminal window
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.godot

Expected: 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
Terminal window
git add platform/tvos/project.godot platform/tvos/splash/boot_splash.png.import scripts/test/splash-art.test.mjs

Task 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 two launch_*.png (Task 1).

  • Produces: bash scripts/release/apply-launch-screen.sh <shell dir>. The shell dir holds BulletHeaven.xcodeproj. Exit 0 prints apply-launch-screen: ok (...). Non-zero means a shell it refuses to patch. Detects tvOS by SDKROOT = appletvos in 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.mjs
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
TVOS=$ROOT/platform/tvos
L=$TVOS/patches/launch
G=/usr/bin/grep
die() { 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.pbxproj
D=$SH/BulletHeaven
XC=$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 0
fi
SPLASH=$TVOS/splash/boot_splash.png
TPL="$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 }
}
JSON
sed "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"; fi
echo "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)
Terminal window
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)
Terminal window
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
Terminal window
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.mjs

Task 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 the apply-ios-metrickit.sh line, currently line 21)
  • Modify: scripts/release/build-tvos.sh (after bash "$TVOS/../../scripts/release/patch-tvos-shell.sh" build, currently line 28)
  • Modify: scripts/dev/install-dev.sh (tvOS branch before its cd build, iOS branch after its apply-ios-metrickit.sh line, 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

Terminal window
bash "$TVOS/../../scripts/release/apply-ios-metrickit.sh" "$TVOS/build-ios" # telemetry v2: MetricKit sink (a full export regenerates dummy.cpp)

add

Terminal window
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

Terminal window
bash "$TVOS/../../scripts/release/patch-tvos-shell.sh" build

add

Terminal window
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

Terminal window
restore_godot_files; restore_presets
check_export
cd build

with

Terminal window
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 build

In the iOS branch, after

Terminal window
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 it

add

Terminal window
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:

Terminal window
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:

Terminal window
node scripts/port-claude-to-factory.mjs
node --test scripts/test/skills-parity.test.mjs scripts/test/skills-paths.test.mjs

Expected: both pass.

  • Step 8: Gate

Run: scripts/gate.sh Expected: green.

  • Step 9: Stage
Terminal window
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):

Terminal window
bash scripts/release/apply-launch-screen.sh platform/tvos/build-ios
bash scripts/release/apply-launch-screen.sh platform/tvos/build

Expected: both print ok. Then do the normal Dev installs (Step 2) and, on each built .app:

Terminal window
plutil -p platform/tvos/build/DerivedData-dev/Build/Products/Release-appletvos/BulletHeaven.app/Info.plist | /usr/bin/grep -A6 UILaunchImages
xcrun assetutil --info platform/tvos/build/DerivedData-dev/Build/Products/Release-appletvos/BulletHeaven.app/Assets.car | /usr/bin/grep -i launch | head
strings 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:

  1. No grey frame at any point (the old storyboard was grey 0.14).
  2. No Godot logo at any point.
  3. The art does not jump in size or position between the native launch screen and the engine boot splash (iOS: storyboard scaleAspectFit ↔ engine stretch_mode=1). A small brightness change in the art at handoff is possible (see Risks). Note it, don’t fail on it.
  4. Letterbox/pillarbox bars stay black with no seam.
  5. The cut from splash to the first menu frame (clear colour 0.03, 0.03, 0.06 plus the menu backdrop) is acceptable to Chris.
  6. 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)”
  1. 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 with actool before 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.
  2. 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.
  3. Art brightness may differ native vs engine. The engine’s Metal output path (P3/EDR, hdr_alpha work) 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.
  4. 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_preview hero3d). Chris’s checkpoint is Task 1 Step 9.
  5. 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.
  6. Uncommitted project.godot edits get wiped by build scripts. build-ios.sh and build-tvos.sh git checkout -- project.godot after their windowed export. Commit Task 2 before any device build from the branch.
  7. App Clip reuse. When the clip target lands, add-app-clip-target.sh must give the clip its own launch storyboard. The simplest route is to point it at the same rendered Launch Screen.storyboard and hashed imageset. Note this for that plan; nothing here blocks it.
  8. 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.md before work starts. That is Chris’s call.
  • 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.sh pattern (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/*, but install-dev.sh uses --export-pack, which never regenerates the shell, and the exporter’s storyboard contentMode reads the deprecated boot_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.