Native Engine Foundation (Increment 1) 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: The Physarum simulation core runs natively in Swift/Metal, headless and verified, on macOS with only Command Line Tools (no Xcode).
Architecture: WGSL shaders (src/engine/shaders/*.wgsl) stay single-source and are transpiled to MSL by a small Rust naga-library tool (wgsl2msl) that also emits a binding manifest. A platform-agnostic Swift package PhysarumEngine mirrors the existing buffer layout byte-for-byte, loads the transpiled MSL at runtime via device.makeLibrary(source:), and runs the sim passes agentStep→deposit→diffuse with ping-pong buffers.
Tech Stack: Rust (naga 29 lib), Swift 6 + SwiftPM, Metal, XCTest.
Reference (read first): spec docs/superpowers/specs/2026-06-02-native-ambiance-app-design.md; source-of-truth contracts src/engine/params.ts, src/engine/types.ts, src/presets/schema.ts, src/engine/seeding.ts, src/engine/Engine.ts, and the verified spike apple/spike/spike.swift.
File Structure¶
apple/tools/wgsl2msl/Cargo.toml— Rust binary manifest.apple/tools/wgsl2msl/src/main.rs— naga WGSL→MSL transpiler + binding manifest emitter.apple/scripts/transpile-shaders.sh— runswgsl2mslover all 8 shaders.apple/PhysarumEngine/Package.swift— Swift package (macOS 14+ now; tvOS/iOS added in Inc 3).apple/PhysarumEngine/Sources/PhysarumEngine/Model/Types.swift— enums + value types.apple/PhysarumEngine/Sources/PhysarumEngine/Model/Params.swift— byte-exact uniform packing.apple/PhysarumEngine/Sources/PhysarumEngine/Model/Preset.swift— Codable preset model.apple/PhysarumEngine/Sources/PhysarumEngine/Model/BuiltInPresets.swift— 1–2 ported presets.apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/ShaderLibrary.swift— loads MSL + manifest, builds pipelines.apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/SimEngine.swift— buffers, seeding, step dispatch.apple/PhysarumEngine/Sources/PhysarumEngine/Resources/Shaders/— generated.metal+.json(bundled resources).apple/PhysarumEngine/Tests/PhysarumEngineTests/ParamsLayoutTests.swiftapple/PhysarumEngine/Tests/PhysarumEngineTests/SimEngineTests.swift
Task 1: wgsl2msl Rust tool¶
Files:
- Create: apple/tools/wgsl2msl/Cargo.toml
- Create: apple/tools/wgsl2msl/src/main.rs
- [ ] Step 1: Cargo.toml
[package]
name = "wgsl2msl"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "wgsl2msl"
path = "src/main.rs"
[dependencies]
naga = { version = "29", features = ["wgsl-in", "msl-out"] }
- [ ] Step 2: src/main.rs — parse WGSL, build an explicit Metal binding map (so no
[[user(fakeN)]]placeholders), write.metal+.jsonmanifest.
use std::collections::BTreeMap;
use std::fs;
use naga::back::msl;
use naga::valid::{Capabilities, ValidationFlags, Validator};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() != 4 {
eprintln!("usage: wgsl2msl <in.wgsl> <out.metal> <out.json>");
std::process::exit(2);
}
let src = fs::read_to_string(&args[1]).expect("read wgsl");
let module = naga::front::wgsl::parse_str(&src).expect("parse wgsl");
let info = Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&module)
.expect("validate");
// Build binding_map: each @group(g)@binding(b) -> a Metal slot in its class.
// Convention: metal index = WGSL binding number, per resource class.
let mut binding_map: msl::BindingMap = BTreeMap::new();
let mut manifest_entries: Vec<String> = Vec::new();
for (_h, var) in module.global_variables.iter() {
let Some(br) = var.binding.clone() else { continue };
let ty = &module.types[var.ty];
let (class, mut target) = classify(&ty.inner, &module, var.space);
let idx = br.binding as u8;
match class {
"buffer" => target.buffer = Some(idx),
"texture" => target.texture = Some(idx),
"sampler" => target.sampler = Some(msl::BindSamplerTarget::Resource(idx)),
_ => {}
}
manifest_entries.push(format!(
" {{\"name\":\"{}\",\"group\":{},\"binding\":{},\"class\":\"{}\",\"index\":{}}}",
var.name.clone().unwrap_or_default(), br.group, br.binding, class, idx
));
binding_map.insert(br, target);
}
let options = msl::Options {
lang_version: (2, 0),
per_entry_point_map: Default::default(),
inline_samplers: vec![],
spirv_cross_compatibility: false,
fake_missing_bindings: false,
binding_map,
bounds_check_policies: naga::proc::BoundsCheckPolicies {
index: naga::proc::BoundsCheckPolicy::Restrict,
buffer: naga::proc::BoundsCheckPolicy::Restrict,
image_load: naga::proc::BoundsCheckPolicy::Restrict,
binding_array: naga::proc::BoundsCheckPolicy::Restrict,
},
zero_initialize_workgroup_memory: false,
force_loop_bounding: true,
};
let pipeline_options = msl::PipelineOptions::default();
let (out, _info) = msl::write_string(&module, &info, &options, &pipeline_options)
.expect("msl write");
fs::write(&args[2], out).expect("write metal");
fs::write(&args[3], format!("{{\n \"resources\": [\n{}\n ]\n}}\n",
manifest_entries.join(",\n"))).expect("write json");
eprintln!("ok: {} -> {} (+manifest)", &args[1], &args[2]);
}
fn classify<'a>(
inner: &naga::TypeInner,
_module: &naga::Module,
space: naga::AddressSpace,
) -> (&'a str, msl::BindTarget) {
let t = msl::BindTarget::default();
match inner {
naga::TypeInner::Image { .. } => ("texture", t),
naga::TypeInner::Sampler { .. } => ("sampler", t),
_ => match space {
naga::AddressSpace::Uniform | naga::AddressSpace::Storage { .. } => ("buffer", t),
_ => ("buffer", t),
},
}
}
- [ ] Step 3: Build the tool
Run: cd apple/tools/wgsl2msl && cargo build --release
Expected: compiles. If naga 29's msl::Options/BindTarget/BindSamplerTarget field names differ, fix against ~/.cargo/registry/src/*/naga-29*/src/back/msl/mod.rs (the struct is the source of truth — adjust field names, do not guess).
- [ ] Step 4: Smoke-run on agentStep
Run: ./target/release/wgsl2msl ../../../src/engine/shaders/agentStep.wgsl /tmp/a.metal /tmp/a.json && grep -c 'user(fake' /tmp/a.metal
Expected: grep -c prints 0 (no placeholders), and /tmp/a.json lists params/pheromone/agentsIn/agentsOut with buffer indices 0–3.
- [ ] Step 5: Commit
git add apple/tools/wgsl2msl
git commit -m "feat(native): wgsl2msl — naga WGSL->MSL transpiler with explicit binding map"
Task 2: transpile-shaders.sh¶
Files:
- Create: apple/scripts/transpile-shaders.sh
- [ ] Step 1: Write the script
#!/usr/bin/env bash
# Transpile all WGSL shaders -> MSL + binding manifests into the engine package.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SRC="$ROOT/src/engine/shaders"
OUT="$ROOT/apple/PhysarumEngine/Sources/PhysarumEngine/Resources/Shaders"
TOOL="$ROOT/apple/tools/wgsl2msl/target/release/wgsl2msl"
[ -x "$TOOL" ] || (cd "$ROOT/apple/tools/wgsl2msl" && cargo build --release)
mkdir -p "$OUT"
for f in "$SRC"/*.wgsl; do
name="$(basename "$f" .wgsl)"
"$TOOL" "$f" "$OUT/$name.metal" "$OUT/$name.json"
done
echo "transpiled $(ls "$SRC"/*.wgsl | wc -l | tr -d ' ') shaders -> $OUT"
- [ ] Step 2: Run it
Run: chmod +x apple/scripts/transpile-shaders.sh && apple/scripts/transpile-shaders.sh
Expected: 8 .metal + 8 .json in the Resources/Shaders dir, no user(fake in any: ! grep -rl 'user(fake' apple/PhysarumEngine/Sources/PhysarumEngine/Resources/Shaders.
- [ ] Step 3: Commit (generated MSL is committed so the package builds without the Rust tool present)
git add apple/scripts/transpile-shaders.sh apple/PhysarumEngine/Sources/PhysarumEngine/Resources/Shaders
git commit -m "feat(native): transpile-shaders.sh + generated MSL for all 8 shaders"
Task 3: Swift Package skeleton¶
Files:
- Create: apple/PhysarumEngine/Package.swift
- [ ] Step 1: Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "PhysarumEngine",
platforms: [.macOS(.v14)],
products: [
.library(name: "PhysarumEngine", targets: ["PhysarumEngine"]),
],
targets: [
.target(
name: "PhysarumEngine",
resources: [.copy("Resources/Shaders")]
),
.testTarget(
name: "PhysarumEngineTests",
dependencies: ["PhysarumEngine"]
),
]
)
- [ ] Step 2: Verify it resolves (add a trivial placeholder so the target is non-empty)
Create apple/PhysarumEngine/Sources/PhysarumEngine/PhysarumEngine.swift:
cd apple/PhysarumEngine && swift build
Expected: Build complete!
- [ ] Step 3: Commit
git add apple/PhysarumEngine/Package.swift apple/PhysarumEngine/Sources/PhysarumEngine/PhysarumEngine.swift
git commit -m "feat(native): PhysarumEngine swift package skeleton"
Task 4: Types model¶
Files:
- Create: apple/PhysarumEngine/Sources/PhysarumEngine/Model/Types.swift
Mirror src/engine/types.ts. SPAWN_PATTERNS, BLEND_MODES, MOD_TARGETS_AGENT/POST, AUDIO_SOURCES, SIGNAL_NAMES must keep the SAME order (their index is serialized into uniform u32 slots).
- [ ] Step 1: Write Types.swift
import simd
public typealias RGB = SIMD3<Float>
public enum SpawnPattern: String, Codable, CaseIterable, Sendable {
case random, clusters, center, ring // order = u32 index in params
}
public enum BlendMode: String, Codable, CaseIterable, Sendable {
case additive, paint // order = u32 index in params
}
public enum AudioBand: String, Codable, CaseIterable, Sendable {
case bass, mid, high
}
public enum ModTarget: String, Codable, CaseIterable, Sendable {
// agent-side then post-side (matches MOD_TARGETS_AGENT + MOD_TARGETS_POST)
case moveSpeed, turnSpeed, depositAmount, sensorDistance, rhythmAmplitude, chaosStrength
case exposure, bloomIntensity, saturation, contrast
}
public enum SignalName: String, Codable, CaseIterable, Sendable {
case pearl, crimson, phosphor, ember, spectre, circuit
case ghost, biolink, neuralBleed = "neural-bleed", rust, toxic, voidwitch
}
public struct SpeciesParams: Codable, Sendable {
public var color: RGB
public var populationShare: Float
public var sensorAngle: Float
public var sensorDistance: Float
public var turnSpeed: Float
public var moveSpeed: Float
public var depositAmount: Float
public init(color: RGB, populationShare: Float, sensorAngle: Float, sensorDistance: Float,
turnSpeed: Float, moveSpeed: Float, depositAmount: Float) {
self.color = color; self.populationShare = populationShare
self.sensorAngle = sensorAngle; self.sensorDistance = sensorDistance
self.turnSpeed = turnSpeed; self.moveSpeed = moveSpeed; self.depositAmount = depositAmount
}
}
public typealias MatrixRow = SIMD3<Float> // [-2..2]
public typealias InteractionMatrix = (MatrixRow, MatrixRow, MatrixRow)
Note: RGB Codable — SIMD3<Float> is Codable as an array [r,g,b], matching the web JSON tuple. Verify in Task 6's round-trip test.
- [ ] Step 2: Build
Run: cd apple/PhysarumEngine && swift build
Expected: Build complete!
- [ ] Step 3: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Model/Types.swift
git commit -m "feat(native): engine value types ported from types.ts"
Task 5: Params packing (byte-exact, TDD)¶
Files:
- Create: apple/PhysarumEngine/Sources/PhysarumEngine/Model/Params.swift
- Test: apple/PhysarumEngine/Tests/PhysarumEngineTests/ParamsLayoutTests.swift
- [ ] Step 1: Write the failing test — assert exact byte offsets per
params.ts.
import XCTest
import simd
@testable import PhysarumEngine
final class ParamsLayoutTests: XCTestCase {
func testParamsSizeAndKeyOffsets() {
var u = UniformInput.zeroed(fieldWidth: 256, fieldHeight: 128, agentCount: 1000)
u.audio.moveSpeedFactor = 1
u.species.0.moveSpeed = 1.5
u.species.0.turnSpeed = 0.3
let bytes = packParams(u)
XCTAssertEqual(bytes.count, 304)
XCTAssertEqual(bytes.u32(at: 0), 256) // fieldWidth
XCTAssertEqual(bytes.u32(at: 1), 128) // fieldHeight
XCTAssertEqual(bytes.u32(at: 2), 1000) // agentCount
XCTAssertEqual(bytes.f32(at: 16), 1.0) // audio moveSpeedFactor
XCTAssertEqual(bytes.f32(at: 28 + 7), 1.5) // species[0].moveSpeed
XCTAssertEqual(bytes.f32(at: 28 + 6), 0.3) // species[0].turnSpeed
}
func testPostParamsSize() {
let bytes = packPostParams(PostUniformInput.zeroed(resX: 256, resY: 128))
XCTAssertEqual(bytes.count, 80)
}
}
extension Array where Element == UInt8 {
func u32(at slot: Int) -> UInt32 {
let i = slot * 4
return UInt32(self[i]) | UInt32(self[i+1]) << 8 | UInt32(self[i+2]) << 16 | UInt32(self[i+3]) << 24
}
func f32(at slot: Int) -> Float { Float(bitPattern: u32(at: slot)) }
}
- [ ] Step 2: Run, verify it fails
Run: cd apple/PhysarumEngine && swift test --filter ParamsLayoutTests
Expected: FAIL (no packParams/UniformInput).
- [ ] Step 3: Implement Params.swift — packing exactly per
params.ts(slots 0–75 = 304 B; PostParams 0–19 = 80 B).
import simd
public struct UniformInput {
public var fieldWidth: UInt32, fieldHeight: UInt32, agentCount: UInt32, frame: UInt32
public var time: Float, decayRate: Float, fadeStrength: Float, trailSaturation: Float
public var diffusionFreq: UInt32, chaosInterval: UInt32
public var chaosStrength: Float, rhythmAmplitude: Float, exposure: Float
public var spawnPattern: SpawnPattern, headIntensity: Float, backgroundColor: RGB
public var species: (SpeciesParams, SpeciesParams, SpeciesParams)
public var matrix: InteractionMatrix
public struct Audio { public var moveSpeedFactor: Float = 1, turnSpeedFactor: Float = 1,
depositFactor: Float = 1, sensorDistanceFactor: Float = 1,
rhythmAmplitudeFactor: Float = 1, chaosStrengthFactor: Float = 1 }
public var audio = Audio()
public static func zeroed(fieldWidth: UInt32, fieldHeight: UInt32, agentCount: UInt32) -> UniformInput {
let z = SpeciesParams(color: .zero, populationShare: 0, sensorAngle: 0, sensorDistance: 1,
turnSpeed: 0, moveSpeed: 0, depositAmount: 0)
return UniformInput(fieldWidth: fieldWidth, fieldHeight: fieldHeight, agentCount: agentCount,
frame: 0, time: 0, decayRate: 0.95, fadeStrength: 0, trailSaturation: 100,
diffusionFreq: 1, chaosInterval: 0, chaosStrength: 0, rhythmAmplitude: 0, exposure: 1,
spawnPattern: .random, headIntensity: 0, backgroundColor: .zero,
species: (z, z, z), matrix: (.zero, .zero, .zero))
}
}
public func packParams(_ i: UniformInput) -> [UInt8] {
var f = [Float](repeating: 0, count: 76)
func su(_ s: Int, _ v: UInt32) { f[s] = Float(bitPattern: v) }
su(0, i.fieldWidth); su(1, i.fieldHeight); su(2, i.agentCount); su(3, i.frame)
f[4] = i.time; f[5] = i.decayRate; f[6] = i.fadeStrength; f[7] = i.trailSaturation
su(8, max(1, i.diffusionFreq)); su(11, i.chaosInterval)
f[12] = i.chaosStrength; f[13] = i.rhythmAmplitude; f[14] = i.exposure
su(15, UInt32(SpawnPattern.allCases.firstIndex(of: i.spawnPattern)!))
f[16] = i.audio.moveSpeedFactor; f[17] = i.audio.turnSpeedFactor; f[18] = i.audio.depositFactor
f[19] = i.audio.sensorDistanceFactor; f[20] = i.audio.rhythmAmplitudeFactor; f[21] = i.audio.chaosStrengthFactor
f[22] = i.headIntensity
f[24] = i.backgroundColor.x; f[25] = i.backgroundColor.y; f[26] = i.backgroundColor.z
let sp = [i.species.0, i.species.1, i.species.2]
for s in 0..<3 {
let o = 28 + s * 12
f[o+0] = sp[s].color.x; f[o+1] = sp[s].color.y; f[o+2] = sp[s].color.z
f[o+3] = sp[s].populationShare; f[o+4] = sp[s].sensorAngle; f[o+5] = sp[s].sensorDistance
f[o+6] = sp[s].turnSpeed; f[o+7] = sp[s].moveSpeed; f[o+8] = sp[s].depositAmount
}
let rows = [i.matrix.0, i.matrix.1, i.matrix.2]
for r in 0..<3 { let o = 64 + r * 4; f[o+0] = rows[r].x; f[o+1] = rows[r].y; f[o+2] = rows[r].z }
return f.withUnsafeBytes { Array($0) }
}
public struct PostUniformInput {
public var resolutionX: Float, resolutionY: Float, exposure: Float, time: Float
public var feedbackAmount: Float, bloomThreshold: Float, bloomIntensity: Float
public var vignette: Float, saturation: Float, contrast: Float
public var backgroundColor: RGB, blendMode: BlendMode, kaleidoscope: UInt32
public var exposureFactor: Float = 1, bloomIntensityFactor: Float = 1
public var saturationFactor: Float = 1, contrastFactor: Float = 1
public static func zeroed(resX: Float, resY: Float) -> PostUniformInput {
PostUniformInput(resolutionX: resX, resolutionY: resY, exposure: 1, time: 0,
feedbackAmount: 0, bloomThreshold: 1, bloomIntensity: 1, vignette: 0,
saturation: 1, contrast: 1, backgroundColor: .zero, blendMode: .additive, kaleidoscope: 0)
}
}
public func packPostParams(_ i: PostUniformInput) -> [UInt8] {
var f = [Float](repeating: 0, count: 20)
func su(_ s: Int, _ v: UInt32) { f[s] = Float(bitPattern: v) }
f[0] = i.resolutionX; f[1] = i.resolutionY; f[2] = i.exposure; f[3] = i.time
f[4] = i.feedbackAmount; f[5] = i.bloomThreshold; f[6] = i.bloomIntensity
f[7] = i.vignette; f[8] = i.saturation; f[9] = i.contrast
su(10, min(6, i.kaleidoscope))
f[12] = i.backgroundColor.x; f[13] = i.backgroundColor.y; f[14] = i.backgroundColor.z
su(15, UInt32(BlendMode.allCases.firstIndex(of: i.blendMode)!))
f[16] = i.exposureFactor; f[17] = i.bloomIntensityFactor; f[18] = i.saturationFactor; f[19] = i.contrastFactor
return f.withUnsafeBytes { Array($0) }
}
- [ ] Step 4: Run, verify pass
Run: cd apple/PhysarumEngine && swift test --filter ParamsLayoutTests
Expected: PASS.
- [ ] Step 5: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Model/Params.swift apple/PhysarumEngine/Tests/PhysarumEngineTests/ParamsLayoutTests.swift
git commit -m "feat(native): byte-exact Params/PostParams packing with layout tests"
Task 6: Preset model (Codable, JSON-compatible)¶
Files:
- Create: apple/PhysarumEngine/Sources/PhysarumEngine/Model/Preset.swift
- Test: add PresetCodableTests to SimEngineTests.swift (or a new file).
- [ ] Step 1: Write failing round-trip test
import XCTest
@testable import PhysarumEngine
final class PresetCodableTests: XCTestCase {
func testDecodeMinimalPresetJSON() throws {
let json = """
{"id":"t","name":"Test","builtIn":true,"tags":[],"tagSignal":"pearl","params":{
"agentCount":1000,"spawnPattern":"random",
"species":[{"color":[0.9,0.6,0.2],"populationShare":1,"sensorAngle":0.5,"sensorDistance":9,"turnSpeed":0.3,"moveSpeed":1.5,"depositAmount":1},
{"color":[0,0,0],"populationShare":0,"sensorAngle":0,"sensorDistance":1,"turnSpeed":0,"moveSpeed":0,"depositAmount":0},
{"color":[0,0,0],"populationShare":0,"sensorAngle":0,"sensorDistance":1,"turnSpeed":0,"moveSpeed":0,"depositAmount":0}],
"interactionMatrix":[[1,0,0],[0,1,0],[0,0,1]],
"decayRate":0.95,"diffusionFreq":1,"fadeStrength":0,"trailSaturation":100,
"chaosInterval":0,"chaosStrength":0,"rhythmAmplitude":0,"simSpeed":1,
"backgroundColor":[0,0,0],"blendMode":"additive","exposure":1,"headIntensity":0,"kaleidoscope":0,
"feedbackAmount":0,"bloomThreshold":1,"bloomIntensity":1,"vignette":0,"saturation":1,"contrast":1,
"audioReactive":{"enabled":false,"gain":1,"autoGain":false,"routes":[]}}}
"""
let preset = try JSONDecoder().decode(Preset.self, from: Data(json.utf8))
XCTAssertEqual(preset.id, "t")
XCTAssertEqual(preset.params.species.count, 3)
XCTAssertEqual(preset.params.species[0].moveSpeed, 1.5)
XCTAssertEqual(preset.params.simSpeed, 1)
}
}
- [ ] Step 2: Run, verify fails
Run: cd apple/PhysarumEngine && swift test --filter PresetCodableTests
Expected: FAIL (no Preset).
- [ ] Step 3: Implement Preset.swift —
Codable, species as a 3-element array (not tuple) for clean JSON; providetupleaccessor for the engine.
import simd
public struct AudioRoute: Codable, Sendable {
public var source: AudioBand; public var target: ModTarget; public var depth: Float
}
public struct AudioReactive: Codable, Sendable {
public var enabled: Bool; public var gain: Float; public var autoGain: Bool
public var routes: [AudioRoute]
}
public struct PresetParams: Codable, Sendable {
public var agentCount: Int
public var spawnPattern: SpawnPattern
public var species: [SpeciesParams] // exactly 3
public var interactionMatrix: [[Float]] // 3x3, each -2..2
public var decayRate, fadeStrength, trailSaturation: Float
public var diffusionFreq: Int
public var chaosInterval: Int
public var chaosStrength, rhythmAmplitude: Float
public var simSpeed: Int
public var backgroundColor: RGB
public var blendMode: BlendMode
public var exposure, headIntensity: Float
public var kaleidoscope: Int
public var feedbackAmount, bloomThreshold, bloomIntensity, vignette, saturation, contrast: Float
public var audioReactive: AudioReactive
public var speciesTuple: (SpeciesParams, SpeciesParams, SpeciesParams) {
(species[0], species[1], species[2])
}
public var matrixTuple: InteractionMatrix {
(SIMD3(interactionMatrix[0][0], interactionMatrix[0][1], interactionMatrix[0][2]),
SIMD3(interactionMatrix[1][0], interactionMatrix[1][1], interactionMatrix[1][2]),
SIMD3(interactionMatrix[2][0], interactionMatrix[2][1], interactionMatrix[2][2]))
}
}
public struct Preset: Codable, Sendable {
public var id: String; public var name: String; public var author: String?
public var builtIn: Bool; public var tags: [String]; public var tagSignal: SignalName
public var params: PresetParams; public var thumbnail: String?
}
- [ ] Step 4: Run, verify pass
Run: cd apple/PhysarumEngine && swift test --filter PresetCodableTests
Expected: PASS. (If RGB tuple [r,g,b] fails to decode, add explicit RGB Codable via an array container.)
- [ ] Step 5: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Model/Preset.swift apple/PhysarumEngine/Tests/PhysarumEngineTests/
git commit -m "feat(native): Codable Preset model compatible with web JSON schema"
Task 7: BuiltInPresets (port 1–2 for testing)¶
Files:
- Create: apple/PhysarumEngine/Sources/PhysarumEngine/Model/BuiltInPresets.swift
-
[ ] Step 1: Port one real preset from
src/presets/builtIn.ts(pick the first) into a SwiftPresetliteral, exposed asBuiltInPresets.all: [Preset]. Use the actual values from the TS source (read the file; do not invent values). Keep it to 1–2 presets in this increment. -
[ ] Step 2: Build + a sanity test (
BuiltInPresets.all.count >= 1, species sum > 0).
Run: cd apple/PhysarumEngine && swift test --filter BuiltInPresets
Expected: PASS.
- [ ] Step 3: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Model/BuiltInPresets.swift apple/PhysarumEngine/Tests/
git commit -m "feat(native): port first built-in preset for engine tests"
Task 8: ShaderLibrary + SimEngine¶
Files:
- Create: apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/ShaderLibrary.swift
- Create: apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/SimEngine.swift
- [ ] Step 1: ShaderLibrary.swift — load a
.metalresource string, runtime-compile, build aMTLComputePipelineStatefor a named kernel; read its.jsonmanifest into[name: (class, index)]for binding.
import Metal
import Foundation
public struct BindingManifest: Decodable {
public struct Resource: Decodable { public let name: String; public let `class`: String; public let index: Int }
public let resources: [Resource]
}
public enum ShaderError: Error { case missingResource(String), compile(String) }
public final class ShaderLibrary {
public let device: MTLDevice
private let bundle: Bundle
public init(device: MTLDevice, bundle: Bundle = .module) { self.device = device; self.bundle = bundle }
public func computePipeline(_ name: String, function: String = "main_") throws
-> (pso: MTLComputePipelineState, manifest: BindingManifest) {
guard let mslURL = bundle.url(forResource: name, withExtension: "metal", subdirectory: "Shaders")
?? bundle.url(forResource: name, withExtension: "metal")
else { throw ShaderError.missingResource("\(name).metal") }
let src = try String(contentsOf: mslURL, encoding: .utf8)
let lib: MTLLibrary
do { lib = try device.makeLibrary(source: src, options: nil) }
catch { throw ShaderError.compile("\(name): \(error)") }
guard let fn = lib.makeFunction(name: function) else { throw ShaderError.compile("no \(function) in \(name)") }
let pso = try device.makeComputePipelineState(function: fn)
let jsonURL = bundle.url(forResource: name, withExtension: "json", subdirectory: "Shaders")
?? bundle.url(forResource: name, withExtension: "json")!
let manifest = try JSONDecoder().decode(BindingManifest.self, from: Data(contentsOf: jsonURL))
return (pso, manifest)
}
}
- [ ] Step 2: SimEngine.swift — allocate buffers (agents ping-pong, pheromone ping-pong, heads), seed agents (port
src/engine/seeding.ts— read it for the exact spawn maths), build pipelines foragentStep/deposit/diffuse, and astep(frame:)that dispatches the three passes with correct ping-pong swapping anddiffusionFreqcadence. Bind buffers via each shader's manifest indices; provide the naga_buffer_sizesbuffer at the index the manifest does not cover (the highest buffer index + 1 per entry point — read it from the generated.metalsignature; document the value).
The agent struct stride is 24 bytes; pheromone element is SIMD4<Float> (16 bytes). Expose:
public final class SimEngine {
public init(device: MTLDevice, library: ShaderLibrary, width: Int, height: Int, preset: PresetParams) throws
public func step() // advances one rendered frame (simSpeed sub-steps)
public func readPheromoneStats() -> (sum: Double, maxValue: Float, hasNaN: Bool) // for tests
public var frame: UInt32 { get }
}
Engine.ts for pass order, deposit-before-diffuse, and that diffuse runs every diffusionFreq-th frame. Keep the full method bodies in the engine file; this plan locks the interface and the contracts above.)
- [ ] Step 3: Build
Run: cd apple/PhysarumEngine && swift build
Expected: Build complete!
- [ ] Step 4: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/
git commit -m "feat(native): ShaderLibrary + SimEngine (agentStep/deposit/diffuse) on Metal"
Task 9: Headless sim test¶
Files:
- Create/extend: apple/PhysarumEngine/Tests/PhysarumEngineTests/SimEngineTests.swift
- [ ] Step 1: Write the test — run the first built-in preset for 120 frames at 256×256, assert the pheromone field evolves non-trivially and stays finite.
import XCTest
import Metal
@testable import PhysarumEngine
final class SimEngineTests: XCTestCase {
func testSimEvolvesPheromoneField() throws {
guard let device = MTLCreateSystemDefaultDevice() else {
throw XCTSkip("no Metal device")
}
let lib = ShaderLibrary(device: device)
let preset = BuiltInPresets.all[0].params
let engine = try SimEngine(device: device, library: lib, width: 256, height: 256, preset: preset)
for _ in 0..<120 { engine.step() }
let s = engine.readPheromoneStats()
XCTAssertFalse(s.hasNaN, "pheromone field has NaN")
XCTAssertGreaterThan(s.sum, 0, "agents deposited no trail")
XCTAssertGreaterThan(s.maxValue, 0)
}
}
- [ ] Step 2: Run
Run: cd apple/PhysarumEngine && swift test --filter SimEngineTests
Expected: PASS (or skip if no GPU — but on this Mac it runs).
- [ ] Step 3: Full suite
Run: cd apple/PhysarumEngine && swift test
Expected: all tests PASS.
- [ ] Step 4: Commit
git add apple/PhysarumEngine/Tests/
git commit -m "test(native): headless sim evolves pheromone field over 120 frames"
Definition of Done (Increment 1)¶
- [ ]
wgsl2mslbuilds all 8 shaders to.metal+ manifest, no[[user(fake)]]. - [ ]
swift testgreen on macOS/CLT: Params layout byte-exact, Preset JSON round-trips, sim runs 120 frames, pheromone field evolves, no NaN/GPU error. - [ ] Buffers bound via manifest (no hardcoded indices except the documented
_buffer_sizesslot). - [ ] Everything committed locally (push deferred to Johannes).
Self-Review notes¶
- Spec coverage: Covers §6 (wgsl2msl), §8 (all listed Inc-1 files + DoD). Post-FX/render (Inc 2), app targets (Inc 3+) intentionally out of this plan.
- Open risk carried forward: the exact
_buffer_sizesbuffer index per entry point is read from the generated MSL at implementation time (Task 8 Step 2) — the spike showed it at index 4 for agentStep (after the 4 real bindings). - Type consistency:
UniformInput/PostUniformInput/packParams/packPostParams/SimEngine/ShaderLibrarynames used consistently across tasks.