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)) }
}