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) createsprivate let renderer: Renderer+private var sim: SimEngineONCE ininit?(field:initialPreset:live:)atfield×field(square,field=720). It hasvar cover(threaded from RootView), a 250 ms debouncedrespawn()viarespawnWork: DispatchWorkItem,switchPreset,setParams, anddraw(in:)which callsrenderer.render(sim:preset:into:drawable:cover:).mtkView(_:drawableSizeWillChange:)is empty.SimEngine(device:library:width:height:preset:)andRenderer(device:library:width:height:)take separate width/height.SimEngineseedspreset.agentCountagents. The sim shader (agentStep.wgsl) wraps on separatefieldWidth/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(_:)setsc.cover,c.switchPreset,c.setParams. Created viamakeCoordinator()→RenderCoordinator(field: field, …).RootView(Sources/App/RootView.swift) has@AppStorage("renderCover") var cover, passescover:toMetalView, computesfullW/fullHin aGeometryReader, frames theMetalView, floats the topbar, and presents the editor as.inspector/.sheetgated byshowEditor.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 underSources//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
formatproperty + push it in sync
In MetalView, add the property next to cover:
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
formatto 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:
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)"
- [ ] 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
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.