Zum Inhalt

native/macos/KuroNativeSaver/tests/main.swift

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
// Assert-based logic tests for the renderer Core. Run via
// scripts/run-native-tests.sh — exits non-zero if any assertion fails.
// Reference values for LCG + height computed from the web engine (Python/JS).

import Foundation
import simd
import Metal

var failures = 0
func check(_ cond: Bool, _ msg: String) {
    if cond { print("ok   - \(msg)") } else { print("FAIL - \(msg)"); failures += 1 }
}
func approx(_ a: Double, _ b: Double, _ tol: Double, _ msg: String) {
    check(abs(a - b) <= tol, "\(msg) (got \(a), want \(b), tol \(tol))")
}

// --- LCG: bit-for-bit vs web mkRng -----------------------------------------
do {
    var r = LCG(seed: 1)
    let exp1 = [0.236455525271595, 0.369270673720166, 0.504242032300681,
                0.704883263679221, 0.050543628633022]
    for (i, e) in exp1.enumerated() { approx(r.next(), e, 1e-12, "LCG seed1 [\(i)]") }

    var r2 = LCG(seed: 1337)
    let exp2 = [0.754225567914546, 0.549500931752846, 0.274493878241628,
                0.158748119371012, 0.449464006349444]
    for (i, e) in exp2.enumerated() { approx(r2.next(), e, 1e-12, "LCG seed1337 [\(i)]") }
}

// --- terrain height field vs web heightAt ----------------------------------
do {
    approx(Double(terrainHeight(0, 0)), 1.8, 2e-3, "height(0,0)")
    approx(Double(terrainHeight(5, -12)), -2.702330068427646, 2e-3, "height(5,-12)")
    approx(Double(terrainHeight(-3.5, 7.25)), -0.873893886150996, 2e-3, "height(-3.5,7.25)")
}

// --- camera FOV breakpoints -------------------------------------------------
do {
    check(Camera.defaultFovDeg(width: 2560, height: 1440) == 62, "fov 16:9 = 62")
    check(Camera.defaultFovDeg(width: 600, height: 800) == 95, "fov portrait = 95")
    check(Camera.defaultFovDeg(width: 1280, height: 1024) == 72, "fov ~5:4 = 72")
}

// --- terrain seam continuity ------------------------------------------------
do {
    check(abs(Terrain.startB - Terrain.startA) == Terrain.D, "|startB-startA| == D")
    let la = Terrain.startA, lb = Terrain.startB
    // The meeting edges sample the SAME logical z: (-90 + la) == (90 + lb).
    for x: Float in [-50, -10, 0, 10, 50] {
        approx(Double(terrainHeight(x, -90 + la)),
               Double(terrainHeight(x, 90 + lb)), 1e-6, "seam continuity x=\(x)")
    }
    // After a wrap (both offsets -= 2D) the relation still holds.
    let la2 = la - 2 * Terrain.D, lb2 = lb - 2 * Terrain.D
    approx(Double(terrainHeight(0, -90 + la2)),
           Double(terrainHeight(0, 90 + lb2)), 1e-6, "seam continuity after wrap")
}

// --- palette sanity ---------------------------------------------------------
do {
    check(Palette.presets.count == 13, "13 presets")
    let p = Palette.preset("phosphor")
    check(p.accentHex == 0x39ff7a, "phosphor accent hex")
    // glow*4 = 2.8, capped at 1.8 (2026-06-03 colour tuning — keeps the hue vs blowing to white)
    approx(Double(p.bloomStrength), 1.8, 1e-6, "phosphor bloomStrength (capped at 1.8)")
    check(Palette.preset("nope").id == "toxic-haze", "unknown preset falls back to toxic-haze")
}

// --- scene registry ---------------------------------------------------------
// Guards against scene drift (e.g. a scene added to the web SCENE_REGISTRY but not
// here, or vice versa). Keep in sync with src/engine/engine/core.ts SCENE_REGISTRY.
do {
    check(SceneRegistry.ids.count == 8, "8 scenes (+ wreckage, + metro)")
    check(Set(SceneRegistry.ids).count == SceneRegistry.ids.count, "scene ids unique")
    check(SceneRegistry.ids.contains("matrix"), "matrix scene registered")
    check(SceneRegistry.ids.contains("metro"), "metro scene registered")

    // Every registered scene needs its HUD strings, or the slab falls back to the id.
    // The mode labels live in the shared SSOT (story-content.json § modeLabels), so this
    // also pins that the native table is a FACADE over it and not a second hand-copy —
    // the drift that let macOS keep a stale scene list in the first place.
    let missingLabel = SceneRegistry.ids.filter { SceneMeta.label[$0] == nil }
    let missingModes = SceneRegistry.ids.filter { (SceneMeta.modes[$0]?.count ?? 0) != 3 }
    check(missingLabel.isEmpty, "every scene has a HUD label (missing: \(missingLabel))")
    check(missingModes.isEmpty, "every scene has 3 mode labels (missing: \(missingModes))")
    let ssot = StoryContent.shared.modeLabels
    let drift = SceneRegistry.ids.filter { SceneMeta.modes[$0] != ssot[$0] }
    check(drift.isEmpty, "mode labels come from the SSOT, not a copy (drifted: \(drift))")
}

// --- FlightDirector: envelope math (mirrors src/engine/modes/flight-director.ts) --
// sin²(πp): value AND slope are 0 at both ends, peak 1 at the middle → no roll snap.
do {
    approx(Double(FlightDirector.envelope(0.0)), 0.0, 1e-12, "env(0)=0")
    approx(Double(FlightDirector.envelope(1.0)), 0.0, 1e-12, "env(1)=0")
    approx(Double(FlightDirector.envelope(0.5)), 1.0, 1e-9,  "env(0.5)=1")
    check(FlightDirector.envelope(0.001) < 1e-3, "env slope→0 at start")
    check(FlightDirector.envelope(0.999) < 1e-3, "env slope→0 at end")
}

// --- FlightDirector: same seed ⇒ identical kick result (the regenerating film) ----
do {
    let a = FlightDirector(seed: 1337); let b = FlightDirector(seed: 1337)
    a.enqueue(Manoeuvre(kind: .kick, dur: 1.0, dir: 0, intensity: 0.55))
    b.enqueue(Manoeuvre(kind: .kick, dur: 1.0, dir: 0, intensity: 0.55))
    var ca = Camera(); ca.position.x = 1
    var cb = Camera(); cb.position.x = 1
    for _ in 0..<10 { a.update(t: 0, dt: 0.05); a.apply(&ca); b.update(t: 0, dt: 0.05); b.apply(&cb) }
    approx(Double(ca.position.x), Double(cb.position.x), 1e-12, "director deterministic x")
    check(ca.position.x < 1.0, "kick dampened cam.x")
}

// --- FlightDirector warp transition: stage clock, ramp C1, swap-once ------------
do {
    let d = FlightDirector(seed: 7)
    var swaps = 0
    let p = TransitionProfile(windupDur: 0.5, warpDur: 1.0, emergeDur: 0.5,
                              warpSpeed: 6, fovPush: 30)
    d.beginTransition(p, baseFov: 72) { swaps += 1 }
    check(d.inTransition, "inTransition true after begin")
    var maxSpeed: Float = 0
    for _ in 0..<100 { d.update(t: 0, dt: 0.02); maxSpeed = max(maxSpeed, d.speedMul) }  // 2.0s total
    check(swaps == 1, "swap fired exactly once")
    check(maxSpeed > 5.5 && maxSpeed <= 6.0, "speedMul ramped to ~warpSpeed")
    check(!d.inTransition, "inTransition false after total duration")
    approx(Double(d.speedMul), 1.0, 1e-6, "speedMul back to 1 after transition")
}

// --- FilmDirector: deterministic, tier-matched, no immediate repeat ---------------
do {
    let f = FilmDirector(seed: 99)
    let cands: [ShiftPhase: [String]] = [
        .routine: ["terrain","city"], .intrusion: ["city","rift"], .alarm: ["rift","tunnel"],
        .panic: ["tunnel","void"], .silence: ["void"]]
    let a = f.sceneAt(3, phase: .alarm, prev: nil)
    let b = FilmDirector(seed: 99).sceneAt(3, phase: .alarm, prev: nil)
    check(a == b, "FilmDirector deterministic")
    check(cands[.alarm]!.contains(a), "alarm pick in candidates")
    let prev = f.sceneAt(5, phase: .intrusion, prev: nil)
    let nextPick = f.sceneAt(5, phase: .intrusion, prev: prev)
    check(nextPick != prev, "no immediate repeat when alt exists")
}

// --- CombatDirector: actors gated off (2026-06-05) — no spawn ---------------------
if let device = MTLCreateSystemDefaultDevice() {
    var n = 0
    let host = ActorHost(device: device, add: { _ in n += 1 }, accentRGB: { SIMD3(0, 1, 0) }, enemyRGB: { SIMD3(1, 0, 0.25) })
    let c = CombatDirector(seed: 5, bus: EventBus(), host: host)
    c.update(t: 1, threat: 0.9)
    check(n == 0, "combat actors gated off — no spawn (re-enable to test thresholds)")
} else {
    print("ok   - (combat test skipped — no Metal device)")
}

// --- Terminal: foreshadow lead scales with durationScale (web parity narrative.ts:157) ----
// Pre-fix the lead was a hardcoded `- 6` seconds while dur() didn't scale at all; with a
// scaled phase the foreshadow would fire at the wrong elapsed fraction (web↔native desync).
do {
    let term = Terminal(seed: 42)
    term.durationScale = 0.1                 // routine phase ~7-11s, foreshadow ~0.6s before end
    var firedAtProgress: Double = -1
    var fired = false
    term.sceneForeshadow = { _ in fired = true; return "x" }
    var t = 0.0
    while t < 60 && firedAtProgress < 0 {
        term.update(t: t)
        if fired && firedAtProgress < 0 { firedAtProgress = term.phaseProgress(t) }
        t += 0.05
    }
    check(firedAtProgress > 0.90 && firedAtProgress < 0.99,
          "foreshadow fires ~0.6s (scaled) before phase end, not hardcoded 6s (got progress \(firedAtProgress))")
}

// --- boot line selection: shuffled from the seeded LCG, not the pool's first 7 ---
// Web parity (hud/boot.ts): the pool is shuffled, then 7 are taken. Native took
// `prefix(7)` and thus showed 8 of the 15 authored lines NEVER — the reachability
// check below is the one that pins that, and it was the red test for the change.
do {
    var a = LCG(seed: 4242), b = LCG(seed: 4242)
    let ra = shuffledPrefix(Script.boot, 7, &a), rb = shuffledPrefix(Script.boot, 7, &b)
    check(ra == rb, "same seed → same boot lines (harness renders stay byte-stable)")
    check(ra.count == 7 && Set(ra).count == 7, "7 distinct lines (got \(ra.count), \(Set(ra).count) distinct)")
    check(Set(ra).isSubset(of: Set(Script.boot)), "picked lines all come from the pool")

    // Through the real caller, not just the helper: BootSequence is what the screen
    // shows, and `prefix(7)` there is what left 8 of 15 authored lines unreachable.
    var seen = Set<String>()
    for s in Int32(1)...Int32(200) { seen.formUnion(BootSequence.lines(seed: s)) }
    check(seen.count == Script.boot.count,
          "every authored boot line is reachable on screen (saw \(seen.count) of \(Script.boot.count))")
    check(BootSequence.lines(seed: 99) == BootSequence.lines(seed: 99), "boot selection is seed-stable")
}

// --- Terminal: deterministic golden stream (additive-invariant guard for arc work) ---
// A fixed seed + tiny durationScale drives several phases fast; we FNV-1a-hash the
// visible line stream. Slice 2 (arc layer) must keep this byte-identical when no arc
// is selected. Capture step prints; the assertion below pins count+hash.
// Re-pinned 2026-08-21 when Script.boot became an SSOT facade: the first 7 lines are boot
// lines, and the shared pool differs from the old hand-copied literals (15 lines, `" ... "`
// filler). Verified line-by-line that ONLY those 7 changed — the other 5 stayed identical.
// Re-pinned 2026-09-02 when the boot log started SHUFFLING the pool instead of taking
// `prefix(7)` (decision: web parity, 8 of 15 authored lines were unreachable). Same
// property proven the same way, and this time by reconstruction rather than by eye: the
// OLD pinned hash 3190464026768004751 is exactly FNV-1a over `[old prefix(7) boot lines]
// + [the 5 narrative lines measured after the change]` — so the narrative stream is
// byte-identical and only the 7 boot lines moved. That the shift did not drift at all is
// by construction: `bootLog()` draws from its own LCG (seed &+ 2323), not from `rng`.
do {
    let term = Terminal(seed: 1337)
    term.durationScale = 0.05
    let persona0 = term.promptLine(t: 0)              // persona is fixed at init (seed-derived)
    var t = 0.0
    while t < 12 { term.update(t: t); t += 0.02 }
    let visible = term.visibleLines(max: 60).map { $0.text }
    var h: UInt64 = 1469598103934665603
    for byte in visible.joined(separator: "\n").utf8 { h = (h ^ UInt64(byte)) &* 1099511628211 }
    check(visible.count == 12 && h == 6793128131140453674,
          "golden narrative stream stable (got count \(visible.count) hash \(h))")
    check(persona0 == "TEL-4747@SCT-7.4-N11:~ █", "golden persona stable (got \(persona0))")
}

// --- METRO: district decode + prism geometry (pure, no Metal) ---------------
// The web twin is engine/geo-extrude.ts + scenes/metro.ts. These are the parts that
// can be checked without a GPU: the delta/decimetre decoding of the baked OSM rings,
// the prism edge emission, and the claim that the procedural footprint is always a
// SIMPLE polygon (self-intersecting rings extrude into visual garbage).
do {
    func near(_ a: Float, _ b: Float, _ eps: Float = 1e-4) -> Bool { abs(a - b) < eps }
    func ringNear(_ a: [Float], _ b: [Float]) -> Bool {
        a.count == b.count && zip(a, b).allSatisfy { near($0, $1) }
    }

    // First pair absolute, the rest deltas — all in decimetres, scaled to metres.
    let r = MetroGeo.decodeRing([100, -50, 10, 5, -20, 0], scale: 0.1)
    check(ringNear(r, [10, -5, 11, -4.5, 9, -4.5]), "decodeRing: absolute head, delta tail, scaled (got \(r))")

    // The real baked district decodes and stays finite.
    let d = District.shared
    check(d.footprints.count == 131 && near(Float(d.meta.scale), 0.1) && d.meta.spanZ == 443,
          "district loads (131 footprints, scale 0.1, spanZ 443) — got \(d.footprints.count)")
    var minV: Float = .greatestFiniteMagnitude, maxV: Float = -.greatestFiniteMagnitude, shortest = Int.max
    for f in d.footprints {
        let ring = MetroGeo.decodeRing(f.r, scale: Float(d.meta.scale))
        shortest = min(shortest, ring.count / 2)
        for v in ring { minV = min(minV, v); maxV = max(maxV, v) }
    }
    check(shortest >= 3, "every baked footprint has >= 3 vertices (shortest \(shortest))")
    check(minV.isFinite && maxV.isFinite && maxV - minV < 1000,
          "decoded district stays in a sane metre range (\(minV)…\(maxV))")

    // Prism edges: n sides → bottom + top + vertical = 3n segments = 18n floats,
    // and every y is either baseY or topY (no stray heights).
    let square: [Float] = [0, 0, 10, 0, 10, 10, 0, 10]
    var buf = [Float]()
    MetroGeo.appendPrismEdges(&buf, ring: square, baseY: 2, height: 8)
    check(buf.count == 18 * 4, "prism of a quad emits 3 segments per side (got \(buf.count) floats)")
    let ys = stride(from: 1, to: buf.count, by: 3).map { buf[$0] }
    check(ys.allSatisfy { near($0, 2) || near($0, 10) }, "prism vertices sit on baseY or topY only")

    // Degenerate input must not crash or emit anything.
    var empty = [Float]()
    MetroGeo.appendPrismEdges(&empty, ring: [0, 0, 1, 1], baseY: 0, height: 5)
    MetroGeo.appendPrismEdges(&empty, ring: square, baseY: 0, height: 0)
    check(empty.isEmpty, "a 2-vertex ring or zero height emits nothing (got \(empty.count))")

    // shrink keeps the centroid and scales the extent.
    let sh = MetroGeo.shrink(square, 0.5)
    let (scx, scz) = MetroGeo.centroid(sh), (ocx, ocz) = MetroGeo.centroid(square)
    check(near(scx, ocx) && near(scz, ocz), "shrink keeps the centroid")
    check(near(sh[0], 2.5) && near(sh[1], 2.5), "shrink halves the extent (got \(sh[0]), \(sh[1]))")

    // The procedural footprint must be SIMPLE. It is built star-shaped around its own
    // offset, and the anisotropic scaling (aspect/stretchZ, both > 0) preserves angular
    // order — so the check is: are the vertex angles around the offset monotone?
    var worst = 0
    for seed in Int32(1)...Int32(300) {
        var rng = LCG(seed: seed)
        let ox = Float(-40 + Int(seed) % 80), oz = Float(Int(seed) % 50)
        let ring = MetroGeo.starFootprint(&rng, ox: ox, oz: oz, baseR: 5 + rng.nextF() * 9,
                                          aspect: 0.7 + rng.nextF() * 0.7, stretchZ: 0.7 + rng.nextF() * 0.6)
        let n = ring.count / 2
        var angles = (0..<n).map { atan2(ring[$0 * 2 + 1] - oz, ring[$0 * 2] - ox) }
        // rotate so the sequence starts at the minimum, then it must increase throughout
        if let mi = angles.indices.min(by: { angles[$0] < angles[$1] }) { angles = Array(angles[mi...] + angles[..<mi]) }
        for i in 1..<angles.count where angles[i] <= angles[i - 1] { worst += 1 }
    }
    check(worst == 0, "procedural footprints are star-shaped ⇒ simple (\(worst) angle inversions in 300 seeds)")
}

// --- wallpaper render policy (pure decision table) --------------------------
do {
    var i = RenderPolicyInputs()
    check(renderState(i) == .animating(fps: 30), "policy default AC → 30fps")
    i.onBattery = true
    check(renderState(i) == .frozen, "policy battery default → frozen")
    i.animateOnBattery = true
    check(renderState(i) == .animating(fps: 10), "policy battery opt-in → 10fps")
    i = RenderPolicyInputs(); i.lowPowerMode = true
    check(renderState(i) == .frozen, "policy low-power behaves like battery")
    i.animateOnBattery = true
    check(renderState(i) == .animating(fps: 10), "policy low-power opt-in → 10fps")
    i = RenderPolicyInputs(); i.thermalSerious = true
    check(renderState(i) == .animating(fps: 10), "policy thermal serious → 10fps on AC")
    i.thermalCritical = true
    check(renderState(i) == .frozen, "policy thermal critical → frozen")
    i = RenderPolicyInputs(); i.onBattery = true; i.animateOnBattery = true; i.thermalCritical = true
    check(renderState(i) == .frozen, "policy critical beats battery opt-in")
    i = RenderPolicyInputs(); i.occluded = true; i.animateOnBattery = true
    check(renderState(i) == .hidden, "policy occluded beats everything")
    i = RenderPolicyInputs(); i.screenLocked = true
    check(renderState(i) == .hidden, "policy locked → hidden")
    i = RenderPolicyInputs(); i.screensAsleep = true
    check(renderState(i) == .hidden, "policy display sleep → hidden")
}

// --- desktop coverage (replaces the unusable NSWindow.occlusionState) -------
do {
    let screen = CGRect(x: 0, y: 0, width: 1512, height: 982)

    check(isDesktopCovered(by: [], screen: screen) == false,
          "coverage: empty window list → not covered")

    // REGRESSION 2026-08-19: the desktop's own layers sit BELOW level 0. If they
    // counted, the wallpaper would consider itself covered by the icons it draws
    // behind — which is exactly the black-desktop bug this replaces.
    let desktopLevel = -2147483623, wallpaperLevel = desktopLevel + 1, iconLevel = -2147483603
    let ownLayers = [CoveringWindow(level: wallpaperLevel, bounds: screen),
                     CoveringWindow(level: iconLevel, bounds: screen)]
    check(isDesktopCovered(by: ownLayers, screen: screen) == false,
          "coverage: wallpaper + icon layers never cover the desktop")

    check(isDesktopCovered(by: [CoveringWindow(level: 0, bounds: screen)], screen: screen),
          "coverage: fullscreen normal window covers")

    check(isDesktopCovered(by: [CoveringWindow(level: 0, bounds: screen, alpha: 0.5)],
                           screen: screen) == false,
          "coverage: translucent window does not cover")

    check(isDesktopCovered(by: [CoveringWindow(level: 0, bounds: screen, onscreen: false)],
                           screen: screen) == false,
          "coverage: offscreen window does not cover")

    let half = CGRect(x: 0, y: 0, width: 1512, height: 400)
    check(isDesktopCovered(by: [CoveringWindow(level: 0, bounds: half)], screen: screen) == false,
          "coverage: partial overlap does not cover (conservative — keep animating)")

    let bigger = CGRect(x: -10, y: -10, width: 2000, height: 1200)
    check(isDesktopCovered(by: [CoveringWindow(level: 0, bounds: bigger)], screen: screen),
          "coverage: oversized window covers")

    // Two screens, one covered: the signature must distinguish them per screen.
    let second = CGRect(x: 1512, y: 0, width: 1920, height: 1080)
    let sig = coverageSignature(of: [CoveringWindow(level: 0, bounds: screen)],
                                screens: [screen, second])
    check(sig == [true, false], "coverage: per-screen signature (got \(sig))")
}

// --- StoryContent: the JSON SSOT decodes from the bundle ---------------------
// The content twin lives in src/engine/data/story-content.json (shared with web).
// Native loads it as a bundle resource; run-native-tests.sh puts it next to the binary.
do {
    let sc = StoryContent.shared
    check(sc.schemaVersion == 1, "story content schemaVersion is 1 (got \(sc.schemaVersion))")
    check(sc.presets.count == 13, "story content carries 13 colour presets (got \(sc.presets.count))")

    // Section manifest — every section the web engine authors must survive the Swift decode.
    // Counts are the guard against a silently half-decoded schema (a missing key would
    // otherwise just yield an empty array and a mute terminal).
    let manifest: [(String, Int, Int)] = [
        ("beats.routine", sc.beats.routine.count, 14),
        ("beats.hqInboundRoutine", sc.beats.hqInboundRoutine.count, 5),
        ("beats.hqReplyRoutine", sc.beats.hqReplyRoutine.count, 5),
        ("beats.intrusionsQuotes", sc.beats.intrusionsQuotes.count, 12),
        ("beats.intrusionsFragments", sc.beats.intrusionsFragments.count, 5),
        ("beats.reactionsFirst", sc.beats.reactionsFirst.count, 5),
        ("beats.reactionsFirstResp", sc.beats.reactionsFirstResp.count, 5),
        ("beats.hesitations", sc.beats.hesitations.count, 7),
        ("beats.reactionsAlarm", sc.beats.reactionsAlarm.count, 6),
        ("beats.reactionsAlarmResp", sc.beats.reactionsAlarmResp.count, 6),
        ("beats.hqEscalationDrafts", sc.beats.hqEscalationDrafts.count, 3),
        ("beats.hqNonResponses", sc.beats.hqNonResponses.count, 5),
        ("beats.reactionsPanic", sc.beats.reactionsPanic.count, 7),
        ("beats.reactionsPanicResp", sc.beats.reactionsPanicResp.count, 5),
        ("beats.panicDrafts", sc.beats.panicDrafts.count, 4),
        ("beats.systemFinal", sc.beats.systemFinal.count, 5),
        ("mentor.handshake", sc.mentor.handshake.count, 4),
        ("mentor.exchanges", sc.mentor.exchanges.count, 21),
        ("film.foreshadow", sc.film.foreshadow.count, 6),
        ("film.arrival", sc.film.arrival.count, 6),
        ("film.combat", sc.film.combat.count, 3),
        ("boot.lines", sc.boot.lines.count, 15),
        ("boot.headers", sc.boot.headers.count, 8),
        ("terminalDicts", sc.terminalDicts.count, 4),
        ("hudHandles", sc.hudHandles.count, 6),
        ("modeLabels", sc.modeLabels.count, 8),
        ("flashPhrases", sc.flashPhrases.count, 10),
        ("alertPhrases", sc.alertPhrases.count, 5),
        ("commandSessions", sc.commandSessions.count, 23),
        ("endings.farewells", sc.endings.farewells.count, 6),
        ("endings.lastWords", sc.endings.lastWords.count, 6),
        ("arcs", sc.arcs.count, 5),
        ("chatter", sc.chatter.count, 13),
    ]
    let bad = manifest.filter { $0.1 != $0.2 }
    check(bad.isEmpty, "story content decodes every section (mismatched: \(bad.map { "\($0.0) \($0.1)≠\($0.2)" }))")

    // A command response is a union in the web schema (script-bank.ts:21):
    // either a bare string or a categorised {cat,text}. Both must decode.
    let resp = sc.beats.routine.flatMap { $0.resp }
    check(resp.contains { $0.cat == nil } && resp.contains { $0.cat != nil },
          "command responses decode in both shapes (bare string and categorised)")
    check(resp.allSatisfy { !$0.text.isEmpty }, "no command response decodes to empty text")

    // Untagged fragments are legal and stay eligible under every arc (arc.ts:47).
    let frags = sc.beats.intrusionsFragments
    check(frags.contains { $0.tags == nil } && frags.contains { ($0.tags?.count ?? 0) > 0 },
          "intrusion fragments decode both tagged and untagged")
}

// --- StoryContent: the optional arc fields survive the decode -----------------
// These are the fields Slices 2/7/8 consume; a Swift Optional that silently stays nil
// would look like "arc has no bias" rather than "the decode dropped it".
do {
    guard let cold = StoryContent.shared.arcs.first(where: { $0.id == "cold-path" }) else {
        check(false, "arc cold-path present"); exit(1)
    }
    check(cold.phaseRouting?["INTRUSION"] == "PANIC", "arc cold-path routes INTRUSION → PANIC")
    check(cold.durationScale?["PANIC"] == 0.9, "arc cold-path scales PANIC duration")
    check(cold.beatTags.include == ["trace", "technical"] && cold.beatTags.exclude == ["calm"],
          "arc cold-path carries both beat-tag lists")
    check(cold.scenes?["PANIC"]?.first?.scene == "tunnel", "arc cold-path biases PANIC scenes")
    check(cold.threatCurve?["PANIC"] == [0.8, 1], "arc cold-path carries a threat curve")
    check(cold.ending.divertOnThreat?.first.map { $0.atStage == 3 && $0.to == "captured" } == true,
          "arc cold-path diverts to the captured ending at stage 3")
    let normal = StoryContent.shared.arcs.first { $0.id == "normal" }
    check(normal?.phaseRouting == nil && normal?.scenes == nil,
          "arc normal leaves the optional bias fields absent")
}

// --- Parity: the hand-maintained Swift literals vs the shared JSON SSOT -------
// Written BEFORE Script.swift becomes a facade, so a divergence surfaces as a named
// section rather than as an opaque golden-hash change. Any mismatch here is a content
// parity finding (AGENTS.md § the Swift twin), not a test defect.
do {
    let sc = StoryContent.shared
    func same(_ a: [String], _ b: [String], _ name: String) {
        if a == b { check(true, "parity \(name)"); return }
        let onlyNative = a.filter { !b.contains($0) }
        let onlyWeb = b.filter { !a.contains($0) }
        check(false, "parity \(name) — native-only \(onlyNative.count) \(onlyNative.prefix(2)), web-only \(onlyWeb.count) \(onlyWeb.prefix(2))")
    }
    same(Script.hqInboundRoutine, sc.beats.hqInboundRoutine, "beats.hqInboundRoutine")
    same(Script.hqReplyRoutine, sc.beats.hqReplyRoutine, "beats.hqReplyRoutine")
    same(Script.reactionsFirst, sc.beats.reactionsFirst, "beats.reactionsFirst")
    same(Script.reactionsAlarm, sc.beats.reactionsAlarm, "beats.reactionsAlarm")
    same(Script.reactionsPanic, sc.beats.reactionsPanic, "beats.reactionsPanic")
    same(Script.boot, sc.boot.lines, "boot.lines")
    same(Script.ghostlinkHandshake, sc.mentor.handshake, "mentor.handshake")
    check(Script.mentorName == sc.mentor.name, "parity mentor.name")
    same(Script.farewells, sc.endings.farewells["normal"] ?? [], "endings.farewells.normal")
    for scene in sc.film.foreshadow.keys.sorted() {
        same(Script.sceneForeshadow[scene] ?? [], sc.film.foreshadow[scene]!, "film.foreshadow.\(scene)")
    }
    for scene in sc.film.arrival.keys.sorted() {
        same(Script.sceneArrival[scene] ?? [], sc.film.arrival[scene]!, "film.arrival.\(scene)")
    }
    for kind in sc.film.combat.keys.sorted() {
        same(Script.combatLines[kind] ?? [], sc.film.combat[kind]!, "film.combat.\(kind)")
    }

    // Structured sections: compare TEXT only. The category taxonomies differ by design
    // (web Cat3 strings vs the native 10-case Terminal.Cat), so a text match means the
    // facade is a category mapping and nothing more.
    same(Script.routineBeats.map { $0.cmd }, sc.beats.routine.map { $0.cmd }, "beats.routine.cmd")
    same(Script.routineBeats.flatMap { $0.resp.map { $0.0 } },
         sc.beats.routine.flatMap { $0.resp.map { $0.text } }, "beats.routine.resp.text")
    same(Script.reactionsFirstResp.map { $0.0 }, sc.beats.reactionsFirstResp.map { $0.text }, "beats.reactionsFirstResp.text")
    same(Script.reactionsAlarmResp.map { $0.0 }, sc.beats.reactionsAlarmResp.map { $0.text }, "beats.reactionsAlarmResp.text")
    same(Script.reactionsPanicResp.map { $0.0 }, sc.beats.reactionsPanicResp.map { $0.text }, "beats.reactionsPanicResp.text")
    same(Script.hqNonResponses.map { $0.0 }, sc.beats.hqNonResponses.map { $0.text }, "beats.hqNonResponses.text")
    same(Script.systemFinal.map { $0.0 }, sc.beats.systemFinal.map { $0.text }, "beats.systemFinal.text")
    same(Script.intrusionsQuotes.map { $0.text }, sc.beats.intrusionsQuotes.map { $0.text }, "beats.intrusionsQuotes")
    same(Script.intrusionsFragments.map { $0.text }, sc.beats.intrusionsFragments.map { $0.text }, "beats.intrusionsFragments")
    same(Script.hesitations.map { $0.typed }, sc.beats.hesitations.map { $0.typed }, "beats.hesitations.typed")
    same(Script.hqEscalationDrafts.map { $0.final }, sc.beats.hqEscalationDrafts.map { $0.final }, "beats.hqEscalationDrafts.final")
    same(Script.panicDrafts.map { $0.final }, sc.beats.panicDrafts.map { $0.final }, "beats.panicDrafts.final")
    same(Script.mentorExchanges.map { $0.out }, sc.mentor.exchanges.map { $0.out }, "mentor.exchanges.out")
    // Is the web→native category mapping even a function? Collect every (webCat, nativeCat)
    // pair across all categorised sections; if one web category maps to two native ones the
    // facade cannot be mechanical.
    var pairs: [String: Set<String>] = [:]
    func collect(_ nat: [(String, Terminal.Cat)], _ web: [StoryContent.Line]) {
        for (n, w) in zip(nat, web) { pairs[w.cat, default: []].insert("\(n.1)") }
    }
    collect(Script.routineBeats.flatMap { $0.resp },
            sc.beats.routine.flatMap { $0.resp }.map { StoryContent.Line(cat: $0.cat ?? "—", text: $0.text) })
    collect(Script.reactionsFirstResp, sc.beats.reactionsFirstResp)
    collect(Script.reactionsAlarmResp, sc.beats.reactionsAlarmResp)
    collect(Script.reactionsPanicResp, sc.beats.reactionsPanicResp)
    collect(Script.hqNonResponses, sc.beats.hqNonResponses)
    collect(Script.systemFinal, sc.beats.systemFinal)
    let ambiguous = pairs.filter { $0.value.count > 1 }
    check(ambiguous.isEmpty, "web→native category mapping is a function (ambiguous: \(ambiguous))")
    print("     mapping: \(pairs.mapValues { $0.first! }.sorted { $0.key < $1.key })")

    // native lastWords are bare strings; the JSON carries the abandon ratio alongside.
    same(Script.lastWords, (sc.endings.lastWords["normal"] ?? []).map { $0.typed }, "endings.lastWords.normal")

    // BootSequence reads its scene headers from the same SSOT. What the parity test cannot
    // see is a MISSING key: a scene absent from boot.headers silently falls back to
    // ">> SYSTEM ONLINE", which looks like a design choice rather than a gap.
    let headerless = SceneRegistry.ids.filter { sc.boot.headers[$0] == nil }
    check(headerless.isEmpty, "every native scene has a boot header in the SSOT (missing: \(headerless))")
}

// --- weather vocabulary vs the web (controller.ts:363) -----------------------
// The web folds weather into baseFogDensity (scene default 0.01): 'clear' sets an
// absolute 0.004 (= x0.4), 'heavy-fog' multiplies by 2.5, 'light-fog' is the no-op
// scene default. Until 2026-08-30 the native enum had only clear/storm/dust, where
// .clear WAS the no-op — same picture as the web's light-fog, under the web's name
// for a different value, with two web values unreachable on macOS.
do {
    check(Settings.Weather.allCases.map { $0.rawValue }.sorted()
          == ["clear", "dust", "heavy-fog", "light-fog", "storm"],
          "weather vocabulary matches the web")
    check(Settings.Weather.lightFog.fogMul == 1, "light-fog is the scene default (no-op)")
    check(Settings.Weather.clear.fogMul == 0.4, "clear thins the fog (web 0.004 / 0.01)")
    check(Settings.Weather.heavyFog.fogMul == 2.5, "heavy-fog x2.5 (web parity)")
    check(Settings.Weather.dust.fogMul == 2.4, "dust x2.4 unchanged")
    check(Settings.Weather.storm.fogMul == 1.7, "storm x1.7 unchanged")
}

// --- app defaults vs the shared query contract -------------------------------
// The Metal app hung on NO parity checkpoint until 2026-08-30: the web, Windows
// and Linux sides all pin native/shared/query-contract.txt, while macOS read its
// own UserDefaults and could drift freely. That is exactly how autoCycle slipped
// away from the engine unnoticed since v0.10 — found by hand, not by a test.
//
// This section closes that hole: every key of the contract is either CHECKED
// against AppDefaults or listed as a deliberate exception WITH a reason. A key
// that is neither fails the run, so a future contract change cannot be adopted
// by three hosts and silently skipped by the fourth.
do {
    guard let url = Bundle.main.url(forResource: "query-contract", withExtension: "txt") else {
        // A checkpoint that can skip itself proves nothing (the Linux host makes
        // the same call). Missing fixture = failure, not a skipped test.
        print("FAIL - query-contract.txt not found next to the executable (\(Bundle.main.bundlePath))")
        failures += 1
        exit(1)
    }
    let text = try! String(contentsOf: url, encoding: .utf8)
    guard let line = text.split(separator: "\n").first(where: { $0.hasPrefix("?") }) else {
        print("FAIL - query-contract.txt holds no query line"); failures += 1; exit(1)
    }
    var contract: [String: String] = [:]
    for pair in line.dropFirst().split(separator: "&", omittingEmptySubsequences: false) {
        let kv = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
        contract[String(kv[0])] = kv.count > 1 ? String(kv[1]) : ""
    }
    check(contract.count >= 30, "contract parsed (\(contract.count) keys)")

    var seen = Set<String>()
    func want(_ key: String, _ got: String) {
        seen.insert(key)
        guard let exp = contract[key] else {
            check(false, "contract has key '\(key)'"); return
        }
        check(got == exp, "default \(key): \(got) == contract \(exp)")
    }
    func wantNum(_ key: String, _ got: Double) {
        seen.insert(key)
        guard let exp = contract[key], let e = Double(exp) else {
            check(false, "contract has numeric key '\(key)'"); return
        }
        check(got == e, "default \(key): \(got) == contract \(e)")
    }
    // Float fields are compared IN Float: the contract carries decimals, the app
    // carries Float, and 0.35 widened to Double is 0.3499999940395355 — a Double
    // comparison would measure the widening, not the value.
    func wantFloat(_ key: String, _ got: Float) {
        seen.insert(key)
        guard let exp = contract[key], let e = Float(exp) else {
            check(false, "contract has numeric key '\(key)'"); return
        }
        check(got == e, "default \(key): \(got) == contract \(e)")
    }
    func wantBool(_ key: String, _ got: Bool) { want(key, got ? "on" : "off") }

    want("scene", AppDefaults.scene)
    want("preset", AppDefaults.preset)
    want("speed", AppDefaults.speed)
    want("altitude", AppDefaults.cityAltitude)
    want("fog", AppDefaults.fog)
    want("weather", AppDefaults.weather)
    want("termlayout", AppDefaults.terminalLayout)
    want("bootspeed", AppDefaults.bootSpeed)
    wantBool("audio", AppDefaults.sound)
    wantBool("matrix", AppDefaults.matrix)
    wantBool("radar", AppDefaults.showRadar)
    wantBool("reactive", AppDefaults.reactiveWorld)
    wantBool("autocycle", AppDefaults.autoCycle)
    wantBool("boot", AppDefaults.bootEnabled)
    wantBool("daynight", AppDefaults.dayNight)
    wantFloat("bank", AppDefaults.bankStrength)
    wantNum("cyclemin", AppDefaults.cycleMinutes)
    wantFloat("crtintensity", AppDefaults.intensity)
    wantFloat("curvature", AppDefaults.curvature)
    wantFloat("aperture", AppDefaults.apertureMask)
    wantFloat("ntsc", AppDefaults.ntsc)
    wantFloat("halation", AppDefaults.halation)

    // Deliberate exceptions — each one is a key the app genuinely has no 1:1
    // counterpart for, NOT a value that happens to disagree. Adding to this list
    // is a decision; letting a key fall through it is not possible (see below).
    let skipped: [String: String] = [
        "look":          "display-only on macOS (which one-click Look is selected); the contract carries it empty",
        "terminal":      "web splits terminal on/off from the layout; macOS encodes both in termlayout",
        "crosshair":     "no separate app setting — folded into showHud",
        "bloom":         "web fx toggle; macOS has only the bloomScale multiplier",
        "bloomstrength": "per-preset strength on macOS (bloomScale multiplies it) — different scale, not comparable",
        "trails":        "web fx toggle; macOS trails is a single amount where 0 means off",
        "trailsamount":  "web damp (0.5..0.95) vs macOS persistence amount — different meaning, see the trails finding",
        "scan":          "no macOS equivalent; scanlines are part of the CRT composite",
        "crt":           "web CRT-sim toggle; macOS drives it from crtintensity alone",
        "scale":         "render scale is wallpaper-only on macOS (WallpaperScale, 0.66)",
        "perfadapt":     "adaptive quality is a web/Windows host feature; the Metal app has its own frame policy",
    ]
    for (k, why) in skipped { seen.insert(k); _ = why }

    let unhandled = Set(contract.keys).subtracting(seen)
    check(unhandled.isEmpty,
          "every contract key is checked or a named exception (unhandled: \(unhandled.sorted()))")
    let stale = seen.subtracting(Set(contract.keys))
    check(stale.isEmpty, "no exception names a key the contract dropped (stale: \(stale.sorted()))")
}

if failures > 0 { print("\n\(failures) FAILURE(S)"); exit(1) }
print("\nALL PASS (\(failures == 0))")