Inc 12c — tvOS T1 Device-Iteration 2 (Phase 1)¶
Date: 2026-06-16
Status: Approved (design), ready for planning
Scope: apple/PhysarumApp/Sources/{Ambient,TV,Model,Player} + Resources/Assets.xcassets + project.yml + Tests.
Engine / WGSL untouched (the structural-param morph that would touch the engine is deferred to Phase 2).
Motivation (Johannes' second device test, ATV 4K)¶
- Preset switch visibly "restarts" the sim. Root-caused: the morph ends at t=1 with
AppState.select()→MetalView.sync→RenderCoordinator.switchPreset(), which rebuilds the SimEngine (agents re-seed + feedback reset). Exactly one re-seed per switch (verified best/worst case), but it reads as a restart because it snaps after the 4 s look-morph. Johannes wants a seamless transition. - A simple touchpad click does nothing. The player handles only swipes (
onMoveCommand) and the dedicated Play/Pause button (onPlayPauseCommand); the touchpad select click is unhandled → no cycle toggle, no HUD. - Overlay cycle button unreachable + naming. The overlay's "Pause/Resume Cycle" button couldn't be focus-reached; rename to "Cycle On/Off" (consistent with the HUD) and make it reachable + centered.
- tvOS app icon missing + (later) animated top-shelf preview.
Goals (Phase 1)¶
- G1 — Restart-free preset transition. No re-seed on a cycler transition; the look morphs continuously and the living agents persist (feedback/echo continues). Structure (
agentCount/spawnPattern/populationShare) is held for now. - G2 — Select-click toggles the cycle (+ HUD), in addition to the dedicated Play/Pause button.
- G3 — Overlay cycle toggle renamed "Cycle On/Off", centered, focus-reachable via swipe-up from the carousel.
- G4 — tvOS app icon (layered/flat brand asset) + a static Top Shelf image.
Non-Goals (→ Phase 2 / later)¶
- Morphing the structural params (
agentCountramp,populationSharespecies-migration).spawnPatternis seed-only and invisible without a re-seed, so it is never "morphed". - Animated Top Shelf preview (TVTopShelf content extension).
- Any change to Engine / Renderer shaders / WGSL, or to iOS/iPad library-pick behavior beyond the note in G1.
Design¶
G1 — Restart-free transition¶
Two coordinated changes, both app-layer:
(a) RenderCoordinator.switchPreset re-seeds only on a structural change.
Extract the structural-diff predicate (already implicit in setParams) into a shared pure helper and reuse it:
static func isStructuralChange(_ a: PresetParams, _ b: PresetParams) -> Bool {
a.agentCount != b.agentCount
|| a.spawnPattern != b.spawnPattern
|| zip(a.species, b.species).contains { $0.populationShare != $1.populationShare }
}
switchPreset becomes:
func switchPreset(_ preset: Preset, live: PresetParams) {
guard preset.id != currentPresetID else { return }
currentPresetID = preset.id
let structural = Self.isStructuralChange(live, params)
params = live
guard structural else { sim.updateLiveParams(live); return } // seamless look-only adoption
respawnWork?.cancel()
if let s = try? Self.makeSim(device: device, lib: lib, field: currentField, preset: live, tier: tier, queue: queue) {
sim = s
renderer.resetFeedback()
}
}
setParams reuses the same helper for needsRespawn.
Cross-platform note: on iOS/macOS a library pick sets edited = full target (structure differs) → still re-seeds (unchanged behavior). The only change is that a pick of a preset whose structure equals the current one becomes a seamless look-change instead of a re-seed — a rare, benign improvement; no test asserts the old behavior (RenderCoordinatorTests only checks init).
(b) AmbientController completes the morph without adopting the target's structure.
Add AppState.adoptTransition(id:) — like select but it does not overwrite edited (the morph already left it at target-look + held-structure):
/// Adopt a preset as the new selection during a seamless cycler transition: update
/// selection + baseline, but DON'T touch `edited` — the morph already set it to the
/// target's look while holding the running sim's structure, so no re-seed is triggered.
func adoptTransition(id: String) {
guard let p = presets.first(where: { $0.id == id }) else { return }
selectedID = id
baseline = p.params
undoStack.removeAll(); redoStack.removeAll()
}
In AmbientController.tick, at t >= 1 call appState.adoptTransition(id: m.toID) instead of appState.select(id: m.toID). Everything else (structure-pinned pinnedMorph during the morph) is unchanged.
Result: at completion edited = target-look + held-structure; switchPreset sees no structural change → no rebuild, no feedback reset → the agents and trails flow straight through. No restart.
Phase-1 limitation (documented): because structure is never re-adopted, the whole cycle runs at the session's initial preset's agentCount/spawnPattern/populationShare. spawnPattern is invisible; density/species-ratio stay constant. Phase 2 morphs these.
G2 — Select-click toggles the cycle¶
In TVPlayerView, add a select handler to the player layer (gated like the existing play/pause):
.onTapGesture { if !controller.overlayVisible { withAnimation { controller.apply(.togglePlaylist) } } }
(onTapGesture on a focused tvOS view fires on the touchpad select click.) The existing onPlayPauseCommand (dedicated button) stays.
G3 — Overlay cycle toggle: rename, center, reachable¶
In TVOverlayView, restructure the panel header so the toggle is centered and in the vertical focus path above the carousel:
- Preset name centered (
.frame(maxWidth: .infinity)). - A centered cycle toggle
Buttonshowing state + icon: cycling →Label("Cycle On", systemImage: "arrow.triangle.2.circlepath"), paused →Label("Cycle Off", systemImage: "pause.fill"); actioncontroller.apply(.togglePlaylist). - Carousel below in its
.focusSection().
Swipe ▲ from the carousel moves focus up to the toggle; ▼ moves back (and the root .onMoveCommand { .down } still dismisses when focus is already at the top). Focus reachability is empirical (tvOS) → verified on sim/device; the select-click (G2) is an independent way to toggle, so the toggle is never the only path.
G4 — tvOS app icon + static Top Shelf¶
Add a tvOS Brand Assets set TVBrandAssets.brandassets to Assets.xcassets:
App Icon.imagestack(400×240) andApp Icon - App Store.imagestack(1280×768): one flat.imagestacklayereach (single layer = valid non-parallax icon), the square Physarum logo centered on the KSP background, fit to the 5:3 canvas.Top Shelf Image.imageset(1920×720 @1x, 3840×1440 @2x) andTop Shelf Image Wide.imageset(2320×720 @1x, 4640×1440 @2x): logo + wordmark on a wide KSP background.
PNG generation via a new scripts/gen-tvos-icons.sh (sips/SVG → the exact sizes, composited onto the canvases), reproducible like gen-icons.sh.
project.yml: set the tvOS-SDK app-icon name without disturbing macOS/iOS:
(AppIcon stays the default for macOS/iOS.)
Tests & Verification¶
Host-unit (macOS):
- RenderCoordinator.isStructuralChange: true on agentCount/spawnPattern/populationShare diff; false when only look params differ. (pure, new)
- AmbientControllerTests: after a manual .next morph completes (tick past t=1), selectedID == target, baseline == target.params, and edited keeps the from structure (edited.agentCount == from.agentCount, spawnPattern, each populationShare) — proving the soft-adopt holds structure and won't re-seed.
- Existing AmbientController + RenderCoordinator tests stay green.
Build/visual: tvOS-sim build green; tvOS-sim screenshot shows the icon-less concerns are moot (icon shows on the Home screen — checked via the sim's app library). Device sign-off (Johannes): seamless transition (no restart) on a manual swipe + auto-cycle; select-click toggles + HUD; overlay "Cycle On/Off" reachable via swipe-up + centered; app icon present on the Home screen; static top-shelf shows when the app sits in the top row.
Risks¶
- tvOS focus reachability of the overlay toggle is empirical; G2's select-click is an independent toggle path so there's no dead-end.
- Brand-asset Contents.json structure is fiddly; the tvOS-sim build failing to find the icon is the gate (build warns/errors on a malformed brand asset).
gen-tvos-icons.shkeeps it reproducible.