Zum Inhalt

Adaptive Sim Format 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.

Goal: Let the simulation field adopt the screen's format (Auto, default) — filling natively with no letterbox/crop in any orientation — with an optional manual fixed format, plus tap-the-canvas-to-dismiss-the-editor.

Architecture: Pure functions map a view aspect + a SimFormat to integer field dimensions and a density-scaled agent count. RenderCoordinator holds a FieldSize (no longer a fixed square) and debounce-recreates the Renderer + SimEngine at the target size whenever the view aspect or format changes (reusing the existing main-thread debounce pattern). SimFormat is a @AppStorage-backed enum shared by RootView (drives the field) and the editor (the picker). The engine is already non-square-capable — no shader/engine changes.

Tech Stack: Swift 5 / SwiftUI (@AppStorage), PhysarumEngine (SimEngine/Renderer take separate width/height), XCTest in the hosted PhysarumAppTests target, XcodeGen + xcodebuild (macOS + iOS Simulator).


Background / IST-state (verified 2026-06-04)

  • RenderCoordinator (Sources/Player/RenderCoordinator.swift) creates private let renderer: Renderer + private var sim: SimEngine ONCE in init?(field:initialPreset:live:) at field×field (square, field=720). It has var cover (threaded from RootView), a 250 ms debounced respawn() via respawnWork: DispatchWorkItem, switchPreset, setParams, and draw(in:) which calls renderer.render(sim:preset:into:drawable:cover:). mtkView(_:drawableSizeWillChange:) is empty.
  • SimEngine(device:library:width:height:preset:) and Renderer(device:library:width:height:) take separate width/height. SimEngine seeds preset.agentCount agents. The sim shader (agentStep.wgsl) wraps on separate fieldWidth/fieldHeight. Non-square works with no engine change.
  • MetalView (Sources/Player/MetalView.swift) is a representable wrapping a container-pinned MTKView; var cover: Bool = false; sync(_:) sets c.cover, c.switchPreset, c.setParams. Created via makeCoordinator() → RenderCoordinator(field: field, …).
  • RootView (Sources/App/RootView.swift) has @AppStorage("renderCover") var cover, passes cover: to MetalView, computes fullW/fullH in a GeometryReader, frames the MetalView, floats the topbar, and presents the editor as .inspector/.sheet gated by showEditor.
  • EditorView (Sources/Editor/EditorView.swift) renders a header + PhysSections from the registry, takes @Bindable var state: AppState.
  • The render fit/cover viewport already cover/fit-crops a non-matching field into the drawable (kept for the manual-override case).
  • Tier-max agentCount is 500_000. Presets are tuned at a 512² baseline (memory physarum-preset-resolution-tuning).

Build & test commands

  • Single test class: cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/<Class> 2>&1 | tail -25
  • Full suite: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8
  • macOS build: xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -10
  • iOS-sim build: xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -12
  • (Do NOT run xcodegen generate — it would wipe the device's GUI signing in the .xcodeproj. New files under Sources//Tests/ are picked up by the existing generated project only after a regenerate; if a NEW file isn't found by the build, regenerate once and re-apply signing via the GUI. To avoid this, prefer adding the new type to an existing file if the build can't see a new one — but try the new file first, the folder-group usually includes it.)

NOTE: the launch-screen fix is already in; the app runs at native resolution. The render fill structure (explicit frame + viewport) stays.

File Structure

Create: - Sources/Model/SimFormat.swift — FieldSize, fieldSize(...), densityScaledAgentCount(...), SimFormat enum, targetFieldSize(...). Pure, one responsibility: format→dimensions math + the format enum. - Tests/SimFormatTests.swift — unit tests for the pure functions + the enum raw round-trip.

Modify: - Sources/Player/RenderCoordinator.swift — FieldSize-based; debounced field re-create; density-scaled sim construction; var format. - Sources/Player/MetalView.swift — format param threaded to the coordinator. - Sources/App/RootView.swift — @AppStorage("simFormat"); pass format: to MetalView; tap-canvas-dismisses-editor. - Sources/Editor/EditorView.swift — a Format picker bound to the same @AppStorage.

Do NOT modify: the engine, shaders, Renderer.swift, project.yml, Info.plist.


Task 1: SimFormat math (pure, unit-tested)

Files: - Create: apple/PhysarumApp/Sources/Model/SimFormat.swift - Create: apple/PhysarumApp/Tests/SimFormatTests.swift

  • [ ] Step 1: Write the failing tests

Create apple/PhysarumApp/Tests/SimFormatTests.swift:

import XCTest
@testable import Physarum

final class SimFormatTests: XCTestCase {
    func testSquareViewGivesSquareField() {
        XCTAssertEqual(fieldSize(viewWidth: 800, viewHeight: 800), FieldSize(width: 720, height: 720))
    }

    func testPortraitViewIsTallField() {
        // 402x874 ≈ 1:2.17 → short edge 720, long edge ~1564 (under the 1600 cap)
        let f = fieldSize(viewWidth: 402, viewHeight: 874)
        XCTAssertEqual(f.width, 720)
        XCTAssertTrue(f.height > 1400 && f.height <= 1600, "got \(f.height)")
        XCTAssertTrue(f.height > f.width)
    }

    func testLandscapeViewIsWideField() {
        let f = fieldSize(viewWidth: 874, viewHeight: 402)
        XCTAssertEqual(f.height, 720)
        XCTAssertTrue(f.width > f.height)
    }

    func testLongEdgeIsCapped() {
        // extreme aspect → long edge clamped to maxLong (1600)
        let f = fieldSize(viewWidth: 200, viewHeight: 4000)
        XCTAssertEqual(f.width, 720)
        XCTAssertEqual(f.height, 1600)
    }

    func testDegenerateViewFallsBackToSquare() {
        XCTAssertEqual(fieldSize(viewWidth: 0, viewHeight: 0), FieldSize(width: 720, height: 720))
    }

    func testDensityScalesWithArea() {
        // baseline area → unchanged
        XCTAssertEqual(densityScaledAgentCount(10_000, fieldWidth: 512, fieldHeight: 512), 10_000)
        // 2× area → ~2× count
        XCTAssertEqual(densityScaledAgentCount(10_000, fieldWidth: 512, fieldHeight: 1024), 20_000)
    }

    func testDensityRespectsTierMaxAndFloor() {
        XCTAssertEqual(densityScaledAgentCount(400_000, fieldWidth: 1024, fieldHeight: 1024), 500_000) // capped
        XCTAssertGreaterThanOrEqual(densityScaledAgentCount(50, fieldWidth: 512, fieldHeight: 512), 100) // floor
    }

    func testTargetFieldSizeAutoUsesViewAspect() {
        XCTAssertEqual(targetFieldSize(viewWidth: 800, viewHeight: 800, format: .auto),
                       fieldSize(viewWidth: 800, viewHeight: 800))
    }

    func testTargetFieldSizeFixedIgnoresView() {
        XCTAssertEqual(targetFieldSize(viewWidth: 800, viewHeight: 800, format: .square),
                       FieldSize(width: 720, height: 720))
        let p = targetFieldSize(viewWidth: 800, viewHeight: 800, format: .portrait)
        XCTAssertTrue(p.height > p.width)
        let l = targetFieldSize(viewWidth: 800, viewHeight: 800, format: .landscape)
        XCTAssertTrue(l.width > l.height)
    }

    func testSimFormatRawRoundTrip() {
        for f in SimFormat.allCases {
            XCTAssertEqual(SimFormat(rawValue: f.rawValue), f)
        }
    }
}
  • [ ] Step 2: Run to verify it fails

Run: cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/SimFormatTests 2>&1 | tail -20 Expected: compile failure — FieldSize/fieldSize/densityScaledAgentCount/SimFormat/targetFieldSize not found.

  • [ ] Step 3: Implement SimFormat.swift

Create apple/PhysarumApp/Sources/Model/SimFormat.swift:

import CoreGraphics
import Foundation

/// Integer simulation-field dimensions.
struct FieldSize: Equatable { let width: Int; let height: Int }

/// User-selectable field format. `.auto` matches the screen; the rest are fixed.
enum SimFormat: String, CaseIterable { case auto, square, portrait, landscape }

/// View aspect → field dimensions: short edge fixed at `base`, long edge proportional,
/// capped at `maxLong`. Square (or degenerate) view → base×base.
func fieldSize(viewWidth: Double, viewHeight: Double, base: Int = 720, maxLong: Int = 1600) -> FieldSize {
    guard viewWidth > 0, viewHeight > 0 else { return FieldSize(width: base, height: base) }
    let lo = min(viewWidth, viewHeight), hi = max(viewWidth, viewHeight)
    let long = min(Int((Double(base) * hi / lo).rounded()), maxLong)
    return viewHeight >= viewWidth
        ? FieldSize(width: base, height: long)      // portrait / square
        : FieldSize(width: long, height: base)      // landscape
}

/// Scale a preset's agentCount (tuned at the `baseline`² area) to the actual field
/// area, so trail density is preserved; clamped to [100, tierMax].
func densityScaledAgentCount(_ presetCount: Int, fieldWidth: Int, fieldHeight: Int,
                             baseline: Int = 512, tierMax: Int = 500_000) -> Int {
    let scale = Double(fieldWidth * fieldHeight) / Double(baseline * baseline)
    let scaled = Int((Double(presetCount) * scale).rounded())
    return min(max(scaled, 100), tierMax)
}

private let fixedLongRatio = 16.0 / 9.0

/// Target field for a given view + format.
func targetFieldSize(viewWidth: Double, viewHeight: Double, format: SimFormat,
                     base: Int = 720, maxLong: Int = 1600) -> FieldSize {
    switch format {
    case .auto:
        return fieldSize(viewWidth: viewWidth, viewHeight: viewHeight, base: base, maxLong: maxLong)
    case .square:
        return FieldSize(width: base, height: base)
    case .portrait:
        return FieldSize(width: base, height: min(Int((Double(base) * fixedLongRatio).rounded()), maxLong))
    case .landscape:
        return FieldSize(width: min(Int((Double(base) * fixedLongRatio).rounded()), maxLong), height: base)
    }
}
  • [ ] Step 4: Run to verify it passes

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/SimFormatTests 2>&1 | tail -20 Expected: PASS (10 tests). If a new-file-not-found error appears, run xcodegen generate once then re-run (and note: signing must be re-applied for device builds).

  • [ ] Step 5: Full suite + commit

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8 → expect all green.

cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/SimFormat.swift apple/PhysarumApp/Tests/SimFormatTests.swift
git commit -m "feat(app): SimFormat math — field sizing + density scaling (pure, tested)"


Task 2: RenderCoordinator — debounced field re-create

Files: - Modify (rewrite): apple/PhysarumApp/Sources/Player/RenderCoordinator.swift

Build-gated (the pure logic is covered by Task 1; the re-create machinery is verified by build + the Task 6 visual check).

  • [ ] Step 1: Rewrite RenderCoordinator.swift

Replace the entire contents with:

import MetalKit
import PhysarumEngine

/// Owns the SimEngine + Renderer and drives the per-frame loop via MTKViewDelegate.
/// The field adopts the view's format: `draw` computes the target FieldSize each frame
/// and `setField` debounce-recreates the engine when it changes (rotation/resize/format).
/// Live params apply every frame; respawn params re-seed via a separate debounce.
final class RenderCoordinator: NSObject, MTKViewDelegate {
    let device: MTLDevice
    private let lib: ShaderLibrary
    private var renderer: Renderer
    private var sim: SimEngine
    private var currentField: FieldSize

    private(set) var params: PresetParams
    private var currentPresetID: String
    private var respawnWork: DispatchWorkItem?
    private var fieldWork: DispatchWorkItem?
    private var pendingField: FieldSize?
    var cover = false                 // aspect-fill (true) vs fit-with-bg-surround (false)
    var format: SimFormat = .auto

    init?(field: Int, initialPreset: Preset, live: PresetParams) {
        guard let device = MTLCreateSystemDefaultDevice() else {
            print("[Physarum] No Metal device available — playback disabled.")
            return nil
        }
        self.device = device
        self.lib = ShaderLibrary(device: device)
        let f0 = FieldSize(width: field, height: field)
        do {
            self.renderer = try Renderer(device: device, library: lib, width: f0.width, height: f0.height)
            self.sim = try Self.makeSim(device: device, lib: lib, field: f0, preset: live)
        } catch {
            print("[Physarum] Engine init failed: \(error) — playback disabled.")
            return nil
        }
        self.currentField = f0
        self.params = live
        self.currentPresetID = initialPreset.id
        super.init()
    }

    /// Build a SimEngine at `field`, with the preset's agentCount density-scaled to the area.
    private static func makeSim(device: MTLDevice, lib: ShaderLibrary,
                                field: FieldSize, preset: PresetParams) throws -> SimEngine {
        var p = preset
        p.agentCount = densityScaledAgentCount(preset.agentCount, fieldWidth: field.width, fieldHeight: field.height)
        return try SimEngine(device: device, library: lib, width: field.width, height: field.height, preset: p)
    }

    /// Apply edited params: live fields immediately; respawn fields after a 250ms debounce.
    func setParams(_ p: PresetParams) {
        let needsRespawn = p.agentCount != params.agentCount
            || p.spawnPattern != params.spawnPattern
            || zip(p.species, params.species).contains { $0.populationShare != $1.populationShare }
        params = p
        sim.updateLiveParams(p)
        if needsRespawn { scheduleRespawn() }
    }

    /// Switch to a different preset (re-seeds immediately + clears the echo).
    func switchPreset(_ preset: Preset, live: PresetParams) {
        guard preset.id != currentPresetID else { return }
        currentPresetID = preset.id
        respawnWork?.cancel()
        params = live
        if let s = try? Self.makeSim(device: device, lib: lib, field: currentField, preset: live) {
            sim = s
            renderer.resetFeedback()
        }
    }

    private func scheduleRespawn() {
        respawnWork?.cancel()
        let work = DispatchWorkItem { [weak self] in self?.respawn() }
        respawnWork = work
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
    }

    private func respawn() {
        if let s = try? Self.makeSim(device: device, lib: lib, field: currentField, preset: params) {
            sim = s
            renderer.resetFeedback()
        }
    }

    /// Ensure the field matches `target`; debounce-recreate the renderer + sim when it
    /// changes. Guarded against rescheduling for an already-pending target so a steady
    /// stream of equal targets (every frame) lets the timer actually fire.
    func setField(_ target: FieldSize) {
        guard target != currentField, target != pendingField else { return }
        pendingField = target
        fieldWork?.cancel()
        let work = DispatchWorkItem { [weak self] in self?.recreate(target) }
        fieldWork = work
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
    }

    private func recreate(_ target: FieldSize) {
        pendingField = nil
        guard target != currentField else { return }
        if let r = try? Renderer(device: device, library: lib, width: target.width, height: target.height),
           let s = try? Self.makeSim(device: device, lib: lib, field: target, preset: params) {
            renderer = r
            sim = s
            currentField = target
        }
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        let ds = view.drawableSize
        setField(targetFieldSize(viewWidth: Double(ds.width), viewHeight: Double(ds.height), format: format))
        sim.step()
        guard let desc = view.currentRenderPassDescriptor, let drawable = view.currentDrawable else { return }
        renderer.render(sim: sim, preset: params, into: desc, drawable: drawable, cover: cover)
    }
}
  • [ ] Step 2: Build (macOS + iOS sim)

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" Expected: both BUILD SUCCEEDED.

  • [ ] Step 3: Full suite (no regression)

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8 Expected: all green.

  • [ ] Step 4: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Player/RenderCoordinator.swift
git commit -m "feat(app): RenderCoordinator adopts the view's field format (debounced re-create + density scaling)"

Task 3: MetalView — thread the format

Files: - Modify: apple/PhysarumApp/Sources/Player/MetalView.swift

  • [ ] Step 1: Add the format property + push it in sync

In MetalView, add the property next to cover:

    var cover: Bool = false
    var format: SimFormat = .auto

In sync(_:), set it on the coordinator alongside cover:

    private func sync(_ coordinator: RenderCoordinator?) {
        guard let c = coordinator else { return }
        c.cover = cover
        c.format = format
        c.switchPreset(selectedPreset, live: editedParams)   // re-seed on preset change
        c.setParams(editedParams)                            // live edits + debounced respawn
    }
  • [ ] Step 2: Build (macOS + iOS sim)

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" Expected: both BUILD SUCCEEDED (RootView still compiles — format defaults to .auto).

  • [ ] Step 3: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Player/MetalView.swift
git commit -m "feat(app): thread SimFormat through MetalView to the coordinator"

Task 4: RootView — format storage + pass-through + canvas-tap dismiss

Files: - Modify: apple/PhysarumApp/Sources/App/RootView.swift

  • [ ] Step 1: Add the format storage

In RootView, add next to the cover storage:

    // false = fit (whole sim visible, surround = preset bg colour); true = cover (fill, crop).
    @AppStorage("renderCover") private var cover = false
    @AppStorage("simFormat") private var format: SimFormat = .auto
  • [ ] Step 2: Pass format to MetalView + add canvas-tap dismiss

Replace the MetalView(...) line (inside the ZStack) with:

                MetalView(field: field, selectedPreset: state.selectedPreset, editedParams: state.edited,
                          cover: cover, format: format)
                    .frame(width: fullW, height: fullH)
                    .clipped()
                    .contentShape(Rectangle())
                    .onTapGesture { if showEditor { showEditor = false } }   // tap the canvas to dismiss the editor
  • [ ] Step 3: Build (macOS + iOS sim) + full suite

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" && xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -6 Expected: both BUILD SUCCEEDED + suite green.

  • [ ] Step 4: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(app): drive sim format from RootView + tap-canvas-to-dismiss-editor"

Task 5: Editor — Format picker

Files: - Modify: apple/PhysarumApp/Sources/Editor/EditorView.swift

  • [ ] Step 1: Add the @AppStorage + a Format picker

In EditorView, add the shared storage next to @State private var showSave:

    @AppStorage("simFormat") private var format: SimFormat = .auto

In body, insert a format picker right after toolbar (before the first PhysSection):

                Picker("Format", selection: $format) {
                    Text("Auto").tag(SimFormat.auto)
                    Text("Square").tag(SimFormat.square)
                    Text("Portrait").tag(SimFormat.portrait)
                    Text("Landscape").tag(SimFormat.landscape)
                }
                .pickerStyle(.segmented)
                .help("Simulation canvas format — Auto matches the screen")

The @AppStorage("simFormat") key matches RootView's, so selecting a format here updates the field live (RootView re-renders → MetalView → coordinator → debounced re-create).

  • [ ] Step 2: Build (macOS + iOS sim)

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" Expected: both BUILD SUCCEEDED.

  • [ ] Step 3: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Editor/EditorView.swift
git commit -m "feat(app): editor Format picker (Auto/Square/Portrait/Landscape)"

Task 6: Verification gate + device deploy

  • [ ] Step 1: All gates green

cd apple/PhysarumApp
xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)"
xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | grep -E "Executed [0-9]+ tests|TEST (SUCCEEDED|FAILED)" | tail -3
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)"
Expected: macOS BUILD + TEST SUCCEEDED, iOS-sim BUILD SUCCEEDED.

  • [ ] Step 2: iOS Simulator visual check (portrait — Auto default)

Build/install/launch on a booted iPhone sim, screenshot. Confirm: the render fills natively edge-to-edge with NO surround bars and NO crop (the field now matches the screen aspect). The trail density looks like the preset intends (not sparse). Topbar + editor still fine.

  • [ ] Step 3: Real device (Johannes) — portrait + landscape + rotation + format picker

Build for platform=iOS,id=<device> -allowProvisioningUpdates, install + launch via devicectl. Confirm: fills natively in portrait AND landscape; rotating re-seeds cleanly after ~0.3 s (no thrash); editor Format picker (Auto/Square/Portrait/Landscape) switches the field; tapping the canvas dismisses the editor; live-render performance of the taller field is acceptable.

  • [ ] Step 4: Push
cd "$(git rev-parse --show-toplevel)" && git push

Self-Review (completed by plan author)

Spec coverage (2026-06-04-adaptive-sim-format-design.md): - §3 fieldSize + densityScaledAgentCount (pure, tested) → Task 1. ✓ - §4 SimFormat + targetFieldSize + @AppStorage round-trip → Task 1 (math/enum) + Tasks 4/5 (@AppStorage). ✓ - §5 RenderCoordinator debounced re-create + density-scaled construction → Task 2. ✓ - §6 editor Format picker → Task 5. ✓ - §7 canvas-tap dismisses editor → Task 4. ✓ - §2 fit/cover kept (untouched; still threaded) → no change needed. ✓ - §8 tests (fieldSize cases, density, raw round-trip) → Task 1; visual/device → Task 6. ✓ - §10 risks: resize thrash → setField guards pendingField + integer FieldSize equality (only re-creates on actual dim change); density-vs-respawn double-fire → field re-create is a separate path (setField/recreate), respawn keys on params; both call makeSim so density is consistent. ✓

Placeholder scan: none — full code + exact commands throughout.

Type consistency: FieldSize(width:height:), SimFormat (.auto/.square/.portrait/.landscape), fieldSize(viewWidth:viewHeight:base:maxLong:), densityScaledAgentCount(_:fieldWidth:fieldHeight:baseline:tierMax:), targetFieldSize(viewWidth:viewHeight:format:base:maxLong:), RenderCoordinator.format/setField/makeSim(static), MetalView.format, @AppStorage("simFormat") (same key in RootView + EditorView) — consistent across tasks. MetalView.format defaults to .auto so intermediate task states compile.

Out-of-scope (correctly deferred): capture/export at a format, per-preset format, animated transitions, tvOS — all noted in the spec §9.