Zum Inhalt

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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 (agentCount ramp, populationShare species-migration). spawnPattern is 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 Button showing state + icon: cycling → Label("Cycle On", systemImage: "arrow.triangle.2.circlepath"), paused → Label("Cycle Off", systemImage: "pause.fill"); action controller.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) and App Icon - App Store.imagestack (1280×768): one flat .imagestacklayer each (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) and Top 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:

"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=appletvos*]": "TVBrandAssets"

(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.sh keeps it reproducible.