Beta skin — implementation plan
Beta Skin Implementation Plan
Section titled “Beta Skin 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, consultbh-sdd-concurrentbefore Task 1 and runsdd-worktree-leak-guardafter every dispatch.
Goal: Add a render-only cosmetic ship-skin layer to the full app: four candidate looks behind a live console tunable, owned/equipped skins persisted in MetaState, an equip row in the hull picker, and an idempotent MetaState.grant_skin() for the later App Clip handoff to call.
Architecture: MetaState gains owned_skins + equipped_skin and a pure grant_skin(id) -> bool. A new ShipSkin node (render/ship_skin.gd) is a child of PlayerRenderer: an overlay Sprite2D that copies the hull sprite’s texture, transform and visibility every frame and repaints it with one saved shader (render/shaders/ship_skin.gdshader, the look is a uniform). Nothing under /sim is touched, so the determinism baselines cannot move. The hull picker (ui/ship_config_panel.gd) gets a pre-run skin row that is only shown when a skin is owned.
Tech Stack: Godot 4.8, typed GDScript, canvas_item shaders, GUT, Node (tools/tunable_docs/generate.mjs, control/test).
Spec: docs/superpowers/specs/2026-10-14-app-clip-warden-design.md. This plan covers ONLY its section “Beta skin (full app)” plus the full-app side of the clip handoff’s grant API. The App Group reading, the native bridge and the clip itself are a separate plan.
Global Constraints
Section titled “Global Constraints”- Skins are render-only and separate from hulls. Hull stats live in
sim/ship_bonuses.gd. No file undersim/other thansim/meta_state.gdmay change.MetaStateis never read bySim.tick. - The three pinned determinism tests (
tests/test_determinism_survival.gd,tests/test_determinism.gd,tests/test_determinism_coop.gd) must stay green untouched. If one fails, stop: this feature can’t legitimately move them. - Old saves must load: the new save fields are additive.
SCHEMA_VERSION/MIN_COMPAT_SCHEMAstay at 1. Corrupt values fall back per field (the_sane_*salvage rule insim/meta_state.gd). - Four candidate looks behind a live console tunable (the spec calls it
ship_skin; repo keys are<Class>.<NAME>, so it isShipSkin.LOOK). 0 = off (follow the equipped skin), 1 Prototype wireframe, 2 Holo-film, 3 Glitch build, 4 Void chrome.- Prototype wireframe: glowing cyan edge lines over a near-black fill, slow scanline sweep.
- Holo-film: oil-slick iridescence that shifts hue with banking; reuses the Supernova thin-film work (
iridesce()infx/weapons/nova_burst.gdshader). - Glitch build: RGB channel split, brief corruption jitter on warp/hit; must not read as a bug (subtle by default).
- Void chrome: black mirror-chrome, single white-hot rim, faint violet afterimage trail.
- App Store rule (Apple 2.3.1, root CLAUDE.md): a remote-config value must not switch an unreviewed feature on for store players.
ShipSkin.LOOKis honoured only whenBuildConfig.dev_tools(); store builds draw a skin only if the player owns and equips one. - Every new visual tuning value is a registered Remote Console tunable: a
static var, oneTunablesRegistry.ENTRIESrow, a label intools/tunable_docs/docs.d/ship_skin.json, thennode tools/tunable_docs/generate.mjs(control/src/tunable_docs.jsis GENERATED: never hand-edit it). Skin keys are"local": true(render-only, per device). All are read per frame (LIVE), never in_ready/_init/_build_*/_spawn*(thetests/test_live_scope.gdone-shot scan). - Shaders: saved materials only (
preload(...).duplicate(), neverShaderMaterial.new()). Translucent output goes throughhdr_alpha()as the LAST write ofCOLOR.a.vec3colour uniforms arrive raw: each passes throughcs_in()in the shader, and is pushed asColorGamut.out(c, HdrTier.PATH_B). Modulate colours useHdrTier.PATH_A. - Two colour systems: every colour/gain that matters has an SDR value and an
*_HDRtwin, picked byHdrOutput.on()(soWIRE_FILLhasWIRE_FILL_HDR). Shape constants baked in the shader (not tunables, named in the shader header): chrome body/band colours, scan strength, holo brightness curve and floor, glitch row count/rate/slide/probability/widen. - A new
.gdshadermust be drawn byrender/prewarm_pass.gd(tests/test_prewarm_coverage.gd) and needs a saved material (tests/test_baked_shader_materials.gd). - The headless gate never compiles shaders. After any shader edit, run
tools/shader_check/compile_check.gdwindowed under BOTH--rendering-method mobileand--rendering-method gl_compatibility, thengit checkout -- project.godot audio/bus_layout.tres. - The Supernova shaders (
fx/weapons/nova_burst.gdshader,fx/weapons/supernova_halo.gdshader) are not edited: shader edits can re-trigger the A17 shader-baker draw bug. The skin’s thin-film helper is a verbatim copy pinned equal by a test. - UI fit tests run at the real device profiles (
tests/shop_layout_fixture.gd: TV 1280, iPhone ~1204, iPad ~985 logical px wide after the 1.3x touch zoom), with worst-case content (a locked hull shows the extra LOCKED line). - Player-visible copy: the skin is “Beta”. No em-dashes in player-facing strings.
- Work in your own worktree (ALREADY CREATED at
.claude/worktrees/beta-skin, branchbeta-skin: do not re-rungit worktree add; never branch inside the main checkout). Run the full suite only throughscripts/check-test-count.sh(setGUT_LOG=$TMPDIR/bh-gut-beta-skin.logso a concurrent gate can’t overwrite it), and grep that log. Before any headless-stool run after newclass_names:godot --headless --path . --import, thengit checkout -- project.godot audio/bus_layout.tres. - Integration note (cross-plan):
2026-10-15-app-clip-warden.md(worktree.claude/worktrees/app-clip) touches the same tails:net/tunables_registry.gd(rows before the closing]ofENTRIES), the group-order array intools/tunable_docs/generate.mjs,control/src/tunable_docs.js, and the end ofdocs/architecture/remote-config.md. At merge time union-resolve those and RE-RUNnode tools/tunable_docs/generate.mjs(never hand-mergetunable_docs.js). The grant contract (grant_skin(id) -> bool,owned_skins) already matches. - No commits by implementers in this plan’s steps beyond what the controller asks: the controller commits after each task’s review.
File structure
Section titled “File structure”| File | Status | Responsibility |
|---|---|---|
sim/meta_state.gd |
modify | KNOWN_SKINS, owned_skins, equipped_skin, owns_skin, grant_skin, equip_skin; save/load |
tests/test_meta_skins.gd |
create | grant/equip semantics, save round trip, old + corrupt saves |
render/shaders/thin_film.gdshaderinc |
create | verbatim copy of the Supernova iridesce() |
render/shaders/ship_skin.gdshader |
create | the four looks, one pipeline |
render/shaders/ship_skin_mat.tres |
create | saved material (baker + prewarm) |
render/prewarm_pass.gd |
modify | family 3b draws the skin material |
tests/test_ship_skin_shader.gd |
create | thin-film drift guard, saved material, remap/cs_in present |
render/ship_skin.gd |
create | ShipSkin overlay node + its tunables + uniform push + chrome ghosts |
net/tunables_registry.gd |
modify | SHIP_SKIN const + 23 rows |
tools/tunable_docs/docs.d/ship_skin.json |
create | console labels |
tools/tunable_docs/generate.mjs |
modify | Ship skins group rule + order |
control/src/tunable_docs.js |
regenerate | generated |
tests/test_ship_skin.gd |
create | ShipSkin unit tests + tunable registration |
render/player_renderer.gd |
modify | owns the ShipSkin, feeds tilt, skin_pulse() |
main.gd |
modify | passes meta.equipped_skin, pulses on warp/hard hit, grant_skin dev command |
tests/test_ship_skin_main.gd |
create | through the real main |
tests/test_main.gd |
modify | dev_commands caps list gains grant_skin |
ui/ship_config_panel.gd |
modify | skin row, thumbnail preview, pad/touch input, layout |
tests/test_ship_config_skin_row.gd |
create | row behaviour + device-profile fit |
tools/vfx_library/library_manifest.gd |
modify | ship_skin entry + score |
tests/test_vfx_library_manifest.gd |
modify | ship_skin in EXPECTED_EFFECTS |
tools/skin_capture/skin_capture.gd + .tscn |
create | windowed capture: 4 looks x SDR 8-bit / linear SDR / HDR |
docs/architecture/meta-and-story.md, docs/architecture/rendering-and-performance.md, docs/architecture/remote-config.md |
modify | current-architecture sections |
Task 1: MetaState skins + the grant API
Section titled “Task 1: MetaState skins + the grant API”Files:
- Modify:
sim/meta_state.gd(new block afterselected_ship;to_dict();from_dict()) - Create:
tests/test_meta_skins.gd - Modify:
docs/architecture/meta-and-story.md(append a subsection to “Meta-progression”)
Interfaces:
-
Produces (THE CONTRACT the App Clip handoff plan calls; do not rename):
const KNOWN_SKINS := ["beta"]var owned_skins: Array[String](grant order, no duplicates, only KNOWN_SKINS ids)var equipped_skin: String(""= none; always “” or an owned id)func owns_skin(id: String) -> boolfunc grant_skin(id: String) -> bool— true only when newly added; equips it if nothing is equipped; unknown or already-owned id = false and NO change (idempotent). Does NOT save: the caller runsMetaStore.save_state(meta)when it returns true.func equip_skin(id: String) -> bool—""always succeeds; an unowned id = false, no change.- Save keys:
"owned_skins"(Array of String),"equipped_skin"(String).
-
Step 1: Write the failing test — create
tests/test_meta_skins.gd:
extends GutTest
# MetaState skins (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md § Beta skin):# render-only cosmetics, granted (App Clip handoff, dev console), never bought, persisted additively.# GUT runs with MetaStore.disk_io_enabled = false; load_from() with explicit paths still reads, as in# tests/test_meta_corrupt_save.gd.
const TMP := "user://_test_meta_skins.json"
func after_each() -> void: if FileAccess.file_exists(TMP): DirAccess.remove_absolute(ProjectSettings.globalize_path(TMP))
func _write(d: Dictionary) -> void: var f := FileAccess.open(TMP, FileAccess.WRITE) f.store_string(JSON.stringify(d)) f.close()
func test_fresh_save_owns_no_skin() -> void: var m := MetaState.new() assert_eq(m.owned_skins.size(), 0) assert_eq(m.equipped_skin, "") assert_false(m.owns_skin("beta"))
func test_grant_skin_adds_and_equips_once() -> void: var m := MetaState.new() assert_true(m.grant_skin("beta"), "first grant adds") assert_true(m.owns_skin("beta")) assert_eq(m.equipped_skin, "beta", "the first skin is equipped so it shows on the next run") assert_false(m.grant_skin("beta"), "re-grant is a no-op (a re-read handoff file is harmless)") assert_eq(m.owned_skins.size(), 1, "no duplicate")
func test_regrant_does_not_re_equip_after_the_player_unequipped() -> void: var m := MetaState.new() m.grant_skin("beta") assert_true(m.equip_skin("")) assert_false(m.grant_skin("beta")) assert_eq(m.equipped_skin, "", "the player's choice stands")
func test_grant_unknown_skin_is_refused() -> void: var m := MetaState.new() assert_false(m.grant_skin("gold_plated")) assert_false(m.grant_skin("")) assert_eq(m.owned_skins.size(), 0) assert_eq(m.equipped_skin, "")
func test_equip_requires_ownership() -> void: var m := MetaState.new() assert_false(m.equip_skin("beta"), "not owned") assert_eq(m.equipped_skin, "") m.grant_skin("beta") m.equip_skin("") assert_true(m.equip_skin("beta")) assert_eq(m.equipped_skin, "beta")
func test_skins_round_trip_through_the_real_save_path() -> void: var m := MetaState.new() m.grant_skin("beta") _write(m.to_dict()) var m2 := MetaStore.load_from([TMP]) assert_true(MetaStore.last_load_compatible) assert_eq(m2.owned_skins.size(), 1) assert_true(m2.owns_skin("beta")) assert_eq(m2.equipped_skin, "beta")
func test_a_save_from_before_skins_loads_with_none() -> void: _write({"schema": MetaState.SCHEMA_VERSION, "banked_gold": 50, "levels": {"vitality": 1}, "selected_ship": "amethyst"}) var m := MetaStore.load_from([TMP]) assert_true(MetaStore.last_load_compatible) assert_eq(m.banked_gold, 50, "the rest of an old save still loads") assert_eq(m.level_of("vitality"), 1) assert_eq(m.owned_skins.size(), 0) assert_eq(m.equipped_skin, "")
func test_corrupt_skin_fields_salvage_the_rest_of_the_save() -> void: for bad: Variant in [null, "beta", 7, {"beta": true}]: _write({"schema": MetaState.SCHEMA_VERSION, "banked_gold": 9, "owned_skins": bad, "equipped_skin": "beta"}) var m := MetaStore.load_from([TMP]) assert_true(MetaStore.last_load_compatible, "%s: still a readable save" % [bad]) assert_eq(m.banked_gold, 9, "%s: the rest loads" % [bad]) assert_eq(m.owned_skins.size(), 0, "%s: no skins" % [bad]) assert_eq(m.equipped_skin, "", "%s: an unowned equipped skin is dropped" % [bad])
func test_unknown_and_duplicate_skin_ids_are_filtered() -> void: _write({"schema": MetaState.SCHEMA_VERSION, "owned_skins": ["beta", "nope", 3, "beta"], "equipped_skin": "nope"}) var m := MetaStore.load_from([TMP]) assert_eq(m.owned_skins.size(), 1) assert_true(m.owns_skin("beta")) assert_eq(m.equipped_skin, "", "equipped must be an owned id")
func test_corrupt_equipped_skin_falls_back_to_none() -> void: for bad: Variant in [null, 5, ["beta"]]: _write({"schema": MetaState.SCHEMA_VERSION, "owned_skins": ["beta"], "equipped_skin": bad}) var m := MetaStore.load_from([TMP]) assert_true(m.owns_skin("beta"), "%s: ownership survives" % [bad]) assert_eq(m.equipped_skin, "", "%s: none equipped" % [bad])
func test_shop_reset_keeps_skins() -> void: var m := MetaState.new() m.grant_skin("beta") m.reset_shop([]) assert_true(m.owns_skin("beta"), "a skin is granted, not bought: a respec keeps it") assert_eq(m.equipped_skin, "beta")- Step 2: Run it to verify it fails
Run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_meta_skins.gd -gexit 2>&1 | tail -15
Expected: FAIL / parse errors naming owned_skins / grant_skin (not defined on MetaState).
- Step 3: Implement — in
sim/meta_state.gd, directly after theselected_shipline add:
# Cosmetic ship skins (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md § Beta skin).# Render-only (render/ship_skin.gd draws them): never read by Sim.tick and never a stat (hull stats live# in ShipBonuses). GRANTED, never bought (the App Clip handoff, the dev console), so reset_shop() leaves# them alone. KNOWN_SKINS is the whitelist from_dict() filters a loaded save against.const KNOWN_SKINS := ["beta"]var owned_skins: Array[String] = [] # grant order, no duplicatesvar equipped_skin: String = "" # "" = no skin (the hull's own look); otherwise an owned id
func owns_skin(id: String) -> bool: return owned_skins.has(id)
# THE grant contract (the App Clip handoff plan calls this): add a skin. True only when it was newly# added; an owned or unknown id changes nothing, so re-reading a handoff file is harmless. The first# skin is also equipped when none is, so an earned skin shows on the next run without a trip to the# hull picker. Pure state change: the caller saves (MetaStore.save_state) when this returns true.func grant_skin(id: String) -> bool: if not (id in KNOWN_SKINS) or owns_skin(id): return false owned_skins.append(id) if equipped_skin == "": equipped_skin = id return true
# Equip an owned skin, or "" for none. An unowned id is refused (false) and changes nothing.func equip_skin(id: String) -> bool: if id != "" and not owns_skin(id): return false equipped_skin = id return trueIn to_dict(), add two keys to the returned dictionary (after "drone_loadout": drone_loadout.duplicate()):
"owned_skins": owned_skins.duplicate(), "equipped_skin": equipped_skin}(i.e. replace the closing "drone_loadout": drone_loadout.duplicate()} with "drone_loadout": drone_loadout.duplicate(), "owned_skins": owned_skins.duplicate(), "equipped_skin": equipped_skin}).
At the end of from_dict() (after the drone_loadout block) add:
# Skins (additive, 2026-10-15): older saves have neither key and load with none. Same salvage rule as # every other field: a corrupt value drops only the skins, never the rest of the save. owned_skins.clear() var sk: Variant = d.get("owned_skins", []) if sk is Array: for s: Variant in sk: if s is String and KNOWN_SKINS.has(s) and not owned_skins.has(s): owned_skins.append(s) var eq: Variant = d.get("equipped_skin", "") equipped_skin = String(eq) if eq is String and owned_skins.has(String(eq)) else ""Do NOT touch reset_shop() (skins are not purchases); add one line to its doc comment: # Skins (owned_skins/equipped_skin) are granted, not bought: untouched.
- Step 4: Run it to verify it passes
Run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_meta_skins.gd -gexit 2>&1 | tail -15
Expected: all 11 tests PASS. Then godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_meta_corrupt_save.gd -gexit 2>&1 | tail -5 and the same for tests/test_meta_state.gd: PASS (unchanged behaviour).
- Step 5: Document — append to
docs/architecture/meta-and-story.md, at the end of the “Meta-progression” section (before## Storyline mode):
### Ship skins (2026-10-15, plan `2026-10-15-beta-skin`)
`MetaState.owned_skins` (Array[String], grant order) + `equipped_skin` ("" = none) hold cosmetic skins.They are render-only (drawn by `render/ship_skin.gd`, see rendering-and-performance.md § Ship skins), arenever read by `Sim.tick`, and are granted, never bought: `reset_shop()` keeps them. `KNOWN_SKINS`(`["beta"]`) whitelists what a loaded save may contain; corrupt values salvage per field like the rest of`from_dict()`. **Grant contract:** `MetaState.grant_skin(id) -> bool` is idempotent (true only whennewly added; equips it when nothing is equipped) and does not save; the caller saves on true. The AppClip handoff (spec `2026-10-14-app-clip-warden-design.md`) and the Dev console's `grant_skin` commandboth go through it. Pinned by `tests/test_meta_skins.gd`.- Step 6: Suite —
GUT_LOG=$TMPDIR/bh-gut-beta-skin.log bash scripts/check-test-count.sh→ green (the test-count guard may need its expected count bumped; follow its printed instruction).
Task 2: Skin shader, thin-film include, saved material, prewarm
Section titled “Task 2: Skin shader, thin-film include, saved material, prewarm”Files:
- Create:
render/shaders/thin_film.gdshaderinc - Create:
render/shaders/ship_skin.gdshader - Create:
render/shaders/ship_skin_mat.tres - Modify:
render/prewarm_pass.gd(new family 3b after the 3ahull_glowblock, ~line 248) - Create:
tests/test_ship_skin_shader.gd - Modify:
tests/test_hdr_alpha.gd(add"res://render/shaders/ship_skin.gdshader"to theSHADERSconst list)
Interfaces:
-
Produces: shader uniforms (names used by Task 3’s
ShipSkin.push_uniforms, exact):look(int),tilt(float, -1..1),px_texels(float),emit(float),wire_color(vec3),wire_fill(vec3),wire_scan_hz,wire_panel,holo_film,holo_bank,holo_cycle_hz,holo_mix,glitch_split_px,glitch_jitter(0..1),chrome_rim(vec3),chrome_reflect(floats unless noted). -
Produces:
res://render/shaders/ship_skin_mat.tres(ShaderMaterial,look = 1). -
Step 1: Write the failing test — create
tests/test_ship_skin_shader.gd:
extends GutTest
# Ship skin shader (render/shaders/ship_skin.gdshader). The headless gate never compiles shaders: the# windowed compile check (tools/shader_check/compile_check.gd) is the real proof. These pin the# structural rules a compile can't: the Holo-film look is the Supernova's thin film, the material is a# saved one, colour uniforms are linearised, and the sprite's modulate RGB is carried through (the hull# picker's locked-hull dim). The alpha-remap rules ("remap is the LAST alpha write", "no early return") are# pinned by tests/test_hdr_alpha.gd, where this shader is registered; prewarm coverage by# tests/test_prewarm_coverage.gd (it auto-discovers every .gdshader under render/).
func _iridesce_body(path: String) -> String: var src := FileAccess.get_file_as_string(path) var re := RegEx.create_from_string("(?s)vec3 iridesce\\(vec3 base, float phase\\) \\{.*?\\n\\}") var m := re.search(src) return m.get_string() if m != null else ""
func test_thin_film_is_the_supernovas_verbatim() -> void: var skin := _iridesce_body("res://render/shaders/thin_film.gdshaderinc") assert_ne(skin, "", "thin_film.gdshaderinc defines iridesce()") assert_eq(skin, _iridesce_body("res://fx/weapons/nova_burst.gdshader"), "same thin-film maths as the Supernova (copy it again if the Supernova's changed)")
func test_skin_material_is_saved_and_uses_the_skin_shader() -> void: var mat := load("res://render/shaders/ship_skin_mat.tres") as ShaderMaterial assert_not_null(mat, "a saved material: the export baker only pre-compiles saved ones") assert_eq(mat.shader.resource_path, "res://render/shaders/ship_skin.gdshader")
func test_skin_shader_linearises_colour_uniforms_and_carries_modulate() -> void: var src := FileAccess.get_file_as_string("res://render/shaders/ship_skin.gdshader") assert_string_contains(src, "#include \"res://render/shaders/thin_film.gdshaderinc\"") for u in ["wire_color", "wire_fill", "chrome_rim"]: assert_string_contains(src, "cs_in(%s)" % u, "%s goes through cs_in (uniforms arrive raw)" % u) assert_string_contains(src, "col * v_mod.rgb", "the whole vertex colour (modulate) is applied, not just its alpha: a locked hull's grey dim in the picker survives")- Step 2: Run it to verify it fails
Run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_ship_skin_shader.gd -gexit 2>&1 | tail -10
Expected: FAIL (files missing).
- Step 3: Create the include —
render/shaders/thin_film.gdshaderinc. Write the header, then paste the function with a command (never retype it; tabs and the comment must match byte for byte):
// Thin-film (oil-slick) colour shared by the ship skins (render/ship_skin.gd, Holo-film look). iridesce()// is a VERBATIM copy of fx/weapons/nova_burst.gdshader's (the Supernova thin-film look, Chris 2026-10-12/13);// tests/test_ship_skin_shader.gd pins the two bodies equal, so a Supernova retune can't silently drift from// the skin. The Supernova shaders keep their own copy on purpose: editing them can re-trigger the A17// shader-baker draw bug (root CLAUDE.md), which this skin work must not risk.#ifndef THIN_FILM_INC#define THIN_FILM_INCThen run:
sed -n '/^vec3 iridesce(vec3 base, float phase) {/,/^}/p' fx/weapons/nova_burst.gdshader >> render/shaders/thin_film.gdshaderincprintf '#endif\n' >> render/shaders/thin_film.gdshaderinc- Step 4: Create the shader —
render/shaders/ship_skin.gdshader:
shader_type canvas_item;// Ship skins (render/ship_skin.gd): a render-only repaint of the player's hull sprite, drawn by an overlay// Sprite2D that mirrors the hull's texture + transform. `look` picks the candidate (ShipSkin constants):// 1 Prototype wireframe, 2 Holo-film, 3 Glitch build, 4 Void chrome. ONE shader for all four, so the baker// and PrewarmPass link one pipeline (the branch is on a uniform: coherent per draw).// Mix blend. The final COLOR.a goes through hdr_alpha() (root CLAUDE.md: a translucent draw on the linear// canvas needs the remap). vec3 colour uniforms arrive RAW, so each passes through cs_in().// The sprite's modulate (vertex COLOR) is carried through whole: the in-run overlay's is white-with-alpha,// the hull picker's locked hull is grey (its dim must survive the repaint).// Literals deliberately LEFT in the shader (shape constants, not colour gains; Chris tunes the looks on// device via the uniforms above): chrome body vec3(0.012,0.012,0.016) + band vec3(0.16,0.17,0.2), scan// strength 0.35, holo brightness 0.55 + 0.65*l and floor vec3(0.3), glitch rows 28 / 24 Hz / slide 0.08 /// probability 0.6 / widen 4.0. Everything with an SDR/HDR meaning is a uniform with an *_HDR twin.#include "res://render/shaders/color_space.gdshaderinc"#include "res://render/shaders/hdr_alpha.gdshaderinc"#include "res://render/shaders/thin_film.gdshaderinc"
uniform int look = 0;uniform float tilt = 0.0; // bank -1..1 (ShipTilt value / ShipTilt.MAX_TILT)uniform float px_texels = 1.0; // texels per screen pixel at the hull's drawn scale (1 / scale.x)uniform float emit = 1.0; // emissive gain on lines / film / rim (ShipSkin.EMIT or EMIT_HDR)uniform vec3 wire_color = vec3(0.35, 0.95, 1.0);uniform vec3 wire_fill = vec3(0.015, 0.02, 0.03);uniform float wire_scan_hz = 0.35;uniform float wire_panel = 1.0; // weight of the interior panel lines (luminance edges)uniform float holo_film = 2.0; // hue cycles across the hull's light/dark rangeuniform float holo_bank = 0.8; // hue shift per full bankuniform float holo_cycle_hz = 0.12;uniform float holo_mix = 0.85;uniform float glitch_split_px = 1.0;uniform float glitch_jitter = 0.0; // 0..1, a decaying pulse on warp / hit (ShipSkin.pulse)uniform vec3 chrome_rim = vec3(1.0, 0.96, 0.9);uniform float chrome_reflect = 1.0;
varying vec4 v_mod; // the sprite's modulate (vertex COLOR); fragment COLOR already holds texture x modulate
void vertex() { v_mod = COLOR;}
float lum(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722));}
float hash12(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);}
// The four neighbour taps at +-d, once for the wireframe and chrome looks. xy = alpha gradient// (right - left, down - up; points INTO the silhouette), zw = luminance gradient (same axes).vec4 edge_taps(sampler2D t, vec2 uv, vec2 d) { vec4 sl = texture(t, uv - vec2(d.x, 0.0)); vec4 sr = texture(t, uv + vec2(d.x, 0.0)); vec4 su = texture(t, uv - vec2(0.0, d.y)); vec4 sd = texture(t, uv + vec2(0.0, d.y)); return vec4(sr.a - sl.a, sd.a - su.a, lum(sr.rgb) - lum(sl.rgb), lum(sd.rgb) - lum(su.rgb));}
void fragment() { vec4 tex = texture(TEXTURE, UV); vec3 col = tex.rgb; float a = tex.a; vec2 d = TEXTURE_PIXEL_SIZE * px_texels * 1.5; // ~1.5 screen px at the drawn scale if (look == 1) { // Prototype wireframe: silhouette edge + interior panel lines in wire_color over a near-black fill, // a soft scanline sweeping down the hull. vec4 g = edge_taps(TEXTURE, UV, d); float edge = clamp((abs(g.x) + abs(g.y)) * 1.5, 0.0, 1.0); float panel = clamp((abs(g.z) + abs(g.w)) * 4.0 * wire_panel, 0.0, 1.0) * a; float line = max(edge, smoothstep(0.25, 0.6, panel)); float sweep = fract(UV.y - TIME * wire_scan_hz); float scan = smoothstep(0.06, 0.0, abs(sweep - 0.5)) * a; vec3 wc = cs_in(wire_color) * emit; col = mix(cs_in(wire_fill), wc, line) + wc * scan * 0.35; a = max(a, edge); } else if (look == 2) { // Holo-film: the Supernova thin film, phase from the hull's own shading + bank + time. float l = lum(tex.rgb); float phase = l * holo_film + tilt * holo_bank + TIME * holo_cycle_hz + UV.y * 0.5; vec3 film = iridesce(max(tex.rgb, vec3(0.3)), phase) * (0.55 + 0.65 * l); col = mix(tex.rgb, film * emit, holo_mix); } else if (look == 3) { // Glitch build: a constant small RGB split; a warp/hit pulse widens it and slides a few rows. float row = floor(UV.y * 28.0); float tick = floor(TIME * 24.0); vec2 uv = UV; if (hash12(vec2(row, tick)) < glitch_jitter * 0.6) { uv.x += (hash12(vec2(tick, row + 7.0)) - 0.5) * 0.08 * glitch_jitter; } float s = TEXTURE_PIXEL_SIZE.x * px_texels * glitch_split_px * (1.0 + 4.0 * glitch_jitter); // sdr-hdr: UV offset width, not a colour gain vec4 cr = texture(TEXTURE, uv + vec2(s, 0.0)); vec4 cg = texture(TEXTURE, uv); vec4 cb = texture(TEXTURE, uv - vec2(s, 0.0)); a = max(cr.a, max(cg.a, cb.a)); col = vec3(cr.r * cr.a, cg.g * cg.a, cb.b * cb.a) / max(a, 0.001); } else if (look == 4) { // Void chrome: near-black body with sliding reflection bands, one white-hot rim on the lit side. vec2 grad = edge_taps(TEXTURE, UV, d).xy; // points INTO the silhouette float l = lum(tex.rgb); float band = 0.5 + 0.5 * sin((UV.y * 2.0 - UV.x + tilt * 0.6) * 9.0 + l * 5.0); vec3 body = vec3(0.012, 0.012, 0.016) + vec3(0.16, 0.17, 0.2) * pow(band, 6.0) * chrome_reflect * (0.3 + l); float lit = clamp(dot(normalize(-grad + vec2(1e-5)), normalize(vec2(-0.55, -0.85))), 0.0, 1.0); float rim = clamp(length(grad) * 1.5, 0.0, 1.0) * lit; col = body + cs_in(chrome_rim) * emit * rim; a = max(a, rim); } COLOR = vec4(col * v_mod.rgb, a * v_mod.a); COLOR.a = hdr_alpha(COLOR.a);}- Step 5: Create the saved material —
render/shaders/ship_skin_mat.tres:
[gd_resource type="ShaderMaterial" load_steps=2 format=3]
[ext_resource type="Shader" path="res://render/shaders/ship_skin.gdshader" id="1"]
[resource]shader = ExtResource("1")shader_parameter/look = 1- Step 6: Prewarm family 3b — in
render/prewarm_pass.gd, directly after the 3a block (add_child(hull_glow)), add:
# 3b) ShipSkin overlay (render/ship_skin.gd): the player hull repainted by ship_skin.gdshader. One # pipeline for all four looks (the look is a uniform), so a run with a skin equipped (or a Dev # console LOOK flip mid-run) never links it cold. look = 1 so the fragment does real work. var skin := Sprite2D.new() skin.texture = GlowTexture.shared() skin.material = preload("res://render/shaders/ship_skin_mat.tres").duplicate() skin.scale = Vector2(0.05, 0.05) add_child(skin)-
Step 7: Run the tests — import first (new files):
godot --headless --path . --import > /dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres. Then runtests/test_ship_skin_shader.gd,tests/test_hdr_alpha.gd,tests/test_shader_hdr_lint.gd,tests/test_prewarm_coverage.gd,tests/test_baked_shader_materials.gd,tests/test_prewarm_pass.gdwith the GUT command from Task 1 Step 2. Expected: all PASS. -
Step 8: Windowed compile check, both renderers (foreground; App Nap):
godot --path . --rendering-method mobile -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "ERROR|WARNING|compile_check"godot --path . --rendering-method gl_compatibility -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "ERROR|WARNING|compile_check"git checkout -- project.godot audio/bus_layout.tresExpected: only the two compile_check lines, and the mobile run’s list includes ship_skin_mat.tres (the checker discovers materials whose shader includes hdr_alpha.gdshaderinc; if it is not listed, grep the script’s MARKERS and report why before going on). Any ERROR/WARNING = fix the shader and re-run both.
Task 3: ShipSkin node + its tunables
Section titled “Task 3: ShipSkin node + its tunables”Files:
- Create:
render/ship_skin.gd - Modify:
net/tunables_registry.gd(path const nearDEATH_SEQUENCE; rows at the end ofENTRIES, before the closing]) - Create:
tools/tunable_docs/docs.d/ship_skin.json - Modify:
tools/tunable_docs/generate.mjs(GROUP_RULES + group order) - Regenerate:
control/src/tunable_docs.js - Create:
tests/test_ship_skin.gd
Interfaces:
-
Consumes: Task 2’s uniform names and
ship_skin_mat.tres;MetaState.KNOWN_SKINSids. -
Produces:
class_name ShipSkin extends Node2D; constantsOFF=0, WIREFRAME=1, HOLO=2, GLITCH=3, CHROME=4;GHOST_CAP := 6.static func look_for(skin: String, dev: bool) -> intstatic func display_name(skin: String) -> String(""→"None","beta"→"Beta")static func new_material() -> ShaderMaterialstatic func push_uniforms(mat: ShaderMaterial, look: int, tilt01: float, jitter: float, px_texels: float, hdr: bool, ui: bool = false) -> void(ui = truewidens colours throughColorGamut.ui_out, for the hull picker’s UI CanvasLayer; the run passes the default)- instance:
var skin_id: String,var force_look: int = -1,var overlay: Sprite2D,func update(hull: Sprite2D, tilt: float, dt: float) -> int(returns the look drawn),func pulse() -> void. - static tunables (keys
ShipSkin.<NAME>):LOOK, BETA_LOOK, EMIT, EMIT_HDR, WIRE_COLOR, WIRE_COLOR_HDR, WIRE_FILL, WIRE_FILL_HDR, WIRE_SCAN_HZ, WIRE_PANEL, HOLO_FILM, HOLO_BANK, HOLO_CYCLE_HZ, HOLO_MIX, GLITCH_SPLIT_PX, GLITCH_JITTER_S, CHROME_RIM, CHROME_RIM_HDR, CHROME_REFLECT, CHROME_GHOST, CHROME_GHOST_HDR, CHROME_GHOST_EVERY_S, CHROME_GHOST_LIFE(23 keys).
-
Step 1: Write the failing test — create
tests/test_ship_skin.gd:
extends GutTest
# ShipSkin (render/ship_skin.gd): the render-only skin overlay. GUT counts as a Dev build# (BuildConfig.dev_tools() is true), so update()'s LOOK path is the Dev one; look_for() takes `dev`# explicitly so the store-build rule is pinned too.
const DT := 1.0 / 60.0
func after_each() -> void: Tunables.reset()
func _hull() -> Sprite2D: var h := Sprite2D.new() h.texture = load("res://render/ship_sprites/ship3d_%s.png" % ShipBonuses.DEFAULT_SHIP) h.scale = Vector2(0.1, 0.1) add_child_autofree(h) return h
func _skin() -> ShipSkin: var s := ShipSkin.new() add_child_autofree(s) return s
func _mat(s: ShipSkin) -> ShaderMaterial: return s.overlay.material as ShaderMaterial
# ── which look draws ─────────────────────────────────────────────────────────────────────────func test_look_follows_the_equipped_skin() -> void: assert_eq(ShipSkin.look_for("", true), ShipSkin.OFF) assert_eq(ShipSkin.look_for("", false), ShipSkin.OFF) assert_eq(ShipSkin.look_for("beta", false), ShipSkin.BETA_LOOK)
func test_console_look_overrides_only_in_dev_builds() -> void: assert_eq(Tunables.apply({"ShipSkin.LOOK": 3}), []) assert_eq(ShipSkin.look_for("", true), ShipSkin.GLITCH, "Dev: the A/B look draws with no skin owned") assert_eq(ShipSkin.look_for("beta", true), ShipSkin.GLITCH, "Dev: it beats the equipped skin") assert_eq(ShipSkin.look_for("", false), ShipSkin.OFF, "store build: a patch can't switch skins on (Apple 2.3.1)") assert_eq(ShipSkin.look_for("beta", false), ShipSkin.BETA_LOOK, "store build: the owned skin's own look")
func test_beta_look_is_patchable() -> void: assert_eq(Tunables.apply({"ShipSkin.BETA_LOOK": 4}), []) assert_eq(ShipSkin.look_for("beta", false), ShipSkin.CHROME)
func test_look_keys_are_bounded_ints() -> void: assert_ne(Tunables.apply({"ShipSkin.LOOK": 5}), [], "only 0-4") assert_ne(Tunables.apply({"ShipSkin.BETA_LOOK": 0}), [], "Beta always draws a look") assert_ne(Tunables.apply({"ShipSkin.LOOK": 1.5}), [], "an int tunable")
func test_display_names() -> void: assert_eq(ShipSkin.display_name(""), "None") assert_eq(ShipSkin.display_name("beta"), "Beta")
# ── the overlay ──────────────────────────────────────────────────────────────────────────────func test_overlay_mirrors_the_hull_and_hides_when_off() -> void: var h := _hull() var s := _skin() assert_eq(s.update(h, 0.0, DT), ShipSkin.OFF) assert_false(s.overlay.visible, "no skin, no console look: nothing drawn") s.skin_id = "beta" h.position = Vector2(3, 4) h.rotation = 0.5 assert_eq(s.update(h, 0.0, DT), ShipSkin.BETA_LOOK) assert_true(s.overlay.visible) assert_eq(s.overlay.texture, h.texture) assert_eq(s.overlay.transform, h.transform) h.visible = false s.update(h, 0.0, DT) assert_false(s.overlay.visible, "a hidden hull hides its skin") assert_eq(s.update(null, 0.0, DT), ShipSkin.BETA_LOOK) assert_false(s.overlay.visible, "no hull, nothing drawn")
func test_overlay_follows_a_hull_texture_swap() -> void: var h := _hull() var s := _skin() s.force_look = ShipSkin.WIREFRAME s.update(h, 0.0, DT) h.texture = load("res://render/ship_sprites/ship3d_obsidian.png") s.update(h, 0.0, DT) assert_eq(s.overlay.texture, h.texture, "Nova hull / tilt flipbook frames are repainted too")
func test_uniforms_carry_look_tilt_scale_and_live_edits() -> void: var h := _hull() var s := _skin() s.force_look = ShipSkin.HOLO s.update(h, ShipTilt.MAX_TILT * 0.5, DT) assert_eq(int(_mat(s).get_shader_parameter("look")), ShipSkin.HOLO) assert_almost_eq(float(_mat(s).get_shader_parameter("tilt")), 0.5, 0.001) assert_almost_eq(float(_mat(s).get_shader_parameter("px_texels")), 10.0, 0.001, "1 / hull scale 0.1") assert_eq(Tunables.apply({"ShipSkin.HOLO_FILM": 3.0}), []) s.update(h, 0.0, DT) assert_almost_eq(float(_mat(s).get_shader_parameter("holo_film")), 3.0, 0.001, "a console edit lands on the next frame")
func test_each_skin_owns_its_material() -> void: var a := _skin() var b := _skin() assert_ne(a.overlay.material, b.overlay.material, "duplicated from the saved material, never shared")
func test_glitch_pulse_decays_over_jitter_s() -> void: var h := _hull() var s := _skin() s.force_look = ShipSkin.GLITCH s.update(h, 0.0, DT) assert_almost_eq(float(_mat(s).get_shader_parameter("glitch_jitter")), 0.0, 0.001) s.pulse() s.update(h, 0.0, 0.0) assert_almost_eq(float(_mat(s).get_shader_parameter("glitch_jitter")), 1.0, 0.001, "a warp/hit spikes it") s.update(h, 0.0, ShipSkin.GLITCH_JITTER_S) assert_almost_eq(float(_mat(s).get_shader_parameter("glitch_jitter")), 0.0, 0.001, "gone after GLITCH_JITTER_S")
# ── Void chrome afterimages ──────────────────────────────────────────────────────────────────func _visible_ghosts(s: ShipSkin) -> int: var n := 0 for g in s._ghosts: if g.visible: n += 1 return n
func test_chrome_leaves_a_fading_trail() -> void: var h := _hull() var s := _skin() s.force_look = ShipSkin.CHROME for i in 12: h.position.x += 10.0 s.update(h, 0.0, 0.05) assert_gt(_visible_ghosts(s), 0, "moving leaves violet afterimages") assert_lte(s._ghosts.size(), ShipSkin.GHOST_CAP, "pooled") for g in s._ghosts: assert_false(g.top_level, "children in the normal tree: hiding the ship (death) hides them too") for i in 20: s.update(h, 0.0, 0.05) assert_eq(_visible_ghosts(s), 0, "standing still, they fade out within CHROME_GHOST_LIFE")
func test_other_looks_leave_no_trail() -> void: var h := _hull() var s := _skin() s.force_look = ShipSkin.WIREFRAME for i in 12: h.position.x += 10.0 s.update(h, 0.0, 0.05) assert_eq(s._ghosts.size(), 0)
# ── tunables: every knob registered, labelled, local, live ──────────────────────────────────func _static_vars() -> Array[String]: var out: Array[String] = [] var re := RegEx.create_from_string("(?m)^static var ([A-Z][A-Z0-9_]*)\\b") for m in re.search_all(FileAccess.get_file_as_string("res://render/ship_skin.gd")): out.append(m.get_string(1)) return out
func test_every_knob_is_a_registered_documented_local_live_tunable() -> void: var names := _static_vars() assert_eq(names.size(), 23, "sanity: the knobs were found") var keys := {} for e: Dictionary in TunablesRegistry.ENTRIES: keys[String(e["key"])] = e var docs: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://tools/tunable_docs/docs.d/ship_skin.json")) assert_true(docs is Dictionary, "docs.d/ship_skin.json parses") for n in names: var k := "ShipSkin." + n assert_true(keys.has(k), k + " is registered") assert_true((docs as Dictionary).has(k), k + " has a console label") if keys.has(k): assert_true(bool(keys[k].get("local", false)), k + " is local (render-only, per device)") assert_false(LiveScope.TUNABLES.has(k) and String(LiveScope.TUNABLES[k][0]) != LiveScope.LIVE, k + " has no non-LIVE LiveScope row (liveness itself is proven by test_live_scope's one-shot scan + test_uniforms_carry_look_tilt_scale_and_live_edits)")-
Step 2: Run it to verify it fails (GUT command as Task 1 Step 2,
-gdir=res://tests -gselect=test_ship_skin.gd). Expected: FAIL,ShipSkinnot found. -
Step 3: Implement
render/ship_skin.gd:
class_name ShipSkinextends Node2D
# Cosmetic ship skins (spec docs/superpowers/specs/2026-10-14-app-clip-warden-design.md § Beta skin).# RENDER-ONLY: an overlay Sprite2D that mirrors the player's hull sprite (texture, transform, visibility)# every frame and repaints it with render/shaders/ship_skin.gdshader. Works over ANY hull (baked, Nova,# tilt-flipbook frames) because it copies whatever the hull is drawing. Never read by /sim and never a# stat (hull stats live in ShipBonuses). PlayerRenderer owns one and calls update() every frame.## Which look draws: LOOK (the Remote Console A/B, "ship_skin" in the spec) when non-zero in a Dev build;# else the equipped skin's look (BETA_LOOK for "beta"); else nothing. LOOK is IGNORED outside Dev builds# so a remote patch can never switch skins on for App Store players (root CLAUDE.md, Apple 2.3.1).## Known limits: the Dev-only SHADER_WARP tilt mode warps the hull but not this overlay; WarpAfterimages# copies the base hull, not the skin; only pilot 1's PlayerRenderer has a skin (PlayerRenderer.skinnable), co-op Player 2 never does.
const OFF := 0const WIREFRAME := 1const HOLO := 2const GLITCH := 3const CHROME := 4const MAT_PATH := "res://render/shaders/ship_skin_mat.tres"# Void chrome afterimages: a mix draw that needs the hdr_alpha remap. hero_glow_mix with glow_tight 1.0 is# exactly "remap only" (and already prewarmed, family 3a).const GHOST_MAT_PATH := "res://render/shaders/hero_glow_mix_mat.tres"const GHOST_CAP := 6
# Remote Console tunables (net/tunables_registry.gd, "local"). All read per frame from update() / pulse(),# so every edit is live. *_HDR twins are used while HdrOutput.on().static var LOOK: int = 0 # 0 = follow the equipped skin; 1-4 = force that candidate (Dev builds only)static var BETA_LOOK: int = 1 # which candidate the "beta" skin draws (the A/B winner goes here)static var EMIT: float = 1.0 # emissive gain on lines / film / rim, SDRstatic var EMIT_HDR: float = 1.8static var WIRE_COLOR: Color = Color(0.35, 0.95, 1.0)static var WIRE_COLOR_HDR: Color = Color(0.35, 0.95, 1.0)static var WIRE_FILL: Color = Color(0.015, 0.02, 0.03)static var WIRE_FILL_HDR: Color = Color(0.015, 0.02, 0.03)static var WIRE_SCAN_HZ: float = 0.35static var WIRE_PANEL: float = 1.0static var HOLO_FILM: float = 2.0static var HOLO_BANK: float = 0.8static var HOLO_CYCLE_HZ: float = 0.12static var HOLO_MIX: float = 0.85static var GLITCH_SPLIT_PX: float = 1.0static var GLITCH_JITTER_S: float = 0.18static var CHROME_RIM: Color = Color(1.0, 0.96, 0.9)static var CHROME_RIM_HDR: Color = Color(1.0, 0.96, 0.9)static var CHROME_REFLECT: float = 1.0static var CHROME_GHOST: Color = Color(0.6, 0.35, 1.0, 0.22)static var CHROME_GHOST_HDR: Color = Color(0.6, 0.35, 1.0, 0.16)static var CHROME_GHOST_EVERY_S: float = 0.06static var CHROME_GHOST_LIFE: float = 0.35
var skin_id: String = "" # MetaState.equipped_skin (set by PlayerRenderer)var force_look: int = -1 # >= 0 overrides LOOK and skin_id (previews, harnesses); -1 = follow themvar overlay: Sprite2Dvar _mat: ShaderMaterialvar _jitter_t := 0.0var _ghost_mat: Materialvar _ghosts: Array[Sprite2D] = []var _ghost_age: Array[float] = []var _ghost_xf: Array[Transform2D] = []var _ghost_t := 0.0var _ghost_next := 0var _last_pos := Vector2.INF
func _ready() -> void: _mat = new_material() overlay = Sprite2D.new() overlay.material = _mat overlay.visible = false add_child(overlay)
# Saved material, not ShaderMaterial.new(): the export shader baker only pre-compiles saved ones.static func new_material() -> ShaderMaterial: return preload(MAT_PATH).duplicate() as ShaderMaterial
static func look_for(skin: String, dev: bool) -> int: if dev and LOOK > 0: return clampi(LOOK, WIREFRAME, CHROME) if skin == "beta": return clampi(BETA_LOOK, WIREFRAME, CHROME) return OFF
static func display_name(skin: String) -> String: return "None" if skin == "" else skin.capitalize()
# Every look uniform from the tunables. Colours ride PATH_B (a cs-converted uniform, render/hdr_tier.gd):# ColorGamut.out widens them for P3, the shader's cs_in() linearises them. `ui` = the hull picker's# thumbnail, a UI CanvasLayer that widens only via ColorGamut.ui_out (its own amount), so its wire/rim# colours match the rest of the UI, not the run.static func push_uniforms(mat: ShaderMaterial, look: int, tilt01: float, jitter: float, px_texels: float, hdr: bool, ui: bool = false) -> void: mat.set_shader_parameter("look", look) mat.set_shader_parameter("tilt", tilt01) mat.set_shader_parameter("px_texels", px_texels) mat.set_shader_parameter("emit", EMIT_HDR if hdr else EMIT) mat.set_shader_parameter("wire_color", _c3(WIRE_COLOR_HDR if hdr else WIRE_COLOR, ui)) mat.set_shader_parameter("wire_fill", _c3(WIRE_FILL_HDR if hdr else WIRE_FILL, ui)) mat.set_shader_parameter("wire_scan_hz", WIRE_SCAN_HZ) mat.set_shader_parameter("wire_panel", WIRE_PANEL) mat.set_shader_parameter("holo_film", HOLO_FILM) mat.set_shader_parameter("holo_bank", HOLO_BANK) mat.set_shader_parameter("holo_cycle_hz", HOLO_CYCLE_HZ) mat.set_shader_parameter("holo_mix", HOLO_MIX) mat.set_shader_parameter("glitch_split_px", GLITCH_SPLIT_PX) mat.set_shader_parameter("glitch_jitter", jitter) mat.set_shader_parameter("chrome_rim", _c3(CHROME_RIM_HDR if hdr else CHROME_RIM, ui)) mat.set_shader_parameter("chrome_reflect", CHROME_REFLECT)
static func _c3(c: Color, ui: bool = false) -> Vector3: var o := ColorGamut.ui_out(c, HdrTier.PATH_B) if ui else ColorGamut.out(c, HdrTier.PATH_B) return Vector3(o.r, o.g, o.b)
# Every render frame from PlayerRenderer.update_visual. hull = the sprite to repaint (null = none),# tilt = the ShipTilt bank in radians, dt = frame delta. Returns the look drawn (OFF = nothing).func update(hull: Sprite2D, tilt: float, dt: float) -> int: var look := force_look if force_look >= 0 else look_for(skin_id, BuildConfig.dev_tools()) _jitter_t = maxf(_jitter_t - dt, 0.0) var on := look != OFF and hull != null and hull.texture != null and hull.visible overlay.visible = on if on: overlay.texture = hull.texture overlay.transform = hull.transform overlay.modulate = Color(1, 1, 1, hull.modulate.a) var jitter := 0.0 if look == GLITCH and GLITCH_JITTER_S > 0.0: jitter = clampf(_jitter_t / GLITCH_JITTER_S, 0.0, 1.0) push_uniforms(_mat, look, clampf(tilt / ShipTilt.MAX_TILT, -1.0, 1.0), jitter, 1.0 / maxf(absf(hull.scale.x), 0.001), HdrOutput.on()) _update_ghosts(hull, on and look == CHROME, dt) return look
# A warp or a hard hit (main's edge detects, via PlayerRenderer.skin_pulse): the Glitch look's jitter.func pulse() -> void: _jitter_t = GLITCH_JITTER_S
func _update_ghosts(hull: Sprite2D, emitting: bool, dt: float) -> void: var gc: Color = CHROME_GHOST_HDR if HdrOutput.on() else CHROME_GHOST var col := ColorGamut.out(gc, HdrTier.PATH_A) # a modulate: PATH_A _ghost_t -= dt if emitting and _ghost_t <= 0.0 and CHROME_GHOST_LIFE > 0.0: if _last_pos == Vector2.INF or hull.global_position.distance_squared_to(_last_pos) > 4.0: _spawn_ghost(hull) _ghost_t = CHROME_GHOST_EVERY_S if hull != null: _last_pos = hull.global_position for i in _ghosts.size(): var g := _ghosts[i] if not g.visible: continue _ghost_age[i] += dt var k := 1.0 - _ghost_age[i] / maxf(CHROME_GHOST_LIFE, 0.01) if k <= 0.0: g.visible = false continue g.global_transform = _ghost_xf[i] # world-fixed while the ship flies on g.modulate = Color(col.r, col.g, col.b, col.a * k)
func _spawn_ghost(hull: Sprite2D) -> void: var i := _ghost_next % GHOST_CAP _ghost_next += 1 if i >= _ghosts.size(): if _ghost_mat == null: _ghost_mat = preload(GHOST_MAT_PATH).duplicate() # glow_tight stays 1.0: remap only var g := Sprite2D.new() g.material = _ghost_mat g.z_index = -1 # behind the hull (relative z) add_child(g) _ghosts.append(g) _ghost_age.append(0.0) _ghost_xf.append(Transform2D.IDENTITY) var ghost := _ghosts[i] ghost.texture = hull.texture ghost.visible = true _ghost_age[i] = 0.0 _ghost_xf[i] = hull.global_transform ghost.global_transform = hull.global_transform- Step 4: Register the tunables — in
net/tunables_registry.gdadd near the other render paths:const SHIP_SKIN := "res://render/ship_skin.gd". At the end ofENTRIES(after the lastLockstepSessionrow, before]) add:
# Ship skins (render/ship_skin.gd, plan 2026-10-15-beta-skin). Render-only, per device: "local". LOOK is # the Dev-only A/B (the spec's "ship_skin"; ignored in store builds); BETA_LOOK picks the Beta skin's look. {"key": "ShipSkin.LOOK", "script": SHIP_SKIN, "name": "LOOK", "min": 0, "max": 4, "local": true}, {"key": "ShipSkin.BETA_LOOK", "script": SHIP_SKIN, "name": "BETA_LOOK", "min": 1, "max": 4, "local": true}, {"key": "ShipSkin.EMIT", "script": SHIP_SKIN, "name": "EMIT", "min": 0.0, "max": 4.0, "local": true}, {"key": "ShipSkin.EMIT_HDR", "script": SHIP_SKIN, "name": "EMIT_HDR", "min": 0.0, "max": 8.0, "local": true}, {"key": "ShipSkin.WIRE_COLOR", "script": SHIP_SKIN, "name": "WIRE_COLOR", "type": "color", "local": true}, {"key": "ShipSkin.WIRE_COLOR_HDR", "script": SHIP_SKIN, "name": "WIRE_COLOR_HDR", "type": "color", "local": true}, {"key": "ShipSkin.WIRE_FILL", "script": SHIP_SKIN, "name": "WIRE_FILL", "type": "color", "local": true}, {"key": "ShipSkin.WIRE_FILL_HDR", "script": SHIP_SKIN, "name": "WIRE_FILL_HDR", "type": "color", "local": true}, {"key": "ShipSkin.WIRE_SCAN_HZ", "script": SHIP_SKIN, "name": "WIRE_SCAN_HZ", "min": 0.0, "max": 2.0, "local": true}, {"key": "ShipSkin.WIRE_PANEL", "script": SHIP_SKIN, "name": "WIRE_PANEL", "min": 0.0, "max": 3.0, "local": true}, {"key": "ShipSkin.HOLO_FILM", "script": SHIP_SKIN, "name": "HOLO_FILM", "min": 0.0, "max": 6.0, "local": true}, {"key": "ShipSkin.HOLO_BANK", "script": SHIP_SKIN, "name": "HOLO_BANK", "min": 0.0, "max": 3.0, "local": true}, {"key": "ShipSkin.HOLO_CYCLE_HZ", "script": SHIP_SKIN, "name": "HOLO_CYCLE_HZ", "min": 0.0, "max": 1.0, "local": true}, {"key": "ShipSkin.HOLO_MIX", "script": SHIP_SKIN, "name": "HOLO_MIX", "min": 0.0, "max": 1.0, "local": true}, {"key": "ShipSkin.GLITCH_SPLIT_PX", "script": SHIP_SKIN, "name": "GLITCH_SPLIT_PX", "min": 0.0, "max": 4.0, "local": true}, {"key": "ShipSkin.GLITCH_JITTER_S", "script": SHIP_SKIN, "name": "GLITCH_JITTER_S", "min": 0.0, "max": 1.0, "local": true}, {"key": "ShipSkin.CHROME_RIM", "script": SHIP_SKIN, "name": "CHROME_RIM", "type": "color", "local": true}, {"key": "ShipSkin.CHROME_RIM_HDR", "script": SHIP_SKIN, "name": "CHROME_RIM_HDR", "type": "color", "local": true}, {"key": "ShipSkin.CHROME_REFLECT", "script": SHIP_SKIN, "name": "CHROME_REFLECT", "min": 0.0, "max": 3.0, "local": true}, {"key": "ShipSkin.CHROME_GHOST", "script": SHIP_SKIN, "name": "CHROME_GHOST", "type": "color", "local": true}, {"key": "ShipSkin.CHROME_GHOST_HDR", "script": SHIP_SKIN, "name": "CHROME_GHOST_HDR", "type": "color", "local": true}, {"key": "ShipSkin.CHROME_GHOST_EVERY_S", "script": SHIP_SKIN, "name": "CHROME_GHOST_EVERY_S", "min": 0.02, "max": 0.5, "local": true}, {"key": "ShipSkin.CHROME_GHOST_LIFE", "script": SHIP_SKIN, "name": "CHROME_GHOST_LIFE", "min": 0.0, "max": 1.5, "local": true},- Step 5: Console labels — create
tools/tunable_docs/docs.d/ship_skin.json:
{ "ShipSkin.LOOK": {"label": "Ship skin A/B (Dev)", "desc": "Force a skin candidate on your ship: 0 = off (your equipped skin), 1 = Prototype wireframe, 2 = Holo-film, 3 = Glitch build, 4 = Void chrome. Dev builds only; store builds ignore it. Visual only.", "src": "code"}, "ShipSkin.BETA_LOOK": {"label": "Beta skin look", "desc": "Which candidate the Beta skin draws: 1 = Prototype wireframe, 2 = Holo-film, 3 = Glitch build, 4 = Void chrome. Visual only.", "src": "code"}, "ShipSkin.EMIT": {"label": "Skin glow (SDR)", "desc": "Brightness of every skin's glowing parts (wire lines, film, chrome rim) on a non-HDR screen. Visual only.", "unit": "×", "src": "code"}, "ShipSkin.EMIT_HDR": {"label": "Skin glow (HDR)", "desc": "Brightness of every skin's glowing parts with HDR output on. Visual only.", "unit": "×", "src": "code"}, "ShipSkin.WIRE_COLOR": {"label": "Wireframe line colour (SDR)", "desc": "Colour of the Prototype wireframe's edge and panel lines on a non-HDR screen. Visual only.", "src": "code"}, "ShipSkin.WIRE_COLOR_HDR": {"label": "Wireframe line colour (HDR)", "desc": "Colour of the Prototype wireframe's lines with HDR output on. Visual only.", "src": "code"}, "ShipSkin.WIRE_FILL": {"label": "Wireframe fill colour (SDR)", "desc": "The near-black body colour under the Prototype wireframe's lines on a non-HDR screen. Visual only.", "src": "code"}, "ShipSkin.WIRE_FILL_HDR": {"label": "Wireframe fill colour (HDR)", "desc": "The near-black body colour under the Prototype wireframe's lines with HDR output on. Visual only.", "src": "code"}, "ShipSkin.WIRE_SCAN_HZ": {"label": "Wireframe scan speed", "desc": "How often the Prototype wireframe's scanline sweeps down the hull. 0 = no sweep. Visual only.", "unit": "Hz", "src": "code"}, "ShipSkin.WIRE_PANEL": {"label": "Wireframe panel lines", "desc": "Strength of the interior panel lines (as well as the outline) on the Prototype wireframe. 0 = outline only. Visual only.", "unit": "×", "src": "code"}, "ShipSkin.HOLO_FILM": {"label": "Holo-film colour bands", "desc": "How many colour bands the Holo-film spreads across the hull's light and dark areas. Visual only.", "unit": "×", "src": "code"}, "ShipSkin.HOLO_BANK": {"label": "Holo-film bank shift", "desc": "How far the Holo-film's colours slide when the ship banks into a turn. 0 = no shift. Visual only.", "unit": "×", "src": "code"}, "ShipSkin.HOLO_CYCLE_HZ": {"label": "Holo-film drift", "desc": "How fast the Holo-film's colours drift on their own. 0 = only banking moves them. Visual only.", "unit": "Hz", "src": "code"}, "ShipSkin.HOLO_MIX": {"label": "Holo-film strength", "desc": "How much of the hull is replaced by the Holo-film colours. 1 = all film, 0 = the plain hull. Visual only.", "unit": "frac", "src": "code"}, "ShipSkin.GLITCH_SPLIT_PX": {"label": "Glitch colour split", "desc": "Width of the Glitch build's red/blue channel split at rest, in screen pixels. A warp or hit widens it briefly. Visual only.", "unit": "px", "src": "code"}, "ShipSkin.GLITCH_JITTER_S": {"label": "Glitch jitter duration", "desc": "How long the Glitch build's corruption jitter lasts after a warp or hit. 0 = none. Visual only.", "unit": "s", "src": "code"}, "ShipSkin.CHROME_RIM": {"label": "Void chrome rim (SDR)", "desc": "Colour of the Void chrome's white-hot rim on a non-HDR screen. Visual only.", "src": "code"}, "ShipSkin.CHROME_RIM_HDR": {"label": "Void chrome rim (HDR)", "desc": "Colour of the Void chrome's white-hot rim with HDR output on. Visual only.", "src": "code"}, "ShipSkin.CHROME_REFLECT": {"label": "Void chrome reflections", "desc": "Strength of the sliding reflections on the Void chrome's black body. 0 = flat black. Visual only.", "unit": "×", "src": "code"}, "ShipSkin.CHROME_GHOST": {"label": "Void chrome trail (SDR)", "desc": "Colour and opacity of the faint violet afterimages the Void chrome leaves while moving, non-HDR screen. Visual only.", "src": "code"}, "ShipSkin.CHROME_GHOST_HDR": {"label": "Void chrome trail (HDR)", "desc": "Colour and opacity of the Void chrome's afterimages with HDR output on. Visual only.", "src": "code"}, "ShipSkin.CHROME_GHOST_EVERY_S": {"label": "Void chrome trail spacing", "desc": "Time between the Void chrome's afterimages while moving. Lower = a denser trail. Visual only.", "unit": "s", "src": "code"}, "ShipSkin.CHROME_GHOST_LIFE": {"label": "Void chrome trail length", "desc": "How long each Void chrome afterimage takes to fade. 0 = no trail. Visual only.", "unit": "s", "src": "code"}}- Step 6: Console group + regenerate — in
tools/tunable_docs/generate.mjs: add[/^ShipSkin\./, 'Ship skins'],directly after the[/^DeathSequence\./, 'Death sequence'],rule, and add'Ship skins'right after'Death sequence'in the group-order array (~line 80). Then:
node tools/tunable_docs/generate.mjsnode --test control/test/tunable-docs.test.mjsExpected: the generator writes control/src/tunable_docs.js with the 23 ShipSkin.* keys under “Ship skins”; the node test passes.
- Step 7: Run the tests —
godot --headless --path . --import > /dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres, then GUT ontests/test_ship_skin.gd,tests/test_tunables_registry.gd,tests/test_live_scope.gd,tests/test_baked_shader_materials.gd. Expected: all PASS. Iftest_live_scopeflags a key, the read is in a one-shot-named function: move it intoupdate()/pulse(), never add a non-LIVE LiveScope row.
Task 4: PlayerRenderer + main wiring + grant_skin dev command
Section titled “Task 4: PlayerRenderer + main wiring + grant_skin dev command”Files:
- Modify:
render/player_renderer.gd(skinnable/skin_idvars,_readyUSE_BAKED_HULL block,set_tilt,update_visual, newskin_pulse/skin) - Modify:
main.gd(~line 1858 renderer setup;_post_tickdash edge ~2222; hit block ~3417;apply_dev_commandnearreview_prompt_test~4513;build_capsdev_commands~5015) - Modify:
tests/test_main.gd:2117(exact caps list) - Create:
tests/test_ship_skin_main.gd
Interfaces:
-
Consumes:
ShipSkin(Task 3),MetaState.grant_skin/equipped_skin(Task 1). -
Produces:
PlayerRenderer.skinnable: bool(default false; main sets it true for PILOT 1 only, beforeadd_child; only a skinnable renderer builds aShipSkin, so co-op Player 2’sPlayerRenderer.new()never has one, even in the Dev A/B),PlayerRenderer.skin_id: String(set beforeadd_child),PlayerRenderer.skin() -> ShipSkin,PlayerRenderer.skin_pulse() -> void; dev command{"kind": "grant_skin", "id": "beta"}(Dev builds only, menu or run). -
Step 1: Write the failing test — create
tests/test_ship_skin_main.gd:
extends GutTest
# Ship skins through the REAL main.gd: the equipped skin reaches the run's hull, the console LOOK is# live mid-run, warps pulse the Glitch look, and the Dev grant_skin command uses the grant contract.# GUT counts as a Dev build.
func after_each() -> void: Tunables.reset() RemoteConfig.active_patch = {} RemoteConfig.active_id = "default" RemoteConfig.active_hash = 0 RemoteConfig.live_edited = false
func _boot(skin: String) -> Node: var m: Node = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m.meta.owned_skins.clear() m.meta.equipped_skin = "" if skin != "": m.meta.grant_skin(skin) m._on_mode_chosen("survival") return m
func test_the_equipped_skin_reaches_the_run_hull() -> void: var m := await _boot("beta") m._process(1.0 / 60.0) var s: ShipSkin = m.player_renderer.skin() assert_not_null(s) assert_eq(s.skin_id, "beta") assert_true(s.overlay.visible, "the Beta skin draws over the hull") assert_eq(s.overlay.texture, m.player_renderer.hull_sprite().texture)
func test_no_skin_draws_nothing() -> void: var m := await _boot("") m._process(1.0 / 60.0) assert_false(m.player_renderer.skin().overlay.visible)
func test_console_look_is_live_mid_run() -> void: var m := await _boot("") assert_true(m.apply_dev_command({"kind": "set_tunables", "tunables": {"ShipSkin.LOOK": 2}})) m._process(1.0 / 60.0) var s: ShipSkin = m.player_renderer.skin() assert_true(s.overlay.visible, "a console flip shows a candidate with no skin owned") assert_eq(int((s.overlay.material as ShaderMaterial).get_shader_parameter("look")), ShipSkin.HOLO)
func test_a_warp_pulses_the_skin() -> void: var m := await _boot("") var s: ShipSkin = m.player_renderer.skin() assert_eq(s._jitter_t, 0.0) m._last_dash_cd = 0.0 m.sim.player.dash_cd = 1.0 # a warp has just fired (main's edge detect: <= 0 -> > 0.1) m._physics_process(1.0 / 60.0) assert_gt(s._jitter_t, 0.0, "the warp edge pulses the Glitch jitter")
func test_grant_skin_dev_command_uses_the_grant_contract() -> void: var m := await _boot("") var saves := MetaStore.save_count assert_true(m.apply_dev_command({"kind": "grant_skin", "id": "beta"})) assert_true(m.meta.owns_skin("beta")) assert_eq(m.meta.equipped_skin, "beta") assert_gt(MetaStore.save_count, saves, "a new grant is saved") saves = MetaStore.save_count assert_true(m.apply_dev_command({"kind": "grant_skin", "id": "beta"}), "handled") assert_eq(MetaStore.save_count, saves, "an idempotent re-grant writes nothing") assert_true(m.apply_dev_command({"kind": "grant_skin", "id": "nope"}), "handled, refused") assert_false(m.meta.owns_skin("nope")) assert_has(m.build_caps()["dev_commands"], "grant_skin", "the console feature-detects it")
func test_only_a_skinnable_renderer_has_a_skin() -> void: var r := PlayerRenderer.new() add_child_autofree(r) assert_null(r.skin(), "co-op P2 is built with PlayerRenderer.new() and is never skinned, even in the Dev A/B")
func test_grant_skin_is_refused_outside_dev_builds() -> void: var m := await _boot("") assert_false(m._grant_skin_command({"id": "beta"}, false)) assert_false(m.meta.owns_skin("beta"))-
Step 2: Run it to verify it fails (GUT on
tests/test_ship_skin_main.gd). Expected: FAIL (skin()/grant_skinmissing). -
Step 3: PlayerRenderer — in
render/player_renderer.gd:
Add vars next to baked_class:
var skinnable := false # set true by main.gd for PILOT 1 only, BEFORE add_child; co-op P2's renderer keeps false and is never skinnedvar skin_id: String = "" # MetaState.equipped_skin; set by main.gd BEFORE add_child (like baked_class)var _skin: ShipSkin # cosmetic skin overlay over _baked (render/ship_skin.gd); null unless skinnable (and a baked hull)var _tilt_rad := 0.0 # the last ShipTilt value (the Holo-film / chrome looks follow the bank)In _ready, inside if USE_BAKED_HULL: right after _apply_hull_look():
# Cosmetic skin: an overlay repainting _baked. Added after it so it draws on top, and before _hull / # _flash (built below) so the ascension flash stays over it. if skinnable: _skin = ShipSkin.new() _skin.skin_id = skin_id add_child(_skin)In set_tilt(rad), first line: _tilt_rad = rad.
At the end of update_visual(...) add:
if _skin != null: _skin.update(_baked, _tilt_rad, dt)Add after hull_sprite():
func skin() -> ShipSkin: return _skin
# A warp or hard-hit edge from main: the Glitch skin's jitter (the other looks ignore it).func skin_pulse() -> void: if _skin != null: _skin.pulse()- Step 4: main.gd —
(a) Renderer setup (~1857): change
if meta != null: player_renderer.baked_class = _drawn_hull() # before add_child; _ready reads it (tutorial: starter hull)to
if meta != null: player_renderer.baked_class = _drawn_hull() # before add_child; _ready reads it (tutorial: starter hull) player_renderer.skin_id = meta.equipped_skin # cosmetic, render-only (render/ship_skin.gd); every mode player_renderer.skinnable = true # pilot 1 only: _spawn_player2_render's PlayerRenderer.new() stays unskinned(b) _post_tick dash edge: after audio.dash() add
if player_renderer != null: player_renderer.skin_pulse() # Glitch skin jitter on a warp(c) Hit block (if sim.player.hp < _last_hp - 0.001:), after var hard_hit := sim.player_hard_hits != _last_hard_hits add
if hard_hit and player_renderer != null: player_renderer.skin_pulse() # Glitch skin jitter on a discrete hit (not on DoT ticks)(d) apply_dev_command, directly after the review_prompt_test branch:
if String(cmd.get("kind", "")) == "grant_skin": return _grant_skin_command(cmd, BuildConfig.dev_tools()) # meta-only: menu or run (draws from the next run)and add the handler next to _review_prompt_test_command:
# Dev console {kind:"grant_skin", id:"beta"}: the App Clip handoff's grant without the clip, so the hull# picker's skin row can be checked on a device. Goes through MetaState.grant_skin (the same contract the# handoff calls) and saves only a new grant. The skin draws from the next run. `dev` = BuildConfig.dev_tools().func _grant_skin_command(cmd: Dictionary, dev: bool) -> bool: if not dev or meta == null: return false var id := String(cmd.get("id", "beta")) var added := meta.grant_skin(id) if added: MetaStore.save_state(meta) _toast("Skin %s: %s" % [id, "granted" if added else ("already owned" if meta.owns_skin(id) else "unknown")]) return true(e) build_caps() dev_commands: append "grant_skin" after "review_prompt_test". Update tests/test_main.gd:2117-2119 the same way (..., "autopilot", "review_prompt_test", "grant_skin"])).
-
Step 5: Run the tests — GUT on
tests/test_ship_skin_main.gd,tests/test_main.gd,tests/test_death_sequence.gd,tests/test_live_console_main.gd. Expected: PASS. Iftest_a_warp_pulses_the_skinfails because the tick consumed the edge differently, read_post_tick’s dash detect and drive it the waytests/test_main.gd::test_p2_dash_plays_warp_sound_in_coopdoes; do not weaken the assertion. -
Step 6: Determinism + suite —
GUT_LOG=$TMPDIR/bh-gut-beta-skin.log bash scripts/check-test-count.sh. Expected green, with the three determinism files untouched and passing.
Task 5: Skin row in the hull picker + device-profile fit tests
Section titled “Task 5: Skin row in the hull picker + device-profile fit tests”Files:
- Modify:
ui/ship_config_panel.gd - Create:
tests/test_ship_config_skin_row.gd
Interfaces:
- Consumes:
MetaState.owned_skins/equipped_skin/equip_skin(Task 1);ShipSkin.look_for/display_name/new_material/push_uniforms/OFF(Task 3). - Produces:
ShipConfigPanel._skin_lbl: Label,_skin_focus: bool,_cycle_skin(direction: int) -> void,_skin_row_shown() -> bool,_skin_hit_rect() -> Rect2(grows DOWNWARD only),_skin_hit_h() -> float,const SKIN_ROW_H := 28.0.
Design: the row (“SKIN ‹ BETA ›”) sits under the status line in the left (hero) column, pre-run only, and only when a skin is owned (no owned skin = the panel is unchanged, so 1.1 store players see nothing new). Pad/keys: down focuses the row, up returns to the hull carousel; while focused, left/right/A step through ["", owned...] and equip immediately (no stats, nothing to confirm) and save. Touch: a tap on the row (hit area at least 44 pt tall, grown downward only so it never covers the status line above) steps it. The hero thumbnail previews the equipped skin with the same shader, colours through the UI gamut (ui = true), and the locked-hull dim survives because the shader carries the modulate. The ship shrinks by the row’s reserved height (SKIN_ROW_H, or 6 + the 44 pt hit height when larger) when the row shows, so the whole hit rect stays on screen on iPhone.
- Step 1: Write the failing test — create
tests/test_ship_config_skin_row.gd:
extends GutTest
# The hull picker's skin row (spec 2026-10-14-app-clip-warden-design § Beta skin, "Equip UI"). Fit is# checked at the real device profiles (tests/shop_layout_fixture.gd: TV 1280, iPhone ~1204, iPad ~985# after the 1.3x touch zoom) over EVERY hull, owned and locked (a locked hull adds the LOCKED line, the# worst case for the left column's height).
const DEVICES: Array[String] = ["tv", "iphone", "ipad"]var _saved_platform := ""
func before_each() -> void: _saved_platform = LiveTokens.platform()
func after_each() -> void: LiveTokens.set_platform(_saved_platform) Platform.force_touch = false Platform.force_safe_insets_pt = Vector4(-1, -1, -1, -1) Platform.force_screen_scale = 0.0
func _meta(skin: bool) -> MetaState: var m := MetaState.new() if skin: m.grant_skin("beta") return m
func _panel() -> ShipConfigPanel: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame return p
func _action(name: String) -> InputEventAction: var e := InputEventAction.new() e.action = name e.pressed = true return e
func test_no_owned_skin_no_row_no_preview() -> void: var p: ShipConfigPanel = await _panel() p.open_config(_meta(false), null) assert_false(p._skin_lbl.visible, "nothing new for a player without a skin") assert_null(p._ship_thumb.material)
func test_owned_skin_shows_the_row_and_previews_it() -> void: var p: ShipConfigPanel = await _panel() p.open_config(_meta(true), null) assert_true(p._skin_lbl.visible) assert_string_contains(p._skin_lbl.text, "BETA") assert_not_null(p._ship_thumb.material, "the thumbnail previews the equipped skin") assert_eq(int((p._ship_thumb.material as ShaderMaterial).get_shader_parameter("look")), ShipSkin.BETA_LOOK)
func test_mid_run_has_no_row() -> void: var p: ShipConfigPanel = await _panel() p.open_config(_meta(true), Sim.new(1, SimContentFixture.db())) assert_false(p._skin_lbl.visible, "skins are chosen before a run")
func test_cycling_steps_none_and_beta_and_saves() -> void: var p: ShipConfigPanel = await _panel() var m := _meta(true) p.open_config(m, null) var saves := MetaStore.save_count p._cycle_skin(1) assert_eq(m.equipped_skin, "", "beta -> none") assert_string_contains(p._skin_lbl.text, "NONE") assert_null(p._ship_thumb.material, "no skin, plain thumbnail") p._cycle_skin(1) assert_eq(m.equipped_skin, "beta", "none -> beta (wraps)") assert_eq(MetaStore.save_count, saves + 2, "each step is saved at once")
func test_pad_down_focuses_the_row_and_left_right_drive_it() -> void: var p: ShipConfigPanel = await _panel() var m := _meta(true) p.open_config(m, null) var ship_before := p._ship_index p._input(_action("ui_down")) assert_true(p._skin_focus) p._input(_action("ui_right")) assert_eq(m.equipped_skin, "", "right steps the skin") assert_eq(p._ship_index, ship_before, "not the hull carousel while the row has focus") p._input(_action("ui_accept")) assert_eq(m.equipped_skin, "beta", "A steps it too") p._input(_action("ui_up")) assert_false(p._skin_focus) p._input(_action("ui_right")) assert_ne(p._ship_index, ship_before, "back on the hull row, right pages the hull")
func test_a_locked_hull_keeps_its_dim_under_the_skin() -> void: var p: ShipConfigPanel = await _panel() p.open_config(_meta(true), null) var found := false for i in ShipBonuses.ORDER.size(): p._ship_index = i p._page_ship(0) if p._thumb_locked: found = true assert_not_null(p._ship_thumb.material, "a locked hull still previews the skin") assert_eq(p._ship_thumb.modulate, p._thumb_tint(), "the grey dim is still the thumbnail's modulate; the shader multiplies it in") assert_true(found, "sanity: a fresh save has a locked hull")
func test_down_does_nothing_without_a_skin() -> void: var p: ShipConfigPanel = await _panel() p.open_config(_meta(false), null) p._input(_action("ui_down")) assert_false(p._skin_focus)
func test_a_tap_on_the_row_steps_it_and_is_not_a_hull_tap() -> void: Platform.force_touch = true var p: ShipConfigPanel = await _panel() var m := _meta(true) p.open_config(m, null) await get_tree().process_frame var calls := p._confirm_ship_call_count var t := InputEventScreenTouch.new() t.index = 0 t.pressed = true t.position = p._skin_lbl.get_global_rect().get_center() p._input(t) assert_eq(m.equipped_skin, "", "the tap stepped the skin") assert_false(p._ship_drag_active, "not tracked as a hull drag") assert_eq(p._confirm_ship_call_count, calls, "not a hull confirm")
func test_skin_row_fits_on_every_device_and_every_hull() -> void: for dev in DEVICES: var pr := ShopLayoutFixture.profile(dev) LiveTokens.set_platform("tv" if dev == "tv" else _saved_platform) TokenBind.flush_now() Platform.force_touch = bool(pr["touch"]) Platform.force_screen_scale = float(ShopLayoutFixture.DEVICES[dev]["scale"]) Platform.force_safe_insets_pt = (pr["insets"] as Vector4) / float(pr["upt"]) var vp := SubViewport.new() vp.size = Vector2i((pr["vp"] as Vector2).round()) add_child_autofree(vp) var p := ShipConfigPanel.new() vp.add_child(p) await get_tree().process_frame var m := _meta(true) # owns only the frigates: the cruiser is LOCKED (worst case) p.open_config(m, null) var ins: Vector4 = pr["insets"] var vps: Vector2 = pr["vp"] var safe := Rect2(Vector2(ins.x, ins.y), vps - Vector2(ins.x + ins.z, ins.y + ins.w)) for i in ShipBonuses.ORDER.size(): p._ship_index = i p._page_ship(0) await get_tree().process_frame var tag := "%s %s" % [dev, ShipBonuses.ORDER[i]] var row := p._skin_lbl.get_global_rect() assert_true(p._skin_lbl.visible, tag) assert_true(safe.grow(0.5).encloses(row), "%s: row %s inside the safe area %s" % [tag, row, safe]) assert_false(row.intersects(p._status_lbl.get_global_rect()), "%s: clear of the status line" % tag) assert_false(row.intersects(p._ship_thumb.get_global_rect()), "%s: clear of the ship" % tag) if p._lock_lbl.visible: assert_false(row.intersects(p._lock_lbl.get_global_rect()), "%s: clear of LOCKED" % tag) assert_lte(row.end.x, p._weapons_hdr.get_global_rect().position.x + 0.5, "%s: left of the loadout column" % tag) var hit := p._skin_hit_rect() assert_false(hit.intersects(p._status_lbl.get_global_rect()), "%s: tap target clear of the status line (a tap there equips the hull)" % tag) assert_true(safe.grow(0.5).encloses(hit), "%s: tap target %s inside the safe area %s" % [tag, hit, safe]) if bool(pr["touch"]): assert_gte(hit.size.y, 44.0 * float(pr["upt"]) - 0.5, "%s: tap target >= 44 pt" % tag)-
Step 2: Run it to verify it fails (GUT on
tests/test_ship_config_skin_row.gd). Expected: FAIL (_skin_lblmissing). -
Step 3: Implement in
ui/ship_config_panel.gd:
Constants/vars (with the others):
const SKIN_ROW_H := 28.0 # height the skin row takes from the hero column when shownvar _skin_lbl: Label # pre-run skin row "SKIN ‹ BETA ›" (only when a skin is owned)var _skin_focus := false # pad/keys: down focuses the row, up returns to the hull carouselvar _thumb_skin_mat: ShaderMaterial # the hero thumbnail's skin preview (ShipSkin's saved material)In _ready, right after _root.add_child(_status_lbl):
_skin_lbl = _label("", NeonTheme.mono_font(), &"font.sm", _skin_color) _skin_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _skin_lbl.visible = false _root.add_child(_skin_lbl)In open_config, before _rebuild_for_ship(...): _skin_focus = false.
In _layout(): after var chev_w := ... add var skin_h := maxf(SKIN_ROW_H, 6.0 + _skin_hit_h()) if _skin_row_shown() else 0.0 (reserves room for the full tap target below the status line); change the ship and hc lines to:
var ship := clampf(minf(hx1 - hx0 - chev_w * 2.0, ch - 64.0 - skin_h), 110.0, SHIP_SIZE * 1.6) var hc := Vector2((hx0 + hx1) * 0.5, cy0 + (ch - 48.0 - skin_h) * 0.5)and after the _status_lbl.size = ... line add:
_skin_lbl.position = Vector2(hx0, _status_lbl.position.y + 26.0) _skin_lbl.size = Vector2(hx1 - hx0, 22) _refresh_skin()New functions (after _refresh_status):
func _skin_row_shown() -> bool: return _pre_run and _meta != null and not _meta.owned_skins.is_empty()
func _skin_color() -> Color: return Tokens.c(&"accent.main" if _skin_focus else &"text.secondary")
# The row text + the thumbnail preview. Also drops focus when the row is hidden.func _refresh_skin() -> void: var shown := _skin_row_shown() _skin_lbl.visible = shown if not shown: _skin_focus = false else: _skin_lbl.text = "SKIN ‹ %s ›" % ShipSkin.display_name(_meta.equipped_skin).to_upper() _skin_lbl.add_theme_color_override("font_color", _skin_color()) # the binding re-derives it on a token change _apply_thumb_skin()
# Preview the equipped skin on the hero thumbnail with the run's own shader. The Dev console LOOK is NOT# applied here (dev = false): the picker shows what the player owns.func _apply_thumb_skin() -> void: var look := ShipSkin.look_for(_meta.equipped_skin if _meta != null else "", false) if look == ShipSkin.OFF or _ship_thumb.texture == null: _ship_thumb.material = null return if _thumb_skin_mat == null: _thumb_skin_mat = ShipSkin.new_material() var px := float(_ship_thumb.texture.get_width()) / maxf(_ship_thumb.size.x, 1.0) # ui = true: this is a UI CanvasLayer, which widens only via ColorGamut.ui_out (not the run's gamut). ShipSkin.push_uniforms(_thumb_skin_mat, look, 0.0, 0.0, px, HdrOutput.on(), true) _ship_thumb.material = _thumb_skin_mat
# Step the equipped skin through ["" (none), owned...] and save at once: a skin has no stats, so there# is nothing to confirm.func _cycle_skin(direction: int) -> void: if not _skin_row_shown(): return var options: Array[String] = [""] options.append_array(_meta.owned_skins) var i := maxi(options.find(_meta.equipped_skin), 0) _meta.equip_skin(options[(i + direction + options.size()) % options.size()]) MetaStore.save_state(_meta) _ui_nav() _refresh_skin()
# The row's tap target height: at least 44 pt (Apple's minimum), never less than the label.func _skin_hit_h() -> float: return maxf(22.0, 44.0 * Platform.units_per_pt(get_viewport()))
# The tap target: the label widened 8 each side and grown DOWNWARD only to _skin_hit_h(). Growing upward# would cover the bottom of _status_lbl ("TAP TO EQUIP"), so a tap there would step the skin instead of# equipping the hull; _layout reserves the room below so it stays inside the safe area.func _skin_hit_rect() -> Rect2: var r := _skin_lbl.get_global_rect() return Rect2(r.position.x - 8.0, r.position.y, r.size.x + 16.0, maxf(_skin_hit_h(), r.size.y))In _input, pre-run touch branch, inside if event.pressed: right after the BACK-button check:
if _skin_lbl.visible and _skin_hit_rect().has_point(event.position): _cycle_skin(1) get_viewport().set_input_as_handled() returnIn _input, pre-run, immediately before var confirm := event.is_action_pressed("ui_accept"):
if _skin_lbl.visible and (MenuNav.is_down(event) or MenuNav.is_up(event)): _skin_focus = MenuNav.is_down(event) _ui_nav() _refresh_skin() get_viewport().set_input_as_handled() return if _skin_focus: var step := event.is_action_pressed("ui_accept") if not step and event is InputEventJoypadButton: var sj: InputEventJoypadButton = event step = sj.pressed and sj.button_index == JOY_BUTTON_A if step or MenuNav.is_right(event) or MenuNav.is_left(event): _cycle_skin(-1 if MenuNav.is_left(event) else 1) get_viewport().set_input_as_handled() return-
Step 4: Run the tests — GUT on
tests/test_ship_config_skin_row.gdandtests/test_ship_config_panel.gd. Expected: PASS. If the fit test fails on a device, fix the LAYOUT (e.g. the 26-unit status-to-row gap, orskin_h), never loosen the assertion or test one width only. -
Step 5: Visual QA — dispatch
bh-visual-qa(or runzsh scripts/ui-sweep.shif it captures the ship config panel) with a save that owns Beta, on iPhone, iPad and TV sizes, SDR and HDR. Check: row legible, not clipped, focus colour visible, thumbnail preview reads like the in-run skin (colours go throughColorGamut.ui_out, so they should match the rest of the UI; an HDR mismatch with the run is still plausible, report it, don’t hide it; note the uniforms are pushed once per_refresh_skin, not on a mid-open gamut/HDR change).
Task 6: VFX library entry, capture harness, architecture docs, full gate
Section titled “Task 6: VFX library entry, capture harness, architecture docs, full gate”Files:
- Modify:
tools/vfx_library/library_manifest.gd(RATINGS + entries, afterplayer_hull) - Modify:
tests/test_vfx_library_manifest.gd(EXPECTED_EFFECTS) - Create:
tools/skin_capture/skin_capture.gd,tools/skin_capture/skin_capture.tscn - Modify:
docs/architecture/rendering-and-performance.md(new section before## Player death sequence),docs/architecture/remote-config.md(new section at the end)
Interfaces:
-
Consumes: everything above.
PlayerRenderer.skin(),ShipSkin.force_look,ShipSkin.update. -
Step 1: Failing test — in
tests/test_vfx_library_manifest.gd, add"ship_skin",after"player_hull",inEXPECTED_EFFECTS. Run it: FAIL (count and missing id). -
Step 2: Manifest entry — in
tools/vfx_library/library_manifest.gd, add to RATINGS after theplayer_deathline:
# Ship skins (2026-10-15, plan 2026-10-15-beta-skin): four Beta candidates, reviewer's read off # tools/skin_capture, provisional until Chris's on-device A/B picks the winner. "ship_skin": 3,and in entries(), after the player_hull entry:
_entry("ship_skin", "Ship Skins (Beta candidates)", "ENTITY", "Cosmetic repaint over any hull, four A/B candidates on the console's Ship skin A/B: Prototype wireframe (cyan edge and panel lines over a near-black fill, slow scanline sweep), Holo-film (the Supernova's thin-film iridescence, shifting as the ship banks), Glitch build (RGB split that widens with a row jitter on a warp or hit), Void chrome (black chrome, one white-hot rim, faint violet afterimage trail).", "render/ship_skin.gd · render/shaders/ship_skin.gdshader", "REVIEW", "entity", Color(0.35, 0.95, 1.0)),Re-run the manifest test: PASS.
- Step 3: Capture harness —
tools/skin_capture/skin_capture.tscn:
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/skin_capture/skin_capture.gd" id="1"]
[node name="SkinCapture" type="Node"]script = ExtResource("1")tools/skin_capture/skin_capture.gd (modelled on tools/player_glow_capture/player_glow_capture.gd, which is the reference for the HDR plumbing):
extends Node# NOTE: _set_profile / _grab / _frames and the boot-and-freeze block are COPIED from# tools/player_glow_capture/player_glow_capture.gd (Dev-only tool): keep in step with it.# Ship skin capture -- DEV ONLY (plan 2026-10-15-beta-skin). Boots the real game (main.tscn: real# PlayerRenderer + ShipSkin, real WorldEnvironment + colour profiles) into a TV-sized SubViewport, freezes# the sim, and saves a 4x crop of the ship for every look under three conditions:# S8 8-bit canvas, SDR profile SL linear canvas, SDR profile (Metal L0, tvOS from boot)# H linear canvas, HDR profile at CS_HEADROOM (default 4.93, the ATV)# Looks: 0 (no skin), 1 wireframe, 2 holo (tilt 0 and full bank), 3 glitch (rest and mid-pulse), 4 chrome.# CS_OUT=/abs/dir perl -e 'alarm 600; exec @ARGV' \# godot --path . --rendering-method mobile res://tools/skin_capture/skin_capture.tscn# Then: git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot
const SIZE := Vector2i(1920, 1080)const HALF := 40 # crop half-size, px (the ship is ~30 px across at 1080p)const UPSCALE := 4
var vp: SubViewportvar mainvar _fake_win: Window
func _ready() -> void: vp = SubViewport.new() vp.size = SIZE vp.use_hdr_2d = true vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS vp.handle_input_locally = false add_child(vp) main = preload("res://main.tscn").instantiate() vp.add_child(main) await _frames(2) seed(1234) main._on_mode_chosen("survival") main._capturing = true if main.telemetry != null: main.telemetry._enabled = false main.sim.player.dev_invuln = true main.quality_manager.auto = false main.quality_manager.set_level(0) await _frames(30) main._paused_for_menu = true # freeze the sim ticks. main._process STILL runs (tilt, update_visual -> skin.update), # so each shot below stops main's _process around the grab instead of fighting it. var pr: PlayerRenderer = main.player_renderer var skin: ShipSkin = pr.skin() var out := OS.get_environment("CS_OUT") if out == "": out = ProjectSettings.globalize_path("res://tools/skin_capture/out") DirAccess.make_dir_recursive_absolute(out) var h_env := OS.get_environment("CS_HEADROOM") var headroom := float(h_env) if h_env != "" else 4.93 # [tag, hdr profile, linear canvas] var conds := [["S8", false, false], ["SL", false, true], ["H", true, true]] # [tag, look, tilt (rad), pulse] var shots := [["0", 0, 0.0, false], ["1wire", 1, 0.0, false], ["2holo", 2, 0.0, false], ["2holo_bank", 2, ShipTilt.MAX_TILT, false], ["3glitch", 3, 0.0, false], ["3glitch_hit", 3, 0.0, true], ["4chrome", 4, 0.0, false]] for c: Array in conds: _set_profile(bool(c[1]), headroom) vp.use_hdr_2d = bool(c[2]) ShaderGlobals.push_alpha(HdrLook.active, bool(c[2])) await _frames(5) main._apply_color_profile() for s: Array in shots: # main._process (left running through the profile settle above, which needs _sync_hdr_output) # re-pushes the real tilt (0) and decays the jitter by real frame time every frame. Stop it for # the grab, then drive the skin once: tilt and pulse land exactly as asserted below. main.set_process(false) skin.force_look = int(s[1]) if bool(s[3]): skin.pulse() skin.update(pr.hull_sprite(), float(s[2]), 0.0) # dt 0: the pulse is still at full jitter await _frames(3) # the skin is untouched meanwhile (nothing else calls update) var mat := skin.overlay.material as ShaderMaterial print("[skin] %s %s look=%d tilt=%.2f jitter=%.2f overlay=%s" % [c[0], s[0], int(mat.get_shader_parameter("look")), float(mat.get_shader_parameter("tilt")), float(mat.get_shader_parameter("glitch_jitter")), skin.overlay.visible]) _save(await _grab(), pr.get_global_transform_with_canvas().origin, out, "%s_%s" % [c[0], s[0]], not bool(c[2])) main.set_process(true) skin.force_look = -1 Tunables.reset() print("[skin] saved crops to ", out) if _fake_win != null: _fake_win.free() get_tree().quit()
func _save(img: Image, center: Vector2, out: String, tag: String, srgb: bool) -> void: var crop := img.get_region(Rect2i(int(center.x) - HALF, int(center.y) - HALF, HALF * 2, HALF * 2)) crop.convert(Image.FORMAT_RGBA8) if not srgb: crop.linear_to_srgb() # clips > 1: viewing only crop.resize(HALF * 2 * UPSCALE, HALF * 2 * UPSCALE, Image.INTERPOLATE_NEAREST) crop.save_png(out.path_join("skin_%s.png" % tag))
# As tools/player_glow_capture: main's own HDR path, with a detached window standing in for an HDR screen.func _set_profile(hdr: bool, h: float) -> void: if _fake_win == null: _fake_win = Window.new() HdrOutput.set_on(true) main._hdr_output_capable = true main._hdr_window_override = _fake_win main._hdr_supported_override = hdr var reported := _fake_win.get_output_max_linear_value() HdrLook.PEAK_CALIBRATION = h / maxf(reported, 1.0e-3) HdrLook.override = 1 if hdr else 0
func _grab() -> Image: await RenderingServer.frame_post_draw var img := vp.get_texture().get_image() img.convert(Image.FORMAT_RGBAF) return img
func _frames(n: int) -> void: for i in n: await get_tree().process_frameNote: ShipSkin.update reads HdrOutput.on(); _set_profile sets it on for all three conditions (as the glow capture does), so S8/SL use the *_HDR gains/colours. If the [skin] lines show that, either add HdrOutput.set_on(bool(c[1])) after _set_profile for the SDR rows or record it in the output note; report which you did.
- Step 4: Run the harness (foreground):
godot --headless --path . --import > /dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tresCS_OUT=$TMPDIR/skin_capture perl -e 'alarm 600; exec @ARGV' godot --path . --rendering-method mobile res://tools/skin_capture/skin_capture.tscn 2>&1 | grep -E "\[skin\]|ERROR|WARNING"git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godotExpected: 21 [skin] lines, overlay=true for every non-zero look, look= matching, jitter=1.00 only on 3glitch_hit, tilt=1.00 on 2holo_bank (main’s _process is stopped around each grab, so both are real); 21 PNGs. Open each and confirm by eye that every look is visibly distinct from 0 and from each other, the Glitch at rest reads as a deliberate style (not a broken frame), and nothing is blown out or invisible in H. Report the observations with the file paths; do not tune by eye beyond fixing an obvious defect (Chris tunes on device).
- Step 5: Docs — add to
docs/architecture/rendering-and-performance.md, before## Player death sequence:
## Ship skins — `ShipSkin` (2026-10-15, plan `2026-10-15-beta-skin`)
Cosmetic, render-only skins (spec `2026-10-14-app-clip-warden-design.md` § Beta skin). `render/ship_skin.gd`is a child of `PlayerRenderer` (added right after the baked hull `_baked`, before `_hull`/`_flash`): anoverlay `Sprite2D` that copies `_baked`'s texture, transform and visibility every frame(`PlayerRenderer.update_visual` → `ShipSkin.update(_baked, tilt, dt)`) and repaints it with`render/shaders/ship_skin.gdshader` (saved `ship_skin_mat.tres`, duplicated per instance). Because it copieswhatever the hull draws, it applies over every hull, the Nova hulls and tilt-flipbook frames. One shader,four looks selected by the `look` uniform (one pipeline: prewarm family 3b): 1 Prototype wireframe (alpha +luminance edges in `WIRE_COLOR` over `WIRE_FILL`, scanline), 2 Holo-film (`iridesce()` from`render/shaders/thin_film.gdshaderinc`, a verbatim copy of the Supernova's, pinned equal by`tests/test_ship_skin_shader.gd`; the Supernova shaders were deliberately not edited, A17 baker risk),3 Glitch build (RGB split, `pulse()` on a warp or hard hit from main's edge detects widens it and slidesrows for `GLITCH_JITTER_S`), 4 Void chrome (black body + reflection bands, one rim lit from a fixed sprite-spacedirection, up to `GHOST_CAP` violet afterimages on `hero_glow_mix_mat`, world-fixed, normal tree children soa hidden ship hides them). Output alpha goes through `hdr_alpha()`; colour uniforms through `cs_in()` andare pushed as `ColorGamut.out(c, PATH_B)`; every colour/gain has an `*_HDR` twin picked by `HdrOutput.on()`.Which look draws: `ShipSkin.LOOK` (Dev builds only; store builds ignore it) else the equipped skin(`BETA_LOOK` for "beta") else nothing. Known limits: the Dev-only `SHADER_WARP` tilt mode does not warp theoverlay; `WarpAfterimages` ghosts show the base hull; only pilot 1's `PlayerRenderer` (`skinnable = true`, set by main) has a skin, co-op P2 never does. The hull picker previewsthe equipped skin on its thumbnail with the same material (colours via `ColorGamut.ui_out`; the shader multiplies the sprite's modulate RGB in, so a locked hull's dim survives). Capture harness: `tools/skin_capture/`. Pinned by`tests/test_ship_skin.gd`, `tests/test_ship_skin_main.gd`, `tests/test_ship_config_skin_row.gd`.Append to docs/architecture/remote-config.md:
## Ship skins (2026-10-15)
Render-side `ShipSkin` tunables (`render/ship_skin.gd`), group **Ship skins** in the console(`tools/tunable_docs/docs.d/ship_skin.json`). All 23 keys are `"local": true` (render-only, per device)and read per frame, so every edit is live mid-run. `ShipSkin.LOOK` (int 0-4) is the Dev-only skin A/B thespec calls `ship_skin`: honoured only when `BuildConfig.dev_tools()`, so a published patch can never switchan unreviewed skin on for App Store players (Apple 2.3.1). `ShipSkin.BETA_LOOK` (int 1-4) picks which lookthe Beta skin draws; it only matters to players who own the skin, so patching it is a look patch. Themanifest is not regenerated here; do it at the BUILD bump that ships it.- Step 6: Full gate —
bash scripts/gate.shfrom the worktree (withGUT_LOG=$TMPDIR/bh-gut-beta-skin.logexported). Expected: exit 0; the three determinism tests pass untouched; node tests pass (tunable docs not stale). Also re-run the Task 2 Step 8 windowed compile check under both renderers (the shader is final now) and restoreproject.godot audio/bus_layout.tres.
Task 7: Device checks, Chris’s A/B, bake the winner (controller + Chris)
Section titled “Task 7: Device checks, Chris’s A/B, bake the winner (controller + Chris)”This task is not an implementer dispatch. The controller runs the device steps; Chris makes the calls.
- Step 1: Baked iPhone Dev install (A17 shader-baker check) — the branch adds a shader, so before merge install a BAKED iPhone Dev build from the worktree (skill
bh-worktree-device-install). Check that glass, menu controls, the ship and its skin all draw. If menu controls or the ship vanish with nothing in godot.log: say first that an uninstall wipes the Dev app’s save, thendevicectl device uninstall app+ clean install of the same build; if a clean install still fails, it is the godot#124088 class bug, so stop and report. - Step 2: Fresh-install stall check — skill
bh-fresh-install-stall-checkon the iPhone and ATV builds (new prewarm family 3b). - Step 3: Chris A/Bs the four looks live on device (HUMAN) — on the ATV and iPhone, mid-run, through the
dark-cosmos-consoleMCP (never raw curl; never while another agent is installing to that device):send_command {"kind": "set_tunables", "tunables": {"ShipSkin.LOOK": N}}for N = 1..4 (0 = off). Verify each by the on-device “Console: …” change note, not/status(it lags 10-20 s).- Each look in SDR (
{"kind": "color_profile", "profile": "sdr"}) and HDR ({"kind": "color_profile", "profile": "hdr"}; HDR output on via the console’s HDR row), so both colour systems are reviewed. Warp and take hits for the Glitch look; fly and bank for Holo-film and the chrome trail. - Chris tunes the per-look knobs live in the console’s “Ship skins” group.
- Step 4: Bake the result — once Chris picks: set
static var BETA_LOOK(and any tuned knob) defaults inrender/ship_skin.gdto his values, update the matching row bounds only if a value fell outside them, set"ship_skin"inlibrary_manifest.gdRATINGS to his score and status to CURRENT, and note the winner + date indocs/architecture/rendering-and-performance.md§ Ship skins. The losing looks stay in the shader (Chris’s call whether to strip them later). Gate, commit, push perdocs/reference/builds-and-releases.md. - Step 5: Device check of the picker —
send_command {"kind": "grant_skin", "id": "beta"}on the menu, open the hull picker: the row appears, pad down/left/right (ATV) and tap (iPhone) step it, and the next run draws the chosen skin.
Self-review
Section titled “Self-review”- Spec coverage: skins separate from hulls and render-only (Tasks 1, 3, 4; no
/simchange beyondmeta_state.gd);owned_skins+equipped_skinpersisted with old-save compatibility (Task 1);ShipSkinlayer over any hull (Tasks 3-4); four looks behind a live tunable (Tasks 2-3, keyShipSkin.LOOK); Holo-film reuses the Supernova thin film (Task 2 include + drift test); covered saved materials +hdr_alpha(Task 2); SDR and HDR review (Task 3 twins, Task 6 capture, Task 7 A/B); equip row in the hull picker fit-tested at iPad/iPhone/TV (Task 5); grant API for the clip handoff (Task 1, documented contract); shader compile under both renderers + baked iPhone check (Tasks 2, 6, 7); determinism untouched (Global Constraints, gates in Tasks 4 and 6); vfx_library entry + score (Task 6). - Out of scope here (separate plan): App Group container read,
clip_rewards.jsonhandoff,addons/dc_appclip, the clip, launch screen. - Placeholder scan: no TBD/TODO; every code step has the code. Two steps carry explicit fallbacks with a stated rule (Task 4 Step 5 warp drive, Task 6 Step 3 HdrOutput on SDR rows), not open-ended “adapt” instructions.
- Type consistency:
look_for(skin, dev) -> int,push_uniforms(mat, look, tilt01, jitter, px_texels, hdr),update(hull, tilt, dt) -> int,pulse(),new_material(),display_name(),skin_id,force_look,overlay,_ghosts,_jitter_t,GHOST_CAPare used with the same names in Tasks 3-6; uniform names match between Task 2’s shader and Task 3’spush_uniforms;grant_skin/equip_skin/owns_skin/owned_skins/equipped_skin/KNOWN_SKINSmatch across Tasks 1, 4, 5.