Zum Inhalt

apple/PhysarumEngine/Tests/PhysarumEngineTests/ParamsLayoutTests.swift

import XCTest
import simd
@testable import PhysarumEngine

/// Byte-exact verification of the packed uniform layouts. These MUST match the
/// WGSL `Params` (304 B / 76 floats) and `PostParams` (80 B / 20 floats) structs
/// and `src/engine/params.ts` slot-for-slot — any drift silently corrupts every
/// sim/scene shader. Offsets are documented as float-slot indices (slot * 4 = byte).
final class ParamsLayoutTests: XCTestCase {
    // MARK: Params (304 B)

    func testParamsByteSize() {
        let bytes = packParams(.zeroed(fieldWidth: 1, fieldHeight: 1, agentCount: 1))
        XCTAssertEqual(bytes.count, 304, "Params must be 304 bytes / 76 floats")
    }

    func testParamsScalarOffsets() {
        var u = UniformInput.zeroed(fieldWidth: 256, fieldHeight: 128, agentCount: 1000)
        u.frame = 42
        u.time = 7.5
        u.decayRate = 0.97
        u.fadeStrength = 0.12
        u.trailSaturation = 255
        u.chaosInterval = 90
        u.chaosStrength = 0.4
        u.rhythmAmplitude = 0.05
        u.exposure = 3.0
        u.headIntensity = 0.6
        u.backgroundColor = RGB(0.1, 0.2, 0.3)
        let b = packParams(u)
        XCTAssertEqual(b.u32(at: 0), 256, "fieldWidth@0")
        XCTAssertEqual(b.u32(at: 1), 128, "fieldHeight@1")
        XCTAssertEqual(b.u32(at: 2), 1000, "agentCount@2")
        XCTAssertEqual(b.u32(at: 3), 42, "frame@3")
        XCTAssertEqual(b.f32(at: 4), 7.5, "time@4")
        XCTAssertEqual(b.f32(at: 5), 0.97, accuracy: 1e-6, "decayRate@5")
        XCTAssertEqual(b.f32(at: 6), 0.12, accuracy: 1e-6, "fadeStrength@6")
        XCTAssertEqual(b.f32(at: 7), 255, "trailSaturation@7")
        XCTAssertEqual(b.u32(at: 11), 90, "chaosInterval@11")
        XCTAssertEqual(b.f32(at: 12), 0.4, accuracy: 1e-6, "chaosStrength@12")
        XCTAssertEqual(b.f32(at: 13), 0.05, accuracy: 1e-6, "rhythmAmplitude@13")
        XCTAssertEqual(b.f32(at: 14), 3.0, "exposure@14")
        XCTAssertEqual(b.f32(at: 22), 0.6, accuracy: 1e-6, "headIntensity@22")
        XCTAssertEqual(b.f32(at: 24), 0.1, accuracy: 1e-6, "backgroundColor.r@24")
        XCTAssertEqual(b.f32(at: 25), 0.2, accuracy: 1e-6, "backgroundColor.g@25")
        XCTAssertEqual(b.f32(at: 26), 0.3, accuracy: 1e-6, "backgroundColor.b@26")
    }

    func testDiffusionFreqClampedToAtLeastOne() {
        var u = UniformInput.zeroed(fieldWidth: 8, fieldHeight: 8, agentCount: 1)
        u.diffusionFreq = 0
        XCTAssertEqual(packParams(u).u32(at: 8), 1, "diffusionFreq packs as max(1, _)")
        u.diffusionFreq = 3
        XCTAssertEqual(packParams(u).u32(at: 8), 3, "diffusionFreq passes through when >= 1")
    }

    func testSpawnPatternIndices() {
        // Order is the u32 index serialized into the uniform — must stay stable.
        let expected: [(SpawnPattern, UInt32)] = [(.random, 0), (.clusters, 1), (.center, 2), (.ring, 3)]
        for (pattern, idx) in expected {
            var u = UniformInput.zeroed(fieldWidth: 8, fieldHeight: 8, agentCount: 1)
            u.spawnPattern = pattern
            XCTAssertEqual(packParams(u).u32(at: 15), idx, "spawnPattern \(pattern)@15")
        }
    }

    func testAudioFactorDefaultsArePacked() {
        // zeroed() leaves audio factors at 1.0 (neutral) — slots 16..21.
        let b = packParams(.zeroed(fieldWidth: 8, fieldHeight: 8, agentCount: 1))
        for slot in 16...21 {
            XCTAssertEqual(b.f32(at: slot), 1.0, "audio factor@\(slot) defaults to 1")
        }
    }

    func testSpeciesOffsets() {
        var u = UniformInput.zeroed(fieldWidth: 8, fieldHeight: 8, agentCount: 1)
        // Distinct values per species so a swapped/strided slot is caught.
        u.species.0 = SpeciesParams(color: RGB(0.9, 0.6, 0.2), populationShare: 0.5,
                                    sensorAngle: 0.5, sensorDistance: 24, turnSpeed: 0.3,
                                    moveSpeed: 1.5, depositAmount: 9)
        u.species.1 = SpeciesParams(color: RGB(0.1, 0.2, 0.3), populationShare: 0.3,
                                    sensorAngle: 0.4, sensorDistance: 18, turnSpeed: 0.2,
                                    moveSpeed: 1.0, depositAmount: 5)
        u.species.2 = SpeciesParams(color: RGB(0.4, 0.5, 0.6), populationShare: 0.2,
                                    sensorAngle: 0.6, sensorDistance: 30, turnSpeed: 0.1,
                                    moveSpeed: 0.7, depositAmount: 3)
        let b = packParams(u)
        let species = [u.species.0, u.species.1, u.species.2]
        for (s, sp) in species.enumerated() {
            let o = 28 + s * 12   // each species is a 12-float stride
            XCTAssertEqual(b.f32(at: o + 0), sp.color.x, accuracy: 1e-6, "species[\(s)].color.r@\(o)")
            XCTAssertEqual(b.f32(at: o + 1), sp.color.y, accuracy: 1e-6, "species[\(s)].color.g")
            XCTAssertEqual(b.f32(at: o + 2), sp.color.z, accuracy: 1e-6, "species[\(s)].color.b")
            XCTAssertEqual(b.f32(at: o + 3), sp.populationShare, accuracy: 1e-6, "species[\(s)].populationShare")
            XCTAssertEqual(b.f32(at: o + 4), sp.sensorAngle, accuracy: 1e-6, "species[\(s)].sensorAngle")
            XCTAssertEqual(b.f32(at: o + 5), sp.sensorDistance, accuracy: 1e-6, "species[\(s)].sensorDistance")
            XCTAssertEqual(b.f32(at: o + 6), sp.turnSpeed, accuracy: 1e-6, "species[\(s)].turnSpeed")
            XCTAssertEqual(b.f32(at: o + 7), sp.moveSpeed, accuracy: 1e-6, "species[\(s)].moveSpeed")
            XCTAssertEqual(b.f32(at: o + 8), sp.depositAmount, accuracy: 1e-6, "species[\(s)].depositAmount")
        }
    }

    func testInteractionMatrixOffsets() {
        var u = UniformInput.zeroed(fieldWidth: 8, fieldHeight: 8, agentCount: 1)
        u.matrix = (MatrixRow(1.0, -0.2, 0.3), MatrixRow(-0.4, 1.1, 0.5), MatrixRow(0.6, -0.7, 1.2))
        let b = packParams(u)
        let rows = [u.matrix.0, u.matrix.1, u.matrix.2]
        for (r, row) in rows.enumerated() {
            let o = 64 + r * 4   // each matrix row is a 4-float stride (vec3 + pad)
            XCTAssertEqual(b.f32(at: o + 0), row.x, accuracy: 1e-6, "matrix[\(r)][0]@\(o)")
            XCTAssertEqual(b.f32(at: o + 1), row.y, accuracy: 1e-6, "matrix[\(r)][1]")
            XCTAssertEqual(b.f32(at: o + 2), row.z, accuracy: 1e-6, "matrix[\(r)][2]")
        }
    }

    // MARK: PostParams (80 B)

    func testPostParamsByteSize() {
        let bytes = packPostParams(.zeroed(resX: 1, resY: 1))
        XCTAssertEqual(bytes.count, 80, "PostParams must be 80 bytes / 20 floats")
    }

    func testPostParamsScalarOffsets() {
        var i = PostUniformInput.zeroed(resX: 256, resY: 128)
        i.exposure = 1.2
        i.feedbackAmount = 0.6
        i.bloomThreshold = 0.7
        i.bloomIntensity = 2.5
        i.vignette = 0.35
        i.saturation = 1.1
        i.contrast = 1.05
        i.backgroundColor = RGB(0.05, 0.03, 0.10)
        let b = packPostParams(i)
        XCTAssertEqual(b.f32(at: 0), 256, "resolutionX@0")
        XCTAssertEqual(b.f32(at: 1), 128, "resolutionY@1")
        XCTAssertEqual(b.f32(at: 2), 1.2, accuracy: 1e-6, "exposure@2")
        XCTAssertEqual(b.f32(at: 4), 0.6, accuracy: 1e-6, "feedbackAmount@4")
        XCTAssertEqual(b.f32(at: 5), 0.7, accuracy: 1e-6, "bloomThreshold@5")
        XCTAssertEqual(b.f32(at: 6), 2.5, accuracy: 1e-6, "bloomIntensity@6")
        XCTAssertEqual(b.f32(at: 7), 0.35, accuracy: 1e-6, "vignette@7")
        XCTAssertEqual(b.f32(at: 8), 1.1, accuracy: 1e-6, "saturation@8")
        XCTAssertEqual(b.f32(at: 9), 1.05, accuracy: 1e-6, "contrast@9")
        XCTAssertEqual(b.f32(at: 12), 0.05, accuracy: 1e-6, "backgroundColor.r@12")
        XCTAssertEqual(b.f32(at: 13), 0.03, accuracy: 1e-6, "backgroundColor.g@13")
        XCTAssertEqual(b.f32(at: 14), 0.10, accuracy: 1e-6, "backgroundColor.b@14")
    }

    func testKaleidoscopeClampedToSix() {
        var i = PostUniformInput.zeroed(resX: 8, resY: 8)
        i.kaleidoscope = 10
        XCTAssertEqual(packPostParams(i).u32(at: 10), 6, "kaleidoscope packs as min(6, _)")
        i.kaleidoscope = 3
        XCTAssertEqual(packPostParams(i).u32(at: 10), 3, "kaleidoscope passes through when <= 6")
    }

    func testBlendModeIndices() {
        var add = PostUniformInput.zeroed(resX: 8, resY: 8)
        add.blendMode = .additive
        XCTAssertEqual(packPostParams(add).u32(at: 15), 0, "blendMode .additive@15")
        var paint = PostUniformInput.zeroed(resX: 8, resY: 8)
        paint.blendMode = .paint
        XCTAssertEqual(packPostParams(paint).u32(at: 15), 1, "blendMode .paint@15")
    }
}

// Little-endian readers for the packed byte buffer (mirror EngineCheck's helpers).
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)) }
}