Repository navigation
Expand file tree
/
Copy pathproof.mjs
More file actions
1195 lines (1172 loc) · 72.9 KB
/
Copy pathproof.mjs
File metadata and controls
1195 lines (1172 loc) · 72.9 KB
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
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
export const proofSDK = import.meta.dir + "/..";
import {refusalMessage} from '../scripts/agent-keys.mjs';
import {releasePinGaps, statKey, proofInputs, proofInputReader, proofInputFiles, proofInputExcluded, executionInputFiles} from './inputs.mjs';
export {proofInputs, proofInputReader, proofInputFiles, proofInputExcluded} from './inputs.mjs';
// Shared lifecycle for game proofs: operations and assertions stay in the game.
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
import { open as openSession, render, lookPicture } from '../scripts/agent.mjs';
import { cdpFailureContext } from '../scripts/agent-launch.mjs';
import { appleArtifacts } from '../host/apple/build.mjs';
import { buildBake, executableName, resolveApp } from '../scripts/app.mjs';
import { prepareProofTree } from './app/shells.mjs';
import { closeFilesystemReader, filesystemLock } from '../scripts/filesystem.mjs';
import { publicFileCards } from '../host/web/serve.mjs';
import { takeWarmGame } from '../host/web-js/module.mjs';
import { webInputDigests } from '../scripts/agent-launch.mjs';
import { decodePng } from '../scripts/png.mjs';
/** Some runtime-created stacks omit the informative Error message. */
export function formatProofError(error) {
const refusal = refusalMessage(error?.reply);
if (refusal && !process.env.EXACT_AGENT_DEBUG && !process.argv.includes('--json')) return refusal;
const message = String(error), stack = error?.stack;
const rendered = typeof stack === 'string' && stack.length
? stack.includes(message) ? stack : `${message}\n${stack}`
: message;
const context = cdpFailureContext(error);
return context ? `${rendered}\nCDP ${JSON.stringify(context)}` : rendered;
}
/** The failed operation's report row; never serialize the Error or its handles. */
export function proofFailureRow(session, method, args, clock, error) {
const context = cdpFailureContext(error);
return {session, method, args, clock, error:error.message, steps:error.steps, ...(context ? {cdp:context} : {})};
}
// Offline diagnostics over existing state reads (LLP 1012; LLP 1046.001 D2/D5).
// These are inspection captures, not EXSIM saves or a second simulation codec.
export async function captureWorld(session, name = 'world') {
const snapshot = await session.world(name).snapshot({all:true});
const {world} = await session.op({op:'state', ...await session.target(name), world:true});
if (!world || snapshot.tick !== world.tick || snapshot.hash !== world.hash)
throw new Error('world changed during capture; capture on the agent clock with no concurrent drive');
if (world.entities !== snapshot.entities?.length)
throw new Error('incomplete world capture; entity count differs');
const capture = {format:'exact-world-state-v1', name:world.name,
tick:world.tick, hash:world.hash, truncated:snapshot.truncated,
entities:snapshot.entities, resources:world.resources,
simulation:{hz:world.hz, seed:world.seed, args:world.args,
input:world.input, published:world.published}};
validateWorldCapture(capture);
return capture;
}
function validateWorldCapture(capture) {
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
if (capture?.format !== 'exact-world-state-v1' || typeof capture.name !== 'string'
|| !Number.isSafeInteger(capture.tick) || capture.tick < 0
|| typeof capture.hash !== 'string' || !capture.hash.length
|| capture.truncated !== false || !Array.isArray(capture.entities)
|| !record(capture.resources) || !record(capture.simulation))
throw new Error('expected a complete exact-world-state-v1 capture (truncated captures are refused)');
const names = new Set(), ids = new Set();
for (const entity of capture.entities) {
if (!record(entity) || !Number.isSafeInteger(entity.id) || entity.id < 0
|| !(entity.name === null || typeof entity.name === 'string') || !record(entity.components)
|| ids.has(entity.id) || (entity.name !== null && names.has(entity.name)))
throw new Error('invalid or duplicate entity identity in world capture');
ids.add(entity.id);
if (entity.name !== null) names.add(entity.name);
}
const json = (value, depth = 0) => {
if (depth > 128) throw new Error('world capture exceeds 128 levels');
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
if (typeof value === 'number' && Number.isFinite(value)) return;
if (Array.isArray(value) || record(value)) {
for (const child of Object.values(value)) json(child, depth + 1);
return;
}
throw new Error('world capture must contain finite JSON values');
};
json(capture);
}
/** Exact JSON-value comparison, with stable entity names and positional array indices. */
/** The accessible names the platform exposes (LLP 1080.002 `tree --ax`), for
* a proof's name checks: `{unavailable: true}` where the host exposes no
* accessibility tree (Linux), else `name(testId)` (the view's own element first)
* and `all`, every element's name. A check reads `ax.unavailable || ax.name(id) === X`. */
export async function axNames(session) {
const {ax} = await session.tree(null, {ax: true});
if (ax?.unavailable) return {unavailable: true, name: () => undefined, all: []};
const elements = ax?.elements ?? [];
const of = id => elements.find(e => e.testId === id && e.via === 'self') ?? elements.find(e => e.testId === id);
return {unavailable: false, name: id => of(id)?.name, frame: id => of(id)?.frame, all: elements.map(e => e.name)};
}
export function diffWorlds(before, after, {limit = 100} = {}) {
validateWorldCapture(before); validateWorldCapture(after);
if (before.name !== after.name) throw new Error('cannot compare captures of different games');
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('diff limit must be a positive integer');
const changes = [];
let total = 0;
const missing = Symbol('missing');
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const pathKey = key => /^[A-Za-z_][A-Za-z_0-9]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
const walk = (a, b, path) => {
if (Object.is(a, b)) return;
if (Array.isArray(a) && Array.isArray(b)) {
for (let i = 0; i < Math.max(a.length, b.length); i++)
walk(i < a.length ? a[i] : missing, i < b.length ? b[i] : missing, `${path}[${i}]`);
return;
}
if (record(a) && record(b)) {
for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort())
walk(Object.hasOwn(a, key) ? a[key] : missing, Object.hasOwn(b, key) ? b[key] : missing, path + pathKey(key));
return;
}
total++;
if (changes.length < limit) changes.push({path,
kind:a === missing ? 'added' : b === missing ? 'removed' : 'changed',
...(a === missing ? {} : {before:a}), ...(b === missing ? {} : {after:b})});
};
const entities = capture => new Map(capture.entities.map(e => [
e.name === null ? `entities[#${e.id}]` : `entities[${JSON.stringify(e.name)}]`, e.components]));
const a = entities(before), b = entities(after);
for (const key of [...new Set([...a.keys(), ...b.keys()])].sort())
walk(a.has(key) ? a.get(key) : missing, b.has(key) ? b.get(key) : missing, key);
walk(before.resources, after.resources, 'resources');
walk(before.simulation, after.simulation, 'simulation');
return {name:before.name, before:{tick:before.tick, hash:before.hash},
after:{tick:after.tick, hash:after.hash}, hashChanged:before.hash !== after.hash,
total, omitted:total - changes.length, changes};
}
export function formatWorldDiff(diff) {
const lines = [`${diff.name}: tick ${diff.before.tick} → ${diff.after.tick}; hash ${diff.before.hash} → ${diff.after.hash}`];
for (const change of diff.changes) {
const value = key => Object.hasOwn(change, key) ? JSON.stringify(change[key]) : '<absent>';
lines.push(`${change.kind} ${change.path}: ${value('before')} → ${value('after')}`);
}
lines.push(`${diff.total} inspected difference(s)${diff.omitted ? `; ${diff.omitted} omitted` : ''}`);
if (diff.hashChanged && !diff.total)
lines.push('World hashes differ despite equal inspected values; inspection is not a complete binary save comparison.');
return lines.join('\n');
}
if (import.meta.main) {
try {
const [command, before, after, ...extra] = process.argv.slice(2);
if (command !== 'diff' || !before || !after || extra.length)
throw new Error('Usage: bun game/proof.mjs diff before.json after.json');
const diff = diffWorlds(JSON.parse(readFileSync(before, 'utf8')), JSON.parse(readFileSync(after, 'utf8')));
console.log(formatWorldDiff(diff));
process.exitCode = diff.total || diff.hashChanged ? 1 : 0;
} catch (error) {
console.error(`world diff refused: ${error.message}`);
process.exitCode = 2;
}
}
export function parseInventoryLine(line) {
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.{24})\s+(.+)$/);
return m && !m[3].startsWith('Z') ? {pid:Number(m[1]), parent:Number(m[2]), stamp:m[4], command:m[5]} : null;
}
export function artifactDigest(host, dist, artifacts) {
try {
const manifest = [];
const walk = (dir, prefix) => {
for (const name of readdirSync(dir).sort()) {
const path = resolve(dir, name), key = `${prefix}/${name}`;
if (statSync(path).isDirectory()) walk(path, key);
else manifest.push([key, createHash('sha256').update(readFileSync(path)).digest('hex')]);
}
};
if (host === 'web') {
readFileSync(resolve(dist, 'exact.json'));
walk(dist, 'dist');
} else if (host === 'windows') {
readFileSync(artifacts.binary); readFileSync(artifacts.module);
walk(dirname(artifacts.binary), 'product');
} else if (host === 'linux') {
for (const path of [artifacts.binary, artifacts.module]) manifest.push([basename(path), createHash('sha256').update(readFileSync(path)).digest('hex')]);
} else {
const executable = resolve(artifacts.bundle, host === 'macos' ? 'Contents/MacOS' : '.', artifacts.executable);
readFileSync(executable);
walk(artifacts.bundle, 'bundle');
// The driver launches the standalone product on macOS, loading its adjacent dylibs/assets.
if (artifacts.binary) {
readFileSync(artifacts.binary); // A missing actual carrier always invalidates the receipt.
walk(artifacts.products ?? resolve(artifacts.binary, '..'), 'product');
}
}
return createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
} catch { return null; }
}
// Shared by the proof and its artifact lifecycle regression: mode is baked only on web.
export function buildInputHash(host, target, mode = '0', profile = process.env.EXACT_GAME_PROOF_PROFILE ?? 'gpu-dev') {
// Receipts made before complete directory checks may describe stale artifacts.
const hash = createHash('sha256').update('exact game proof receipt v4\0').update(host).update(target);
if (host === 'web') hash.update(mode);
if (host === 'linux' || host === 'windows' || host === 'web') hash.update(profile);
return hash;
}
export async function ensureBuildReceipt({receipt, inputs, artifact, build}) {
const readInputs = typeof inputs === 'function' ? inputs : () => inputs;
let currentInputs = readInputs(), digest = artifact();
const stamp = () => JSON.stringify({inputs:currentInputs, artifact:digest});
if (digest && existsSync(receipt) && readFileSync(receipt, 'utf8') === stamp()) return false;
await build();
// A bake may rewrite generated inputs; bind them to the product from that same build.
try { currentInputs = readInputs(); }
catch (error) { rmSync(receipt, {force:true}); throw error; }
digest = artifact();
if (!digest) throw new Error('build produced no complete proof artifact');
writeFileSync(receipt, stamp());
return true;
}
export async function closeSessions(monitor, record, sessions, check) {
clearInterval(monitor);
try { try { record(); } catch { /* Inventory is best-effort. */ } }
finally {
for (const session of sessions) {
try { await session.close(); } catch (e) { check('session cleanup', false, e.message); }
}
}
}
export function equal(a, b) {
if (a === b) return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
if (Array.isArray(a) || Array.isArray(b)) {
return Array.isArray(a) && Array.isArray(b) && a.length === b.length
&& a.every((value, i) => equal(value, b[i]));
}
const keys = Object.keys(a).sort(), other = Object.keys(b).sort();
return keys.length === other.length
&& keys.every((key, i) => key === other[i] && equal(a[key], b[key]));
}
export const webUnavailable = log => /web carrier unavailable:[^\n]*: ENOENT;/.test(log);
// Both native carriers exercise the same engine Save/FreshGame protocol. Keep
// Linux as the canonical lane whenever requested, including mixed host runs.
export const nativeProofHost = hosts => hosts.includes('linux') ? 'linux' : hosts.includes('windows') ? 'windows' : null;
// Compare observed pins, never the old expected values, before touching pins.json.
export function agreePins(rows, previous, hosts, app, {release: checkRelease = true} = {}) {
const modes = ['0', '1', 'fresh-game'];
const native = nativeProofHost(hosts);
if (!native) throw new Error('repin refused: linux or windows continuous / Save / FreshGame are required');
let reference;
for (const host of hosts) for (const mode of modes) {
const matches = rows.filter(row => row.host === host && row.mode === mode && row.profile !== 'release');
if (matches.length !== 1 || matches[0].failures?.length) throw new Error(`repin refused: ${host} ${mode} missing or failed; inspect artifacts/prove and rerun --paranoid`);
const row = matches[0], pins = row.pins;
if (!pins || !Object.keys(pins.ticks ?? {}).length || !Object.keys(pins.saves ?? {}).length)
throw new Error(`repin refused: ${host} ${mode} has no tick/save observations`);
for (const [section, pattern] of [['ticks', /^0x[0-9a-f]{16}$/], ['saves', /^[0-9a-f]{64}$/]]) {
for (const key of Object.keys(previous[section] ?? {})) if (!(key in pins[section]))
throw new Error(`repin refused: ${host} ${mode} did not observe ${section} ${key}`);
for (const [key, value] of Object.entries(pins[section])) {
if (!pattern.test(value)) throw new Error(`repin refused: ${host} ${mode} invalid ${section} ${key}: ${value}`);
if (reference && reference.pins[section][key] !== value)
throw new Error(`repin refused: ${host} ${mode} ${section} ${key}=${value} disagrees with ${reference.host} ${reference.mode}=${reference.pins[section][key]}; ${proofCommand(resolve(app, 'proof.mjs'), host, '--paranoid')}`);
}
if (reference && !equal(Object.keys(pins[section]).sort(), Object.keys(reference.pins[section]).sort()))
throw new Error(`repin refused: ${host} ${mode} ${section} inventory disagrees with ${reference.host} ${reference.mode}`);
}
reference ??= row;
}
for (const host of checkRelease ? [native, ...hosts.includes('web') ? ['web'] : []] : []) {
const release = rows.filter(row => row.host === host && row.mode === '0' && row.profile === 'release');
if (release.length !== 1 || release[0].failures?.length || !equal(release[0].pins, reference.pins))
throw new Error(`repin refused: ${host} release proof missing, failed, or disagrees with gpu-dev; pins.json unchanged`);
}
return {...reference.pins, hosts, modes, profiles:checkRelease ? ['gpu-dev', 'release'] : ['gpu-dev']};
}
export const pinCoverageGaps = releasePinGaps;
export function pinInputs(rows) {
const inputs=rows[0]?.inputs;
if(!/^[a-f0-9]{64}$/.test(inputs ?? '') || rows.some(row=>row.inputs!==inputs))
throw new Error('repin refused: proof inputs missing or changed between runs; pins.json unchanged');
return inputs;
}
export function pinSourceInputs(rows) {
const inputs=rows[0]?.sourceInputs;
if(!/^[a-f0-9]{64}$/.test(inputs ?? '') || rows.some(row=>row.sourceInputs!==inputs))
throw new Error('repin refused: source inputs missing or changed between runs; pins.json unchanged');
return inputs;
}
/** Provenance without a commit is valid only for an unborn or non-Git game. */
export function pinRevision(app, sourceInputs) {
const git = args => spawnSync('git', args, {cwd:app, encoding:'utf8', env:{...process.env, LC_ALL:'C'}});
const refuse = result => { throw new Error(`repin refused: git provenance failed: ${result.error?.message ?? result.stderr?.trim() ?? result.status}; pins.json unchanged`); };
const inside = git(['rev-parse', '--is-inside-work-tree']);
if (inside.status !== 0) {
let repository = false;
for (let dir = resolve(app);; dir = dirname(dir)) {
if (existsSync(resolve(dir, '.git'))) { repository = true; break; }
if (dirname(dir) === dir) break;
}
if (!repository && inside.status === 128 && /not a git repository/.test(inside.stderr)) return `inputs:${sourceInputs}`;
refuse(inside);
}
const revision = git(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
if (revision.status === 0) return revision.stdout.trim();
const branch = git(['symbolic-ref', '-q', 'HEAD']);
if (revision.status === 1 && branch.status === 0) {
const missing = git(['show-ref', '--exists', branch.stdout.trim()]);
if (missing.status === 2) return `inputs:${sourceInputs}`;
}
refuse(revision);
}
/** Admission markers decide pin provenance; gameplay can log arbitrary text. */
export function assertNoLiveEdits(worlds, journals = []) {
const rows = Array.isArray(worlds) ? worlds : [worlds, ...(worlds?.world ?? [])];
const edited = rows.some(w => w?.developmentEdits || w?.sessionDevelopmentEdits);
const lines = journals.flatMap(j => j.lines ?? []);
const line = lines.find(l => /(?:^| tick=\d+ )set\s+\S+\s+[^.\s]+\.[^.\s]+\s+\S/.test(l));
if (edited) throw new Error(`pin refused: run contains a set development edit${line ? ': ' + line : ''}`);
}
export function editProvenance() {
let refusal;
const pending = new Map();
return {
observe(worlds, journals, session = 'default') {
const rows = Array.isArray(worlds) ? worlds : [worlds, ...(worlds?.world ?? [])];
pending.set(session, rows.some(w => w?.queuedSets > 0));
try { assertNoLiveEdits(worlds, journals); } catch (error) { refusal ??= error; }
},
assert() {
if (refusal) throw refusal;
if ([...pending.values()].some(Boolean)) throw new Error('pin refused: queued set development edit awaits admission');
},
};
}
// Read only provenance fields; walk skipped values to preserve the binary name table.
// Tags and bounds follow engine/src/data/bin.rs. This does not restore gameplay.
function savedFields(bytes, magic, fields) {
const fail = reason => { throw new Error(`pinSave refused: invalid ${magic.startsWith('EXSIM') ? 'EXSIM v8' : 'EXGAME v6'} save: ${reason}`); };
if (!bytes.subarray(0, magic.length).equals(Buffer.from(magic, 'binary'))) fail('unsupported format');
if (bytes.length > 2 * 1024 ** 3) fail('load size limit');
let pos = magic.length;
const names = [], utf8 = new TextDecoder('utf-8', {fatal:true, ignoreBOM:true});
const take = n => {
if (!Number.isSafeInteger(n) || n < 0 || n > bytes.length - pos) fail('truncated value');
const part = bytes.subarray(pos, pos += n); return part;
};
const byte = () => take(1)[0];
const variable = () => {
let n = 0n;
for (let shift = 0n; shift < 70n; shift += 7n) {
const b = byte();
if (shift === 63n && b > 1) fail('varint overflow');
n |= BigInt(b & 127) << shift;
if (b < 128) return n;
}
fail('varint overflow');
};
const text = () => {
const n = Number(variable());
if (n > 64 * 1024 ** 2) fail('string size limit');
try { return utf8.decode(take(n)); } catch { fail('invalid string'); }
};
const name = () => {
const n = variable();
if (n === 0n) { const s = text(); names.push(s); return s; }
if (n > BigInt(names.length)) fail('unknown name index');
return names[Number(n - 1n)];
};
const value = (keep, depth = 0) => {
if (depth >= 256) fail('nesting limit');
const tag = byte();
switch (tag) {
case 0: case 1: return Boolean(tag);
case 2: case 3: return variable();
case 4: take(4); return;
case 5: take(8); return;
case 6: return text();
case 7: {
const n = Number(variable()), out = keep ? [] : null;
if (!Number.isSafeInteger(n) || n > bytes.length - pos) fail('sequence size limit');
for (let i = 0; i < n; i++) { const item = value(keep, depth + 1); if (keep) out.push(item); }
return out;
}
case 8: {
const out = Object.create(null), seen = new Set();
for (let field = byte(); field !== 0; field = byte()) {
if (field !== 1) fail('expected field');
const key = name();
if (seen.has(key)) fail('duplicate field');
seen.add(key);
const retain = keep === true || keep instanceof Set && keep.has(key);
const item = value(retain, depth + 1);
if (retain) out[key] = item;
}
return out;
}
case 9: {
variable(); const variant = name(), item = value(keep, depth + 1);
return keep ? {variant, value:item} : null;
}
case 10: return null;
case 11: return value(keep, depth + 1);
case 12: case 13: case 14: case 15: {
const part = take(Number(variable())); return keep ? part : null;
}
default: fail('unknown value tag');
}
};
if (bytes[pos] !== 8) fail('expected record');
const out = value(new Set(fields));
if (pos !== bytes.length) fail('trailing data');
return out;
}
function assertSaveEdits(bytes) {
const saved = savedFields(bytes, 'EXSIM\0\x08', ['world', 'queue']);
if (!Buffer.isBuffer(saved.world) || !Array.isArray(saved.queue))
throw new Error('pinSave refused: invalid EXSIM v8 world or queue');
const world = savedFields(saved.world, 'EXGAME\0\x06', ['developmentEdits']);
if (world.developmentEdits !== undefined && typeof world.developmentEdits !== 'boolean')
throw new Error('pinSave refused: invalid developmentEdits marker');
if (world.developmentEdits) throw new Error('pinSave refused: save contains a set development edit');
// Rust char::is_whitespace uses Unicode White_Space, unlike JS trim/\s.
const completeSet = text => {
const words = text.split(/\p{White_Space}+/u).filter(Boolean), path = words[2]?.split('.') ?? [];
return words[0] === 'set' && words.length >= 4 && path.length === 2 && path.every(Boolean);
};
if (saved.queue.some(q => q?.event?.variant === 'Message'
&& typeof q.event.value?.text === 'string' && completeSet(q.event.value.text)))
throw new Error('pinSave refused: save contains a queued set development edit awaiting admission');
}
export function pinRecorder(previous, app, check, collecting = false, provenance = editProvenance()) {
const pins = {ticks:{}, saves:{}};
const record = (section, key, got) => {
provenance.assert();
const expected = previous[section]?.[key];
if (key in pins[section]) check(`pin ${key} repeated consistently`, pins[section][key] === got);
pins[section][key] = got;
if (!collecting && (expected !== undefined || Object.keys(previous.ticks ?? {}).length || Object.keys(previous.saves ?? {}).length)) check(
expected === got ? `pin ${key}=${got}` : `pin ${key} differs (expected ${expected}, got ${got}); if the change is intended: ${proofCommand(resolve(import.meta.dir, 'prove.mjs'), app, '--repin')}`, expected === got);
};
return {pins,
pin(tick, state) {
if (state?.tableEdits) check(`pin ${tick} refused: development table edits are in the journal`, false);
provenance.assert();
provenance.observe([state]);
provenance.assert();
check(`pin ${tick} sampled at expected tick (got ${state?.tick})`, state?.tick === tick && /^0x[0-9a-f]{16}$/.test(state?.hash));
record('ticks', String(tick), state?.hash);
},
pinSave(key, path) {
provenance.assert();
const bytes = readFileSync(path);
assertSaveEdits(bytes);
record('saves', key, createHash('sha256').update(bytes).digest('hex'));
},
};
}
// Gameplay assertions can succeed before a game's first baseline exists.
export function proofStatus({failures, expected, pins, collecting = false, partial = false}) {
if (failures.length) return 'FAIL';
if (collecting || partial || ['ticks', 'saves'].some(section =>
!Object.keys(expected[section] ?? {}).length || !equal(expected[section], pins[section]))) return 'UNVERIFIED';
return 'PASS';
}
export function proofVerdict(options, args = []) {
const status = proofStatus({...options, partial:args.some(a => ['--build-only','--screenshot-only','--capture40'].includes(a))});
const reason = status === 'FAIL' ? 'assertions failed' : status === 'PASS' ? 'complete baseline checked'
: args.includes('--build-only') ? 'build only' : args.includes('--screenshot-only') || args.includes('--capture40') ? 'pixels only'
: options.collecting ? 'collecting baseline' : !Object.keys(options.expected.ticks ?? {}).length || !Object.keys(options.expected.saves ?? {}).length
? 'no baseline yet (expected before the first prove)' : 'incomplete baseline observations';
return {status, reason, code:status === 'PASS' ? 0 : status === 'FAIL' ? 1 : 3};
}
// A report describes only recorded failures/stalls, never guesses from successful calls.
export function facilityReport(replies) {
const success = (r, method) => r.method === method && r.reply != null && !r.error && !r.reply.error;
const stalls = replies.filter(r => r.method === 'clock' && r.reply?.settled === false);
const failures = replies.filter(r => r.error || r.reply?.error);
const hints = [];
const sameSession = (a,b) => a.session === b.session && replies.indexOf(a) >= replies.indexOf(b) && (a.clock == null || b.clock == null || a.clock >= b.clock);
const stateUsed = failure => replies.some(r => sameSession(r,failure) && success(r, 'state') && !r.args?.[0]);
const busyUsed = stalls.every(stall => replies.some(r => sameSession(r,stall) && success(r, 'state') && r.args?.[0]?.endsWith(':*') && r.args?.[3] === true));
if (stalls.length) hints.push(`${stalls.length} stalls; state world:* busy exposes moving values and busy reasons${busyUsed ? '' : '; state unused'}`);
const geometry = failures.filter(r => /\bis hidden\b|\bhidden (?:behind|\()|\bbehind (?:the )?camera\b|\boff screen\b|\bcovered or not hit\b|\bno screen box\b/.test(r.error ?? r.reply.error));
if (geometry.some(f => !replies.some(r => sameSession(r,f) && success(r, 'layout') && f.args?.[0] && (r.args?.[0] === f.args[0] || r.args?.[0]?.endsWith(`:${f.args[0]}`))))) hints.push('layout unused; layout <id> (with the driver --json flag) shows visibility and available screen boxes');
if (failures.some(r => /asset|save|restor/.test(r.error ?? r.reply.error) && !stateUsed(r))) hints.push('state unused; untargeted state exposes pending assets and restore errors');
if (failures.some(f => !replies.some(r => sameSession(r,f) && success(r, 'logs')))) hints.push('logs unused; logs includes reload/carry refusals');
if (!stalls.length && !failures.length) hints.push('no recorded stalls or refusals');
return hints;
}
export async function ensureLinuxReceipts({directory,app,surface,gpuInputs,hostInputs,gpuArtifact,hostArtifact,build,completeGpu = () => {}}) {
if (surface) mkdirSync(dirname(surface),{recursive:true});
const declaration = resolve(directory,'build-linux-surfaces.json'), assets = resolve(directory,'build-linux-assets');
// The keys `Actions` bind travel with the declaration (the bake's shortcut warning reads them).
const keys = surface && resolve(dirname(surface),'keys.json'), keysCache = resolve(directory,'build-linux-keys.json');
let cachedAssets = !app;
// No cache is an ordinary miss; a cached file that would overwrite an authored one is a refusal.
if (app && existsSync(resolve(assets,'files.json'))) cachedAssets = restoreBakedAssets(assets, app);
const artifact = () => {
const product = gpuArtifact();
if (!surface) return product;
if (!product || !existsSync(declaration) || !cachedAssets) return null;
return createHash('sha256').update(product).update(readFileSync(declaration)).update(app ? bakedAssetDigest(assets) : '').digest('hex');
};
const gpu=await ensureBuildReceipt({receipt:resolve(directory,'build-linux-gpu.sha256'),inputs:gpuInputs,artifact,build:async () => {
await build('gpu');
if (surface) copyFileSync(surface,declaration);
if (keys && existsSync(keys)) copyFileSync(keys,keysCache);
if (app) { cacheBakedAssets(app,assets); cachedAssets = true; }
}});
// A fresh snapshot needs the declaration even when the GPU product is reused.
if (surface) copyFileSync(declaration,surface);
if (keys && existsSync(keysCache)) copyFileSync(keysCache,keys);
completeGpu();
// The GPU build may have changed surfaces.json; observe it only afterwards.
const host=await ensureBuildReceipt({receipt:resolve(directory,'build-linux-host.sha256'),inputs:hostInputs,artifact:hostArtifact,build:()=>build('host')});
return gpu || host;
}
function bakedAssetDigest(cache) {
const hash = createHash('sha256'), files = JSON.parse(readFileSync(resolve(cache,'files.json')));
hash.update(JSON.stringify(files));
for (const name of files) hash.update(name).update(readFileSync(resolve(cache,name)));
return hash.digest('hex');
}
function cacheBakedAssets(app, cache) {
const root = resolve(import.meta.dir,'..'), authored = new Set(proofInputFiles(root,app));
const manifest = resolve(app,'.baked-assets.json');
const owned = new Set(existsSync(manifest) ? Object.keys(JSON.parse(readFileSync(manifest))).map(name=>'assets/'+name) : []);
const files = executionInputFiles(root,app,undefined,undefined,authored).filter(file => {
const name = relative(app,resolve(root,file));
return name === '.baked-assets.json' || name.startsWith('assets/') && (!authored.has(file) || owned.has(name));
}).map(file=>relative(app,resolve(root,file))).sort();
rmSync(cache,{recursive:true,force:true}); mkdirSync(cache,{recursive:true});
for (const name of files) { mkdirSync(dirname(resolve(cache,name)),{recursive:true}); copyFileSync(resolve(app,name),resolve(cache,name)); }
writeFileSync(resolve(cache,'files.json'),JSON.stringify(files));
}
// A cached file lands only where the app's own bake manifest says a generated file of that
// digest is (or where nothing is): a file the author put there is refused by name, as the baker
// refuses the same collision (game/bake, "collides with an authored asset"). Every destination is
// checked before any is written.
function restoreBakedAssets(cache, app) {
const files = JSON.parse(readFileSync(resolve(cache,'files.json')));
const manifest = resolve(app,'.baked-assets.json');
// Art was baked before the input scan. An older manifest must not remove ownership
// of a new model, restore a removed one, or replace its current bytes.
const cachedManifest = resolve(cache,'.baked-assets.json');
if (existsSync(cachedManifest) !== existsSync(manifest)
|| existsSync(manifest) && !readFileSync(manifest).equals(readFileSync(cachedManifest))) return false;
const generated = existsSync(manifest) ? JSON.parse(readFileSync(manifest)) : {};
const writes = [];
for (const name of files) {
if (name !== '.baked-assets.json' && (!name.startsWith('assets/') || !resolve(app,name).startsWith(resolve(app,'assets')+'/'))) throw new Error(`invalid baked asset path: ${name}`);
const destination = resolve(app,name), bytes = readFileSync(resolve(cache,name));
if (!existsSync(destination)) { writes.push([destination,bytes]); continue; }
const current = readFileSync(destination);
if (current.equals(bytes)) continue;
const recorded = name === '.baked-assets.json' ? undefined : generated[name.slice('assets/'.length)];
const ownedDigest = recorded && (typeof recorded === 'string' ? recorded : recorded.digest ?? recorded.sha256);
const currentDigest = createHash('sha256').update(current).digest('hex');
if (name !== '.baked-assets.json' && ownedDigest !== currentDigest)
throw new Error(`cached asset \`${name}\` would overwrite a file the bake does not own; move the authored file or delete the cached build (artifacts/build)`);
writes.push([destination,bytes]);
}
for (const [destination,bytes] of writes) { mkdirSync(dirname(destination),{recursive:true}); writeFileSync(destination,bytes); }
return true;
}
export async function withProofBuild(target, build) {
for (;;) {
let entered = false;
try { return await filesystemLock(target, 'proof-products/.lock', async () => { entered = true; return await build(); }); }
catch (error) { if (entered || !error.message.includes('stream is locked by another publisher')) throw error; }
await new Promise(ok => setTimeout(ok,100));
}
}
export function copyProofProducts(root, app, artifacts, destination, expected = {}) {
mkdirSync(destination,{recursive:true});
const products = {}, copied = {};
for (const [kind,path] of Object.entries(artifacts)) {
const to = resolve(destination,basename(path)), bytes = readFileSync(path);
copyFileSync(path,to);
const hash = createHash('sha256').update(bytes).digest('hex');
if (createHash('sha256').update(readFileSync(to)).digest('hex') !== hash) throw new Error(`proof product changed while copying: ${path}`);
if (expected[kind] && expected[kind] !== hash) throw new Error(`proof product changed after validation: ${path}`);
copied[kind] = to; products[to] = hash;
if (existsSync(path+'.proof.json')) { copyFileSync(path+'.proof.json',to+'.proof.json'); products[to+'.proof.json'] = createHash('sha256').update(readFileSync(to+'.proof.json')).digest('hex'); }
}
const inputs = Object.fromEntries(executionInputFiles(root,app).map(file => [file,createHash('sha256').update(readFileSync(resolve(root,file))).digest('hex')]));
const receipt = resolve(destination,'products.json');
writeFileSync(receipt,JSON.stringify({binary:copied.binary,inputs,products}));
return {...copied,receipt};
}
export function productDigest(path, cachePath) {
try {
const key=statKey(path); let cached;
try {cached=JSON.parse(readFileSync(cachePath,'utf8'));} catch { /* First product. */ }
if(cached?.key===key) return cached.digest;
const digest=createHash('sha256').update(readFileSync(path)).digest('hex');
writeFileSync(cachePath,JSON.stringify({key,digest})); return digest;
} catch {return null;}
}
export async function paranoidRuns(run, restore = async () => 0, host = 'web') {
let failed = false;
for (const mode of ['0', '1', 'fresh-game']) {
try { failed = (await run(mode)) !== 0 || failed; }
catch (error) { console.error(error); failed = true; }
}
try { if (host === 'web') failed = (await restore()) !== 0 || failed; }
catch (error) { console.error(error); failed = true; }
return failed;
}
// The look oracle's tells (LLP 1127.000 D1; game/render/src/tells.rs), by the
// words of their `look:` lines, and the ones a game's app.json silences.
export const TELLS = {'near-black':/near black$/, 'near-white':/near white$/, 'clipped':/% of pixels clipped$/,
'particles-uncovered':/particles live, 0 pixels covered \(.*\)$/, 'no-shadow-casters':/sun shadow maps hold no texel nearer than their clear depth$/};
export function tellOf(line) {
const at = typeof line === 'string' ? line.indexOf('look: ') : -1;
if (at < 0) return null;
const text = line.slice(at), name = Object.keys(TELLS).find(n => TELLS[n].test(text));
return name ? {name, line:text} : null;
}
export function lookSilence(app) {
const path = resolve(app, 'app.json');
const silence = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).game?.look?.silence ?? [] : [];
const unknown = silence.filter(name => !(name in TELLS));
if (unknown.length) throw new Error(`${path}: game.look.silence names ${unknown.join(', ')}; the tells are ${Object.keys(TELLS).join(', ')}`);
return new Set(silence);
}
// A world's own `logs` line that a look frame added (`tick=N look: …`,
// surface/look.rs): presentation, which no save or fresh game reproduces.
export const isTellLine = line => typeof line === 'string' && /^tick=\d+ look: /.test(line);
// A world's journal chunks as the paranoid comparison reads them: without tell lines.
export function worldJournal(chunks) {
return chunks.map(({from, next, lines, tick}) => Object.fromEntries(
Object.entries({from, next, lines:lines?.filter(line => !isTellLine(line)), tick}).filter(([,value]) => value !== undefined)));
}
// The frames one reply judged and their tells: a settle's or a screenshot's
// lines, a film's frames (`lookFrames`), or a look frame's own. A reduced
// (contact-sheet) frame and a look frame from another view than the game's
// camera are pictures, and judge nothing. With them, every gap and failed look
// frame the reply names.
export function frameTells(reply) {
const look = reply?.look, own = look && !Array.isArray(look) && !look.reduced && !reply.view ? look : null;
const frames = reply?.lookFrames ?? (Array.isArray(look) ? [look] : own ? [(own.tells ?? []).map(t => t.line)] : []);
return {frames:frames.map(lines => lines.map(tellOf).filter(Boolean)),
gaps:[...own?.gaps ?? [], ...reply?.lookGaps ?? [], ...reply?.lookFailed ?? []]};
}
// The look result beside the simulation result (LLP 1127.000 D5). `worlds` is
// each world every session showed at its close, with its picture or why it has
// none; `tells` one entry a judged frame told; `gaps` what a judged frame could
// not measure. A tell no silence names fails it; it passes only when every
// world was seen and every measurement taken, and is otherwise unverified.
export function lookResult({worlds, tells, gaps = []}, silence) {
// One entry a tell: its first line and how many distinct frames told it.
const byName = list => [...Map.groupBy(list, t => t.name).values()]
.map(all => ({...all[0], times:new Set(all.map((t, i) => t.frame ?? `entry ${i}`)).size}));
const loud = byName(tells.filter(t => !silence.has(t.name))), quiet = byName(tells.filter(t => silence.has(t.name)));
// Worlds without a picture, one line a canvas and reason: `sessions 1, 2 world: no picture on this host: …`.
const unseen = Map.groupBy(worlds.filter(w => !w.picture), w => `${w.canvas}: ${w.unavailable ?? w.error ?? 'no picture'}`);
const missing = [...[...unseen].map(([why, all]) => `session${all.length > 1 ? 's' : ''} ${all.map(w => w.session).join(', ')} ${why}`), ...new Set(gaps)];
const status = loud.length ? 'FAIL' : worlds.length && !missing.length ? 'PASS' : 'UNVERIFIED';
const detail = loud.length ? [...new Set(loud.map(t => t.name))].join(', ')
: !worlds.length ? 'no world was shown' : missing.length ? missing.join('; ')
: `${quiet.length} tells${quiet.length ? ' silenced' : ''}`;
return {status, detail, loud, quiet, missing};
}
// One proof's look accounting (LLP 1127.000 D5): the pictures and judged
// frames, every world a session showed (by session and canvas id), and the
// ones a closing look frame, a judged settle or screenshot, or an explicit
// look(…) looked at. `idOf(session)` names a session; `out` the artifacts.
export function lookAccount(out, idOf) {
const looks = {closing:new Map(), pictures:[], pages:[], sheets:[], tells:[], gaps:[], frames:0};
const shown = new Map(), lookedAt = new Set();
// Each judged frame's tells, marked with the frame (a tell several frames told counts each), and its gaps.
const noteFrame = ({frames, gaps}) => {
for (const tells of frames) { looks.frames += 1; looks.tells.push(...tells.map(t => ({...t, frame:looks.frames}))); }
looks.gaps.push(...gaps);
};
const show = (session, id, canvas = id) => { if (!shown.has(`${session}:${id}`)) shown.set(`${session}:${id}`, {session, id, canvas}); };
return {
looks, show,
// One look frame of a session's world, from `view` ({}, {top}, {yaw, pitch, distance}), into the artifacts. Only
// the game's own camera is judged, and a judged one looked at its canvas; another view is a picture.
async look(session, view = {}, label = 'look', canvas = 'world') {
const frame = await lookPicture(session, resolve(out, `${label}.png`), canvas, view);
looks.pictures.push(frame.screenshot);
const judged = frameTells(frame);
noteFrame(judged);
const sid = idOf(session);
if (sid != null && frame.canvas != null) {
show(sid, frame.canvas, canvas);
if (judged.frames.length) lookedAt.add(`${sid}:${frame.canvas}`);
}
return {picture:frame.screenshot, look:frame.look};
},
// A recorded reply ({session, method, reply}): the frames it judged, the worlds it showed (a settle's or a
// state's world list, a tree's canvases) and the canvases its tells looked at.
replied({session, method, reply}) {
if (['clock', 'screenshot', 'state'].includes(method)) noteFrame(frameTells(reply));
for (const w of Array.isArray(reply?.world) ? reply.world : []) if (w?.canvas != null) show(session, w.canvas);
if (method === 'tree') for (const n of reply?.nodes ?? []) if (n.world) show(session, n.id, n.props?.testId ?? n.id);
for (const id of reply?.lookSeen ?? []) lookedAt.add(`${session}:${id}`);
},
// Every world shown: its closing look frame's record, or seen by a judged frame, or a gap.
worlds() {
return [...shown].map(([key, w]) => looks.closing.get(key)
?? (lookedAt.has(key) ? {...w, picture:'a judged frame'} : {...w, error:'shown, and never looked at'}));
},
};
}
// The look result across hosts and runs (prove.mjs): a FAIL anywhere fails
// it; it passes only when every expected run ({host, repeat}) has a summary
// whose look passed, and a run with none, or none with a look, is a named gap.
export function lookAcrossRuns(rows, expected = rows.map(r => ({host:r.host, repeat:r.repeat}))) {
const gaps = expected.filter(e => !rows.some(r => r.host === e.host && r.repeat === e.repeat && r.look))
.map(e => `${e.host} run ${e.repeat}: no look result`);
const looks = rows.map(r => r.look).filter(Boolean);
const status = looks.some(l => l.status === 'FAIL') ? 'FAIL'
: looks.length && !gaps.length && looks.every(l => l.status === 'PASS') ? 'PASS' : 'UNVERIFIED';
return {status, gaps};
}
// A run that failed by its look alone: no simulation assertion failed, whether
// its simulation passed or was collecting a baseline (UNVERIFIED).
export const lookOnlyFailure = summary => summary?.look?.status === 'FAIL'
&& summary.simulation !== 'FAIL' && !(summary.failures ?? []).length;
export function proofCommand(script, ...args) {
// From the filesystem root a relative path would read as a bare name (`Users/…`).
const cwd = process.cwd(), path = dirname(cwd) === cwd ? script : relative(cwd, script);
return ['bun', path, ...args]
.map(value => /^[a-zA-Z0-9_./-]+$/.test(value) ? value : "'"+value.replaceAll("'", "'\\''")+"'").join(' ');
}
// Both whole-app state and targeted world snapshots are observations.
export function worldObservations(observations, session) {
return reply => {
const worlds = Array.isArray(reply?.world) ? reply.world : [reply?.world ?? reply];
for (const world of worlds) if (world?.hash && Number.isSafeInteger(world.tick))
observations.set(session, {session, tick:world.tick, hash:world.hash});
};
}
// Read every world's executor, including worlds removed before the session closes.
export function executedModes(mode) {
const records = new Map();
return {
observe(state, session) {
for (const world of Array.isArray(state?.world) ? state.world : []) {
if (!world?.hash) continue;
const p = world.paranoid;
if (p?.mode !== mode) throw new Error(`executed mode ${p?.mode ?? 'missing'} differs from requested ${mode} (session ${session}, canvas ${world.canvas})`);
if (!Number.isSafeInteger(p.checked) || p.checked < 0 || typeof p.owed !== 'boolean')
throw new Error('paranoid reconstruction evidence missing');
const key = JSON.stringify([session, world.canvas, world.restarted]);
const previous = records.get(key);
records.set(key, {session, canvas:world.canvas, restarted:world.restarted, firstTick:previous?.firstTick ?? world.tick,
firstChecked:previous?.firstChecked ?? p.checked, tick:world.tick, ...p});
}
},
rows:() => [...records.values()],
assert() {
const rows = [...records.values()];
if (!rows.length) throw new Error('executed mode evidence missing: no worlds observed');
if (mode !== '0') {
if (!rows.some(p => p.checked > p.firstChecked)) throw new Error(`paranoid ${mode}: no completed reconstruction samples`);
for (const p of rows) {
if (p.owed) throw new Error(`paranoid ${mode}: sample still owed (session ${p.session}, canvas ${p.canvas})`);
if (p.tick > p.firstTick && p.checked <= p.firstChecked)
throw new Error(`paranoid ${mode}: advanced world has no reconstruction samples (session ${p.session}, canvas ${p.canvas})`);
}
}
},
};
}
/** What a screenshot shows of a world canvas: the canvas's box from `layout()`, less
* the boxes of every node inside the canvas's subtree in `tree()` (its HUD, masked
* whatever its size or overflow; a sibling or ancestor is not masked), and the share of
* the rest further than `tolerance` (per channel, 0–255) from its most common colour,
* which a cleared canvas is everywhere. `shot` is the screenshot's reply, whose `w`
* scales points to pixels. `pixels` is 0 when the HUD covers the whole canvas. */
export function worldPixels(png, shot, layout, tree, canvas = 'world', tolerance = 8) {
const {width, height, data} = decodePng(png), scale = width / (shot?.w || layout.viewport.w);
const owner = tree.nodes.find(n => n.props?.testId === canvas);
const world = owner && layout.nodes.find(n => n.id === owner.id);
if (!world) throw new Error(`no canvas ${canvas} in tree and layout`);
const parents = new Map(tree.nodes.map(n => [n.id, n.parent]));
const inside = id => { for (let p = parents.get(id); p != null; p = parents.get(p)) if (p === owner.id) return true; return false; };
const hud = layout.nodes.filter(n => inside(n.id) && n.w > 0 && n.h > 0);
const covered = (b, x, y) => x >= b.x && x < b.x + b.w && y >= b.y && y < b.y + b.h;
const pixels = [];
for (let py = Math.max(0, Math.floor(world.y * scale)); py < Math.min(height, Math.ceil((world.y + world.h) * scale)); py++) {
for (let px = Math.max(0, Math.floor(world.x * scale)); px < Math.min(width, Math.ceil((world.x + world.w) * scale)); px++) {
const [x, y] = [(px + 0.5) / scale, (py + 0.5) / scale];
if (hud.some(b => covered(b, x, y))) continue;
pixels.push((py * width + px) * 4);
}
}
const counts = new Map();
for (const i of pixels) {
const key = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
counts.set(key, (counts.get(key) ?? 0) + 1);
}
let clear = 0, most = -1;
for (const [key, n] of counts) if (n > most) { most = n; clear = key; }
const [r, g, b] = [clear >> 16, (clear >> 8) & 255, clear & 255];
const drawn = pixels.filter(i => Math.max(Math.abs(data[i] - r), Math.abs(data[i + 1] - g), Math.abs(data[i + 2] - b)) > tolerance).length;
return {pixels:pixels.length, drawn, share:pixels.length ? drawn / pixels.length : 0, hud:hud.length};
}
/** The engine fact a proof checks on a GPU host: the renderer reports itself `ready`
* and the canvas, outside its HUD, is not one cleared colour. When the HUD covers the
* whole canvas no pixel can speak, so it rests on `ready` and the host's first-draw
* report (`perf.firstFrameMs`, where the host gives one) and says so. */
export function completedWorldFrame(shown, world) {
const ready = world?.ready === true, firstDraw = world?.perf?.firstFrameMs;
if (shown.pixels > 0) return {ok:ready && shown.drawn > 0, fallback:false, ready, ...shown};
return {ok:ready && (firstDraw === undefined || Number.isFinite(firstDraw)), fallback:true, ready, firstDraw, ...shown};
}
/** The game a proof script checks: `--app <dir>`, else the nearest directory at or
* above the script's own that holds `app.json` or `logic/src/lib.rs`, so a script
* under `tools/` proves the game around it. */
export function proofGame(scriptDir, args) {
const isGame = dir => existsSync(resolve(dir, 'app.json')) || existsSync(resolve(dir, 'logic/src/lib.rs'));
const at = args.indexOf('--app');
if (at >= 0) {
const named = args[at + 1];
if (!named || named.startsWith('--')) throw new Error('--app requires the game\'s directory');
if (!isGame(resolve(named))) throw new Error(`--app ${named}: not a game (no app.json or logic/src/lib.rs there)`);
return resolve(named);
}
for (let dir = resolve(scriptDir);; dir = dirname(dir)) {
if (isGame(dir)) return dir;
if (dirname(dir) === dir) throw new Error(`${resolve(scriptDir)}: no game at or above this script (no app.json or logic/src/lib.rs); pass --app <game directory>`);
}
}
export async function proof(meta, script) {
try { await runProof(meta, script); }
catch (error) {
console.error(`PROOF FAIL: ${formatProofError(error)}`);
process.exitCode = 1;
}
}
async function runProof(meta, script) {
const args = process.argv.slice(2), device = args.includes('--device');
const named = args.includes('--app') ? args[args.indexOf('--app') + 1] : undefined;
const app = proofGame(fileURLToPath(new URL('.', meta.url)), args) + sep, name = process.env.EXACT_PROOF_NAME ?? basename(app);
const root = fileURLToPath(new URL('..', import.meta.url));
const phone = args.includes('--phone') ? args[args.indexOf('--phone') + 1] : undefined;
const host = args.find(arg => !arg.startsWith('--') && arg !== phone && arg !== named) ?? 'linux';
const appFlag = named === undefined ? [] : ['--app', app];
if (device && host !== 'ios') throw new Error('--device requires the ios proof host');
if (args.includes('--phone') && (!device || !phone || phone.startsWith('--'))) throw new Error('--phone requires --device and a device name or identifier');
const destination = device ? 'ios-device' : host;
const out = resolve(process.env.EXACT_PROOF_OUT ?? resolve(app, 'artifacts', destination));
const buildOut = resolve(process.env.EXACT_PROOF_BUILD_OUT ?? resolve(app, 'artifacts'));
mkdirSync(buildOut, {recursive:true});
// Web builds replace dist atomically on every invocation. Keep that churn
// inside the excluded artifact tree, away from monitored source directories.
const dist = resolve(process.env.EXACT_WEB_DIST ?? resolve(buildOut, 'web-dist'));
mkdirSync(out, {recursive:true});
// Re-execute the actual proof, comparing every session's final simulation state.
if (process.argv.includes('--paranoid')) {
if (!['web', 'linux', 'windows'].includes(host)) throw new Error('--paranoid supports web, linux and windows');
let unverified = false;
const failed = await paranoidRuns(async mode => {
const started = performance.now();
const child = spawn(process.execPath, [fileURLToPath(meta.url), host, ...appFlag], {
env:{...process.env, EXACT_GAME_PARANOID:mode, EXACT_GAME_PARANOID_COMPARE:'1'}, stdio:'inherit',
});
const code = await new Promise((ok, reject) => { child.on('exit', ok); child.on('error', reject); });
unverified ||= code === 3;
console.log(`PARANOID ${name} ${host} ${mode}: ${((performance.now()-started)/1000).toFixed(3)} s (including build)`);
return code === 3 ? 0 : code;
}, async () => {
const child = spawn(process.execPath, [fileURLToPath(meta.url), host, ...appFlag, '--build-only'], {
env:{...process.env, EXACT_GAME_PARANOID:'0'}, stdio:'inherit',
});
const code = await new Promise((ok, reject) => { child.on('exit', ok); child.on('error', reject); });
return code === 3 ? 0 : code;
}, host);
console.log(`PROOF ${failed ? 'FAIL' : unverified ? 'UNVERIFIED' : 'PASS'} ${name}: paranoid modes ${failed ? 'failed' : unverified ? 'completed without a complete baseline' : 'checked the baseline'}`);
process.exit(failed ? 1 : unverified ? 3 : 0);
}
const finalWorlds = [], observations = new Map();
const modes = executedModes(process.env.EXACT_GAME_PARANOID ?? '0');
const previousPins = JSON.parse(readFileSync(resolve(app, 'pins.json'), 'utf8'));
const collecting = process.env.EXACT_PROOF_REPIN === '1';
const compareParanoid = process.env.EXACT_GAME_PARANOID_COMPARE === '1';
Object.assign(process.env, {EXACT_APP_DIR:app, EXACT_WEB_DIST:dist,
EXACT_UPDATE_TRUST:'development'});
const started = performance.now(), failures = [], transcript = [], replies = [], sessions = new Set();
const say = line => { transcript.push(line); console.log(line); };
const check = (label, ok, value) => {
say(`${ok ? 'PASS' : 'FAIL'} ${label}${ok || value === undefined ? '' : ': ' + JSON.stringify(value)}`);
if (!ok) failures.push(label);
return ok;
};
const provenance = editProvenance();
const {pins, pin, pinSave} = pinRecorder(previousPins, app, check, collecting, provenance);
// The look step runs once per proof mode 0: the paranoid modes check the simulation.
const silence = lookSilence(app), looking = (process.env.EXACT_GAME_PARANOID ?? '0') === '0';
const sessionIds = new WeakMap(), account = lookAccount(out, session => sessionIds.get(session));
const {looks, show} = account, look = (...args) => account.look(...args);
// At a session's close: each world's look frame from its own camera at the held tick, which is judged; the page
// as composited where the canvas draws; and a contact sheet over 600 ms at half resolution, which is a picture.
const lookStep = async (raw, id) => {
const nodes = ((await raw.tree()).nodes ?? []).filter(n => n.world);
for (const node of nodes) {
const canvas = node.props?.testId ?? node.id;
const label = `look-${id}${nodes.length > 1 ? `-${canvas}` : ''}`, world = {session:id, id:node.id, canvas};
show(id, node.id, canvas);
looks.closing.set(`${id}:${node.id}`, world);
try {
world.picture = (await look(raw, {}, label, canvas)).picture;
if (!['linux', 'windows'].includes(host)) looks.pages.push((await raw.screenshot(resolve(out, `${label}-page.png`))).screenshot);
looks.sheets.push((await raw.screenshot(resolve(out, `${label}-sheet.png`), {over:600, every:50, window:file => lookPicture(raw, file, canvas, {resolution:0.5})})).screenshot);
} catch (error) {
if (error.unavailable) world.unavailable = error.unavailable;
else if (!world.picture) world.error = `look frame failed: ${error.message}`;
else looks.gaps.push(`session ${id} ${canvas}: ${error.message}`);
}
}
};
// The GPU-less host has no process tree to discover: retain the process
// handles from the carrier and await them. Global ps can block indefinitely
// on this Mac; an optional web descendant audit is bounded and never delays
// headless gameplay verification.
const children = [], recorded = new Map();
const onProcess = child => { children.push(child); recorded.set(child.pid, 'carrier'); };
let auditUnavailable = false, inventoryPending;
const inventory = () => new Promise(resolve => {
const child = spawn('ps', ['-axo', 'pid=,ppid=,stat=,lstart=,comm='], {stdio:['ignore','pipe','ignore']});
let output = '', done = false;
const finish = rows => { if (done) return; done = true; clearTimeout(timer); resolve(rows); };
const timer = setTimeout(() => {
auditUnavailable = true;
child.kill('SIGKILL'); // This invocation's recorded ps, never a name/pattern.
child.stdout.destroy(); child.unref(); finish(null);
}, 200);
child.stdout.on('data', data => output += data);
child.on('error', () => { auditUnavailable = true; finish(null); });
// A zombie is dead: killed with its group, not yet reaped by launchd.
child.on('exit', code => finish(code === 0 ? output.trim().split('\n').map(parseInventoryLine).filter(Boolean) : null));
});
const sample = () => {
if (host === 'linux' || host === 'windows' || host === 'ios' || auditUnavailable || inventoryPending) return;
inventoryPending = inventory().then(rows => {
const owned = new Set([process.pid, ...children.map(child => child.pid)]);
for (let changed = true; changed;) {
changed = false;
for (const row of rows ?? []) if (owned.has(row.parent) && !owned.has(row.pid)) {
owned.add(row.pid); recorded.set(row.pid, row.stamp); changed = true;
}
}
}).finally(() => { inventoryPending = null; });
};
const monitor = setInterval(sample, 100);
let reusableWeb, reusableOptions;
const open = async (options = {}) => {
const signature = JSON.stringify(options);
if ((options.fresh || options.world || options.plan || signature !== reusableOptions) && reusableWeb) { await reusableWeb.close(); reusableWeb = null; }
const reuse = host === 'web' && !options.world && !options.plan ? reusableWeb : null;
reusableWeb = null;
if (reuse) say('CARRIER reused web process; fresh document');
const raw = await openSession({host, device, phone, app:name, size:[1280,720], webDist:dist, onProcess, reuse, ...options,
env:{EXACT_GAME_PARANOID:process.env.EXACT_GAME_PARANOID ?? '0', ...options.env}});
sample();
let closed = false;
const close = async () => { if (!closed) {
try {
const journal = (await raw.logs({all:true})).world ?? [];
const state = await raw.state();
modes.observe(state, id);
try { provenance.observe(state, journal, id); provenance.assert(); } catch (error) { check(error.message, false); }
if (compareParanoid || process.env.EXACT_PROOF_COMPARE === '1') {
const state = await raw.op({op:'state', ...await raw.target('world'), world:true});
const world = state.world;
const logs = journal;
finalWorlds.push({session:id, tick:world?.tick, hash:world?.hash,
published:world?.published,
// Native hosts own incremental journal cursors, even for since:0.
// Include the chunks this script already read as well as the tail.
// A look frame's tell lines are presentation: out of the comparison.
journal:worldJournal([...replies.filter(r => r.session === id && r.method === 'logs')
.flatMap(r => r.reply?.world ?? []), ...logs])});
if (!world?.hash) throw new Error('paranoid comparison: final world hash missing');
}
if (looking) await lookStep(raw, id);
sample();
} finally {