Zum Inhalt

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 — runs wgsl2msl over 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.swift
  • apple/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 + .json manifest.
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:

public enum PhysarumEngineInfo { public static let version = "0.1.0" }
Run: 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; provide tuple accessor 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 Swift Preset literal, exposed as BuiltInPresets.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 .metal resource string, runtime-compile, build a MTLComputePipelineState for a named kernel; read its .json manifest 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 for agentStep/deposit/diffuse, and a step(frame:) that dispatches the three passes with correct ping-pong swapping and diffusionFreq cadence. Bind buffers via each shader's manifest indices; provide the naga _buffer_sizes buffer at the index the manifest does not cover (the highest buffer index + 1 per entry point — read it from the generated .metal signature; 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 }
}
(Implementation: see 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)

  • [ ] wgsl2msl builds all 8 shaders to .metal + manifest, no [[user(fake)]].
  • [ ] swift test green 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_sizes slot).
  • [ ] 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_sizes buffer 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/ShaderLibrary names used consistently across tasks.