@series-inc/rundot-syncplay 5.23.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +610 -0
- package/dist/animation.d.ts +119 -0
- package/dist/animation.js +488 -0
- package/dist/asset-cooking.d.ts +82 -0
- package/dist/asset-cooking.js +364 -0
- package/dist/authority-room.d.ts +217 -0
- package/dist/authority-room.js +701 -0
- package/dist/bot-documents.d.ts +115 -0
- package/dist/bot-documents.js +549 -0
- package/dist/bot-primitives.d.ts +152 -0
- package/dist/bot-primitives.js +472 -0
- package/dist/browser.d.ts +57 -0
- package/dist/browser.js +53 -0
- package/dist/canonical.d.ts +12 -0
- package/dist/canonical.js +288 -0
- package/dist/certification.d.ts +455 -0
- package/dist/certification.js +1855 -0
- package/dist/chaos-certification.d.ts +56 -0
- package/dist/chaos-certification.js +1397 -0
- package/dist/cjs/animation.js +495 -0
- package/dist/cjs/asset-cooking.js +372 -0
- package/dist/cjs/authority-room.js +704 -0
- package/dist/cjs/bot-documents.js +556 -0
- package/dist/cjs/bot-primitives.js +482 -0
- package/dist/cjs/browser.js +96 -0
- package/dist/cjs/canonical.js +299 -0
- package/dist/cjs/certification.js +1860 -0
- package/dist/cjs/chaos-certification.js +1402 -0
- package/dist/cjs/collections.js +36 -0
- package/dist/cjs/collision.js +96 -0
- package/dist/cjs/commands.js +987 -0
- package/dist/cjs/config-bundle.js +491 -0
- package/dist/cjs/creator.js +30 -0
- package/dist/cjs/deterministic-primitives.js +134 -0
- package/dist/cjs/deterministic-session.js +1410 -0
- package/dist/cjs/ecs-lite.js +155 -0
- package/dist/cjs/effects-adapter.js +97 -0
- package/dist/cjs/engine-complete.js +2297 -0
- package/dist/cjs/engine-core-proof.js +70 -0
- package/dist/cjs/engine-parity-matrix.js +874 -0
- package/dist/cjs/engine-parity.js +3183 -0
- package/dist/cjs/engine-runtime.js +3141 -0
- package/dist/cjs/errors.js +22 -0
- package/dist/cjs/events.js +247 -0
- package/dist/cjs/examples/counter-duel.js +157 -0
- package/dist/cjs/examples/fixed-arena.js +368 -0
- package/dist/cjs/index.js +229 -0
- package/dist/cjs/input-authority.js +150 -0
- package/dist/cjs/input-button.js +256 -0
- package/dist/cjs/input-timeline.js +418 -0
- package/dist/cjs/instant-replay.js +66 -0
- package/dist/cjs/lag-compensation3d.js +837 -0
- package/dist/cjs/local-authority-server-runner.js +1121 -0
- package/dist/cjs/local-authority-wire.js +1497 -0
- package/dist/cjs/local-authority.js +817 -0
- package/dist/cjs/local-ws-adapter.js +150 -0
- package/dist/cjs/match-log.js +61 -0
- package/dist/cjs/math.js +157 -0
- package/dist/cjs/mock-network-adapter.js +26 -0
- package/dist/cjs/movement.js +703 -0
- package/dist/cjs/movement3d.js +1246 -0
- package/dist/cjs/multiclient-authority.js +308 -0
- package/dist/cjs/multiplayer-service.js +434 -0
- package/dist/cjs/navigation.js +347 -0
- package/dist/cjs/navmesh.js +1437 -0
- package/dist/cjs/networked-client.js +691 -0
- package/dist/cjs/networked-input-authority.js +443 -0
- package/dist/cjs/node.js +47 -0
- package/dist/cjs/number-lab.js +78 -0
- package/dist/cjs/offline-session.js +370 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pause-resume.js +43 -0
- package/dist/cjs/physics-cert.js +426 -0
- package/dist/cjs/physics2d.js +751 -0
- package/dist/cjs/physics3d.js +1996 -0
- package/dist/cjs/prediction-culling.js +203 -0
- package/dist/cjs/protocol-lab.js +1299 -0
- package/dist/cjs/r3f-render-adapter.js +209 -0
- package/dist/cjs/random.js +59 -0
- package/dist/cjs/replay-bundle.js +1598 -0
- package/dist/cjs/replay-inspect.js +236 -0
- package/dist/cjs/replay-scoring.js +68 -0
- package/dist/cjs/replay.js +356 -0
- package/dist/cjs/reports.js +181 -0
- package/dist/cjs/rollback-history.js +251 -0
- package/dist/cjs/runtime-guards.js +111 -0
- package/dist/cjs/sample-lab.js +145 -0
- package/dist/cjs/sample-scenes.js +752 -0
- package/dist/cjs/schema-artifacts.js +359 -0
- package/dist/cjs/schema-codegen.js +544 -0
- package/dist/cjs/schema-dsl-binary.js +655 -0
- package/dist/cjs/schema.js +319 -0
- package/dist/cjs/sdk-offline.js +99 -0
- package/dist/cjs/session-promotion.js +74 -0
- package/dist/cjs/session-snapshot.js +265 -0
- package/dist/cjs/session-wire.js +216 -0
- package/dist/cjs/signals.js +103 -0
- package/dist/cjs/sparse-set.js +169 -0
- package/dist/cjs/synctest.js +136 -0
- package/dist/cjs/system-lifecycle.js +286 -0
- package/dist/cjs/timers.js +61 -0
- package/dist/cjs/tools/static-checker.js +1468 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/venus-mp-budget.js +125 -0
- package/dist/cjs/ws-frame.js +255 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11379 -0
- package/dist/collections.d.ts +9 -0
- package/dist/collections.js +30 -0
- package/dist/collision.d.ts +25 -0
- package/dist/collision.js +85 -0
- package/dist/commands.d.ts +137 -0
- package/dist/commands.js +977 -0
- package/dist/config-bundle.d.ts +89 -0
- package/dist/config-bundle.js +483 -0
- package/dist/creator.d.ts +20 -0
- package/dist/creator.js +11 -0
- package/dist/deterministic-primitives.d.ts +59 -0
- package/dist/deterministic-primitives.js +126 -0
- package/dist/deterministic-session.d.ts +197 -0
- package/dist/deterministic-session.js +1401 -0
- package/dist/ecs-lite.d.ts +26 -0
- package/dist/ecs-lite.js +152 -0
- package/dist/effects-adapter.d.ts +21 -0
- package/dist/effects-adapter.js +94 -0
- package/dist/engine-complete.d.ts +45 -0
- package/dist/engine-complete.js +2292 -0
- package/dist/engine-core-proof.d.ts +26 -0
- package/dist/engine-core-proof.js +66 -0
- package/dist/engine-parity-matrix.d.ts +300 -0
- package/dist/engine-parity-matrix.js +869 -0
- package/dist/engine-parity.d.ts +74 -0
- package/dist/engine-parity.js +3178 -0
- package/dist/engine-runtime.d.ts +351 -0
- package/dist/engine-runtime.js +3123 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +17 -0
- package/dist/events.d.ts +45 -0
- package/dist/events.js +242 -0
- package/dist/examples/counter-duel.d.ts +43 -0
- package/dist/examples/counter-duel.js +150 -0
- package/dist/examples/fixed-arena.d.ts +110 -0
- package/dist/examples/fixed-arena.js +356 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +66 -0
- package/dist/input-authority.d.ts +82 -0
- package/dist/input-authority.js +147 -0
- package/dist/input-button.d.ts +36 -0
- package/dist/input-button.js +249 -0
- package/dist/input-timeline.d.ts +65 -0
- package/dist/input-timeline.js +412 -0
- package/dist/instant-replay.d.ts +59 -0
- package/dist/instant-replay.js +62 -0
- package/dist/lag-compensation3d.d.ts +146 -0
- package/dist/lag-compensation3d.js +827 -0
- package/dist/local-authority-server-runner.d.ts +79 -0
- package/dist/local-authority-server-runner.js +1118 -0
- package/dist/local-authority-wire.d.ts +142 -0
- package/dist/local-authority-wire.js +1477 -0
- package/dist/local-authority.d.ts +160 -0
- package/dist/local-authority.js +810 -0
- package/dist/local-ws-adapter.d.ts +14 -0
- package/dist/local-ws-adapter.js +146 -0
- package/dist/match-log.d.ts +74 -0
- package/dist/match-log.js +57 -0
- package/dist/math.d.ts +41 -0
- package/dist/math.js +154 -0
- package/dist/mock-network-adapter.d.ts +13 -0
- package/dist/mock-network-adapter.js +23 -0
- package/dist/movement.d.ts +155 -0
- package/dist/movement.js +694 -0
- package/dist/movement3d.d.ts +195 -0
- package/dist/movement3d.js +1238 -0
- package/dist/multiclient-authority.d.ts +30 -0
- package/dist/multiclient-authority.js +305 -0
- package/dist/multiplayer-service.d.ts +153 -0
- package/dist/multiplayer-service.js +429 -0
- package/dist/navigation.d.ts +91 -0
- package/dist/navigation.js +338 -0
- package/dist/navmesh.d.ts +236 -0
- package/dist/navmesh.js +1423 -0
- package/dist/networked-client.d.ts +124 -0
- package/dist/networked-client.js +688 -0
- package/dist/networked-input-authority.d.ts +51 -0
- package/dist/networked-input-authority.js +439 -0
- package/dist/node.d.ts +22 -0
- package/dist/node.js +19 -0
- package/dist/number-lab.d.ts +15 -0
- package/dist/number-lab.js +75 -0
- package/dist/offline-session.d.ts +2 -0
- package/dist/offline-session.js +367 -0
- package/dist/pause-resume.d.ts +48 -0
- package/dist/pause-resume.js +40 -0
- package/dist/physics-cert.d.ts +40 -0
- package/dist/physics-cert.js +389 -0
- package/dist/physics2d.d.ts +142 -0
- package/dist/physics2d.js +741 -0
- package/dist/physics3d.d.ts +285 -0
- package/dist/physics3d.js +1979 -0
- package/dist/prediction-culling.d.ts +34 -0
- package/dist/prediction-culling.js +200 -0
- package/dist/protocol-lab.d.ts +115 -0
- package/dist/protocol-lab.js +1295 -0
- package/dist/r3f-render-adapter.d.ts +61 -0
- package/dist/r3f-render-adapter.js +203 -0
- package/dist/random.d.ts +13 -0
- package/dist/random.js +55 -0
- package/dist/replay-bundle.d.ts +150 -0
- package/dist/replay-bundle.js +1586 -0
- package/dist/replay-inspect.d.ts +60 -0
- package/dist/replay-inspect.js +228 -0
- package/dist/replay-scoring.d.ts +28 -0
- package/dist/replay-scoring.js +65 -0
- package/dist/replay.d.ts +17 -0
- package/dist/replay.js +351 -0
- package/dist/reports.d.ts +176 -0
- package/dist/reports.js +174 -0
- package/dist/rollback-history.d.ts +46 -0
- package/dist/rollback-history.js +247 -0
- package/dist/runtime-guards.d.ts +1 -0
- package/dist/runtime-guards.js +108 -0
- package/dist/sample-lab.d.ts +32 -0
- package/dist/sample-lab.js +134 -0
- package/dist/sample-scenes.d.ts +86 -0
- package/dist/sample-scenes.js +745 -0
- package/dist/schema-artifacts.d.ts +49 -0
- package/dist/schema-artifacts.js +356 -0
- package/dist/schema-codegen.d.ts +21 -0
- package/dist/schema-codegen.js +538 -0
- package/dist/schema-dsl-binary.d.ts +75 -0
- package/dist/schema-dsl-binary.js +648 -0
- package/dist/schema.d.ts +87 -0
- package/dist/schema.js +315 -0
- package/dist/sdk-offline.d.ts +34 -0
- package/dist/sdk-offline.js +96 -0
- package/dist/session-promotion.d.ts +26 -0
- package/dist/session-promotion.js +70 -0
- package/dist/session-snapshot.d.ts +95 -0
- package/dist/session-snapshot.js +255 -0
- package/dist/session-wire.d.ts +89 -0
- package/dist/session-wire.js +211 -0
- package/dist/signals.d.ts +31 -0
- package/dist/signals.js +99 -0
- package/dist/sparse-set.d.ts +34 -0
- package/dist/sparse-set.js +165 -0
- package/dist/synctest.d.ts +31 -0
- package/dist/synctest.js +133 -0
- package/dist/system-lifecycle.d.ts +72 -0
- package/dist/system-lifecycle.js +281 -0
- package/dist/timers.d.ts +17 -0
- package/dist/timers.js +53 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/static-checker.d.ts +31 -0
- package/dist/tools/static-checker.js +1462 -0
- package/dist/tools/vite-plugin.d.ts +69 -0
- package/dist/tools/vite-plugin.js +203 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.js +1 -0
- package/dist/venus-mp-budget.d.ts +29 -0
- package/dist/venus-mp-budget.js +122 -0
- package/dist/ws-frame.d.ts +49 -0
- package/dist/ws-frame.js +247 -0
- package/package.json +169 -0
|
@@ -0,0 +1,1586 @@
|
|
|
1
|
+
import { canonicalStringify, cloneCanonical, defaultChecksum } from './canonical.js';
|
|
2
|
+
import { createDeterministicFrameRuntime, createDeterministicSparseSetFrameRuntime, } from './engine-runtime.js';
|
|
3
|
+
export function createDeterministicReplayBundle(bundle) {
|
|
4
|
+
assertReplayBundleIdentity(bundle);
|
|
5
|
+
assertReplayRuntimeManifest(bundle.runtimeReplay);
|
|
6
|
+
const semanticReplayHash = bundle.semanticReplay === undefined ? bundle.semanticReplayHash : defaultChecksum(bundle.semanticReplay);
|
|
7
|
+
return cloneCanonical({
|
|
8
|
+
schemaVersion: 1,
|
|
9
|
+
...bundle,
|
|
10
|
+
...(semanticReplayHash === undefined ? {} : { semanticReplayHash }),
|
|
11
|
+
inputHistory: [...bundle.inputHistory].sort((left, right) => left.frame - right.frame),
|
|
12
|
+
commandHistory: [...bundle.commandHistory].sort((left, right) => left.frame - right.frame),
|
|
13
|
+
checksums: [...bundle.checksums].sort((left, right) => left.frame - right.frame),
|
|
14
|
+
debugEvents: [...bundle.debugEvents].sort(compareDebugEvents),
|
|
15
|
+
snapshots: [...bundle.snapshots].sort((left, right) => left.frame - right.frame),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function createDeterministicReplayRuntimeManifest(source) {
|
|
19
|
+
const manifest = {
|
|
20
|
+
descriptor: source.descriptor,
|
|
21
|
+
descriptorHash: source.descriptorHash ?? defaultChecksum(source.descriptor),
|
|
22
|
+
systemId: source.systemId,
|
|
23
|
+
beforeFrameId: source.beforeFrameId,
|
|
24
|
+
systemsSourceHash: source.systemsSourceHash ?? deterministicReplayRuntimeSourceHash(source.systemId, source.beforeFrameId),
|
|
25
|
+
...(source.acceptedSystemsSourceHashes === undefined ? {} : {
|
|
26
|
+
acceptedSystemsSourceHashes: [...source.acceptedSystemsSourceHashes].sort(compareStrings),
|
|
27
|
+
}),
|
|
28
|
+
seed: source.seed ?? 1,
|
|
29
|
+
...(source.sparse === undefined ? {} : { sparse: source.sparse }),
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
...manifest,
|
|
33
|
+
manifestHash: deterministicReplayRuntimeManifestHash(manifest),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function verifyDeterministicReplayBundle(bundle, expected) {
|
|
37
|
+
const bundleHash = defaultChecksum(bundle);
|
|
38
|
+
const bundleRecord = bundle !== null && typeof bundle === 'object' && !Array.isArray(bundle)
|
|
39
|
+
? bundle
|
|
40
|
+
: {};
|
|
41
|
+
const inputHistory = Array.isArray(bundleRecord.inputHistory) ? bundleRecord.inputHistory : [];
|
|
42
|
+
const commandHistory = Array.isArray(bundleRecord.commandHistory) ? bundleRecord.commandHistory : [];
|
|
43
|
+
const checksums = Array.isArray(bundleRecord.checksums) ? bundleRecord.checksums : [];
|
|
44
|
+
const debugEvents = Array.isArray(bundleRecord.debugEvents) ? bundleRecord.debugEvents : [];
|
|
45
|
+
const snapshots = Array.isArray(bundleRecord.snapshots) ? bundleRecord.snapshots : [];
|
|
46
|
+
const snapshotBytes = snapshots.reduce((sum, snapshot) => sum + snapshot.bytes.length, 0);
|
|
47
|
+
const inputCount = inputHistory.reduce((sum, inputFrame) => sum + inputFrame.inputs.length, 0);
|
|
48
|
+
const commandCount = commandHistory.reduce((sum, commandFrame) => sum + commandFrame.commands.length, 0);
|
|
49
|
+
const base = {
|
|
50
|
+
bundleHash,
|
|
51
|
+
replayBytes: canonicalStringify(bundle).length,
|
|
52
|
+
snapshotBytes,
|
|
53
|
+
inputCount,
|
|
54
|
+
checksumCount: checksums.length,
|
|
55
|
+
commandCount,
|
|
56
|
+
debugEventCount: debugEvents.length,
|
|
57
|
+
semanticReplayExecuted: false,
|
|
58
|
+
semanticReplayFrames: 0,
|
|
59
|
+
};
|
|
60
|
+
const shapeMismatch = findReplayBundleShapeMismatch(bundle);
|
|
61
|
+
if (shapeMismatch !== undefined) {
|
|
62
|
+
return { ...base, result: 'fail', firstMismatch: shapeMismatch };
|
|
63
|
+
}
|
|
64
|
+
if (bundle.schemaVersion !== 1) {
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
result: 'fail',
|
|
68
|
+
firstMismatch: {
|
|
69
|
+
frame: 0,
|
|
70
|
+
subsystem: 'replay-bundle',
|
|
71
|
+
path: 'schemaVersion',
|
|
72
|
+
expectedChecksum: '1',
|
|
73
|
+
actualChecksum: String(bundle.schemaVersion),
|
|
74
|
+
minimalReproPath: 'artifacts/replays/replay-min-schema-version.json',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const identityMismatch = findIdentityMismatch(bundle, expected);
|
|
79
|
+
if (identityMismatch !== undefined) {
|
|
80
|
+
return { ...base, result: 'fail', firstMismatch: identityMismatch };
|
|
81
|
+
}
|
|
82
|
+
const missingSection = findMissingRequiredSection(bundle, inputCount, commandCount);
|
|
83
|
+
if (missingSection !== undefined) {
|
|
84
|
+
return { ...base, result: 'fail', firstMismatch: missingSection };
|
|
85
|
+
}
|
|
86
|
+
const duplicateFrame = findDuplicateFrameRecord(bundle);
|
|
87
|
+
if (duplicateFrame !== undefined) {
|
|
88
|
+
return { ...base, result: 'fail', firstMismatch: duplicateFrame };
|
|
89
|
+
}
|
|
90
|
+
const invalidSlice = findInvalidReplaySlice(bundle);
|
|
91
|
+
if (invalidSlice !== undefined) {
|
|
92
|
+
return { ...base, result: 'fail', firstMismatch: invalidSlice };
|
|
93
|
+
}
|
|
94
|
+
const nonContiguousFullHistory = findNonContiguousFullHistory(bundle);
|
|
95
|
+
if (nonContiguousFullHistory !== undefined) {
|
|
96
|
+
return { ...base, result: 'fail', firstMismatch: nonContiguousFullHistory };
|
|
97
|
+
}
|
|
98
|
+
const checksumByFrame = new Map(bundle.checksums.map((checksum) => [checksum.frame, checksum.checksum]));
|
|
99
|
+
const snapshotByFrame = new Map(bundle.snapshots.map((snapshot) => [snapshot.frame, snapshot]));
|
|
100
|
+
for (const checksum of bundle.checksums) {
|
|
101
|
+
if (!snapshotByFrame.has(checksum.frame)) {
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
result: 'fail',
|
|
105
|
+
firstMismatch: {
|
|
106
|
+
frame: checksum.frame,
|
|
107
|
+
subsystem: 'replay-bundle',
|
|
108
|
+
path: `checksums[frame=${checksum.frame}]`,
|
|
109
|
+
expectedChecksum: checksum.checksum,
|
|
110
|
+
actualChecksum: 'missing-snapshot',
|
|
111
|
+
minimalReproPath: `artifacts/replays/replay-min-${checksum.frame}.json`,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const snapshot of bundle.snapshots) {
|
|
117
|
+
const parsedSnapshot = parseSnapshotBytes(snapshot);
|
|
118
|
+
if (parsedSnapshot === undefined || parsedSnapshot.frame !== snapshot.frame) {
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
result: 'fail',
|
|
122
|
+
firstMismatch: {
|
|
123
|
+
frame: snapshot.frame,
|
|
124
|
+
subsystem: 'snapshot',
|
|
125
|
+
path: `snapshots[frame=${snapshot.frame}].bytes.frame`,
|
|
126
|
+
expectedChecksum: String(snapshot.frame),
|
|
127
|
+
actualChecksum: parsedSnapshot === undefined ? 'unparseable' : String(parsedSnapshot.frame),
|
|
128
|
+
minimalReproPath: `artifacts/replays/replay-min-${snapshot.frame}.json`,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const actualSnapshotChecksum = defaultChecksum(snapshot.bytes);
|
|
133
|
+
if (actualSnapshotChecksum !== snapshot.checksum) {
|
|
134
|
+
return {
|
|
135
|
+
...base,
|
|
136
|
+
result: 'fail',
|
|
137
|
+
firstMismatch: {
|
|
138
|
+
frame: snapshot.frame,
|
|
139
|
+
subsystem: 'snapshot',
|
|
140
|
+
path: `snapshots[frame=${snapshot.frame}].bytes`,
|
|
141
|
+
expectedChecksum: snapshot.checksum,
|
|
142
|
+
actualChecksum: actualSnapshotChecksum,
|
|
143
|
+
minimalReproPath: `artifacts/replays/replay-min-${snapshot.frame}.json`,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const expectedChecksum = checksumByFrame.get(snapshot.frame);
|
|
148
|
+
if (expectedChecksum === undefined) {
|
|
149
|
+
return {
|
|
150
|
+
...base,
|
|
151
|
+
result: 'fail',
|
|
152
|
+
firstMismatch: {
|
|
153
|
+
frame: snapshot.frame,
|
|
154
|
+
subsystem: 'replay-bundle',
|
|
155
|
+
path: `snapshots[frame=${snapshot.frame}]`,
|
|
156
|
+
expectedChecksum: 'checksum-present',
|
|
157
|
+
actualChecksum: 'missing',
|
|
158
|
+
minimalReproPath: `artifacts/replays/replay-min-${snapshot.frame}.json`,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (expectedChecksum !== snapshot.checksum) {
|
|
163
|
+
return {
|
|
164
|
+
...base,
|
|
165
|
+
result: 'fail',
|
|
166
|
+
firstMismatch: {
|
|
167
|
+
frame: snapshot.frame,
|
|
168
|
+
subsystem: 'snapshot',
|
|
169
|
+
path: `snapshots[frame=${snapshot.frame}].checksum`,
|
|
170
|
+
expectedChecksum,
|
|
171
|
+
actualChecksum: snapshot.checksum,
|
|
172
|
+
minimalReproPath: `artifacts/replays/replay-min-${snapshot.frame}.json`,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const semantic = verifySemanticReplay(bundle, expected);
|
|
178
|
+
if (semantic.mismatch !== undefined) {
|
|
179
|
+
return {
|
|
180
|
+
...base,
|
|
181
|
+
semanticReplayExecuted: semantic.executed,
|
|
182
|
+
semanticReplayFrames: semantic.frames,
|
|
183
|
+
result: 'fail',
|
|
184
|
+
firstMismatch: semantic.mismatch,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (semantic.required === true && semantic.executed === false) {
|
|
188
|
+
return {
|
|
189
|
+
...base,
|
|
190
|
+
semanticReplayExecuted: false,
|
|
191
|
+
semanticReplayFrames: 0,
|
|
192
|
+
result: 'fail',
|
|
193
|
+
firstMismatch: {
|
|
194
|
+
frame: bundle.snapshots[0].frame,
|
|
195
|
+
subsystem: 'semantic-replay',
|
|
196
|
+
path: 'snapshots',
|
|
197
|
+
expectedChecksum: 'semantic-replayable-snapshot',
|
|
198
|
+
actualChecksum: 'missing-positions',
|
|
199
|
+
minimalReproPath: 'artifacts/replays/replay-min-semantic.json',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return { ...base, semanticReplayExecuted: semantic.executed, semanticReplayFrames: semantic.frames, result: 'pass' };
|
|
204
|
+
}
|
|
205
|
+
function findReplayBundleShapeMismatch(bundle) {
|
|
206
|
+
if (bundle === null || typeof bundle !== 'object' || Array.isArray(bundle)) {
|
|
207
|
+
return replayShapeMismatch('', 'object', typeof bundle);
|
|
208
|
+
}
|
|
209
|
+
const fields = [
|
|
210
|
+
{ path: 'schemaVersion', expected: 'number' },
|
|
211
|
+
{ path: 'runtimeVersion', expected: 'string' },
|
|
212
|
+
{ path: 'engineHash', expected: 'string' },
|
|
213
|
+
{ path: 'schemaHash', expected: 'string' },
|
|
214
|
+
{ path: 'assetDbHash', expected: 'string' },
|
|
215
|
+
{ path: 'configHash', expected: 'string' },
|
|
216
|
+
{ path: 'sampleId', expected: 'string' },
|
|
217
|
+
{ path: 'inputHistory', expected: 'array' },
|
|
218
|
+
{ path: 'commandHistory', expected: 'array' },
|
|
219
|
+
{ path: 'checksums', expected: 'array' },
|
|
220
|
+
{ path: 'debugEvents', expected: 'array' },
|
|
221
|
+
{ path: 'snapshots', expected: 'array' },
|
|
222
|
+
{ path: 'metadata', expected: 'object' },
|
|
223
|
+
];
|
|
224
|
+
for (const field of fields) {
|
|
225
|
+
const value = bundle[field.path];
|
|
226
|
+
if (field.expected === 'array') {
|
|
227
|
+
if (!Array.isArray(value)) {
|
|
228
|
+
return replayShapeMismatch(String(field.path), field.expected, replayShapeOf(value));
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (field.expected === 'object') {
|
|
233
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
234
|
+
return replayShapeMismatch(String(field.path), field.expected, replayShapeOf(value));
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (typeof value !== field.expected) {
|
|
239
|
+
return replayShapeMismatch(String(field.path), field.expected, replayShapeOf(value));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
function replayShapeMismatch(path, expected, actual) {
|
|
245
|
+
return {
|
|
246
|
+
frame: 0,
|
|
247
|
+
subsystem: 'replay-bundle',
|
|
248
|
+
path,
|
|
249
|
+
expectedChecksum: expected,
|
|
250
|
+
actualChecksum: actual,
|
|
251
|
+
minimalReproPath: 'artifacts/replays/replay-min-shape.json',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function replayShapeOf(value) {
|
|
255
|
+
if (Array.isArray(value)) {
|
|
256
|
+
return 'array';
|
|
257
|
+
}
|
|
258
|
+
if (value === null) {
|
|
259
|
+
return 'null';
|
|
260
|
+
}
|
|
261
|
+
return typeof value;
|
|
262
|
+
}
|
|
263
|
+
export function verifyDeterministicFrameRuntimeReplayBundle(bundle, options) {
|
|
264
|
+
const bundleHash = defaultChecksum(bundle);
|
|
265
|
+
const snapshots = [...bundle.snapshots].sort((left, right) => left.frame - right.frame);
|
|
266
|
+
const baseline = snapshots[0];
|
|
267
|
+
const finalFrame = snapshots.at(-1)?.frame ?? -1;
|
|
268
|
+
const base = {
|
|
269
|
+
bundleHash,
|
|
270
|
+
frames: 0,
|
|
271
|
+
baselineFrame: baseline?.frame ?? -1,
|
|
272
|
+
finalFrame,
|
|
273
|
+
finalChecksum: '',
|
|
274
|
+
};
|
|
275
|
+
const manifestMismatch = findRuntimeManifestIdentityMismatch(bundle.runtimeReplay, options);
|
|
276
|
+
if (manifestMismatch !== undefined) {
|
|
277
|
+
return {
|
|
278
|
+
...base,
|
|
279
|
+
result: 'fail',
|
|
280
|
+
firstMismatch: manifestMismatch,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (baseline === undefined) {
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
result: 'fail',
|
|
287
|
+
firstMismatch: runtimeReplayMismatch(0, 'snapshots', 'baseline-snapshot', 'missing'),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const runtime = options.sparse === true
|
|
291
|
+
? createDeterministicSparseSetFrameRuntime(options.descriptor, options.seed ?? 1)
|
|
292
|
+
: createDeterministicFrameRuntime(options.descriptor, options.seed ?? 1);
|
|
293
|
+
const baselineSnapshot = frameSnapshotFromReplaySnapshot(baseline);
|
|
294
|
+
if (baselineSnapshot === undefined) {
|
|
295
|
+
return {
|
|
296
|
+
...base,
|
|
297
|
+
result: 'fail',
|
|
298
|
+
firstMismatch: runtimeReplayMismatch(baseline.frame, `snapshots[frame=${baseline.frame}].bytes`, 'frame-runtime-snapshot', 'unparseable'),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
runtime.restore(baselineSnapshot);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
return {
|
|
306
|
+
...base,
|
|
307
|
+
result: 'fail',
|
|
308
|
+
firstMismatch: runtimeReplayMismatch(baseline.frame, `snapshots[frame=${baseline.frame}].restore`, 'restorable-frame-runtime-snapshot', String(error)),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const snapshotByFrame = new Map(snapshots.map((snapshot) => [snapshot.frame, snapshot]));
|
|
312
|
+
const inputByFrame = new Map(bundle.inputHistory.map((frame) => [frame.frame, frame.inputs]));
|
|
313
|
+
const commandByFrame = new Map(bundle.commandHistory.map((frame) => [frame.frame, frame.commands]));
|
|
314
|
+
let comparedFrames = 0;
|
|
315
|
+
const baselineMismatch = compareRuntimeSnapshot(runtime.snapshot(), baseline);
|
|
316
|
+
/* istanbul ignore next -- runtime.restore validates the snapshot bytes/checksum before this post-restore parity guard. */
|
|
317
|
+
if (baselineMismatch !== undefined) {
|
|
318
|
+
return {
|
|
319
|
+
...base,
|
|
320
|
+
result: 'fail',
|
|
321
|
+
firstMismatch: baselineMismatch,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
comparedFrames += 1;
|
|
325
|
+
for (let frame = baseline.frame; frame < finalFrame; frame += 1) {
|
|
326
|
+
options.beforeFrame?.(runtime, frame, inputByFrame.get(frame) ?? [], commandByFrame.get(frame) ?? []);
|
|
327
|
+
runtime.step(options.systems);
|
|
328
|
+
const expectedSnapshot = snapshotByFrame.get(runtime.frame);
|
|
329
|
+
if (expectedSnapshot === undefined) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
comparedFrames += 1;
|
|
333
|
+
const mismatch = compareRuntimeSnapshot(runtime.snapshot(), expectedSnapshot);
|
|
334
|
+
if (mismatch !== undefined) {
|
|
335
|
+
return {
|
|
336
|
+
...base,
|
|
337
|
+
frames: comparedFrames,
|
|
338
|
+
finalChecksum: runtime.snapshot().checksum,
|
|
339
|
+
result: 'fail',
|
|
340
|
+
firstMismatch: mismatch,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
...base,
|
|
346
|
+
frames: comparedFrames,
|
|
347
|
+
finalChecksum: runtime.snapshot().checksum,
|
|
348
|
+
result: 'pass',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
export function verifyDeterministicSelfContainedFrameRuntimeReplayBundle(bundle, options = {}) {
|
|
352
|
+
const bundleHash = defaultChecksum(bundle);
|
|
353
|
+
const snapshots = [...bundle.snapshots].sort((left, right) => left.frame - right.frame);
|
|
354
|
+
const baseline = snapshots[0];
|
|
355
|
+
const base = {
|
|
356
|
+
bundleHash,
|
|
357
|
+
frames: 0,
|
|
358
|
+
baselineFrame: baseline?.frame ?? -1,
|
|
359
|
+
finalFrame: snapshots.at(-1)?.frame ?? -1,
|
|
360
|
+
finalChecksum: '',
|
|
361
|
+
};
|
|
362
|
+
const manifest = bundle.runtimeReplay;
|
|
363
|
+
if (manifest === undefined) {
|
|
364
|
+
return {
|
|
365
|
+
...base,
|
|
366
|
+
result: 'fail',
|
|
367
|
+
firstMismatch: runtimeReplayMismatch(0, 'runtimeReplay', 'embedded-runtime-manifest', 'missing'),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const expectedManifestHash = deterministicReplayRuntimeManifestHash(manifest);
|
|
371
|
+
if (manifest.manifestHash !== expectedManifestHash) {
|
|
372
|
+
return {
|
|
373
|
+
...base,
|
|
374
|
+
result: 'fail',
|
|
375
|
+
firstMismatch: runtimeReplayMismatch(0, 'runtimeReplay.manifestHash', expectedManifestHash, manifest.manifestHash),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return verifyDeterministicFrameRuntimeReplayBundle(bundle, {
|
|
379
|
+
descriptor: manifest.descriptor,
|
|
380
|
+
systems: deterministicReplayRuntimeSystems(manifest.systemId),
|
|
381
|
+
descriptorHash: manifest.descriptorHash,
|
|
382
|
+
systemsSourceHash: deterministicReplayRuntimeSourceHash(manifest.systemId, manifest.beforeFrameId),
|
|
383
|
+
requireRuntimeIdentity: true,
|
|
384
|
+
seed: manifest.seed,
|
|
385
|
+
sparse: options.sparse ?? manifest.sparse,
|
|
386
|
+
beforeFrame: deterministicReplayRuntimeBeforeFrame(manifest.beforeFrameId),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function findDuplicateFrameRecord(bundle) {
|
|
390
|
+
return findDuplicateFrame(bundle.inputHistory.map((frame) => frame.frame), 'inputHistory')
|
|
391
|
+
?? findDuplicateFrame(bundle.commandHistory.map((frame) => frame.frame), 'commandHistory')
|
|
392
|
+
?? findDuplicateFrame(bundle.checksums.map((checksum) => checksum.frame), 'checksums')
|
|
393
|
+
?? findDuplicateFrame(bundle.snapshots.map((snapshot) => snapshot.frame), 'snapshots')
|
|
394
|
+
?? findDuplicateFrame(bundle.debugEvents.map((event) => `${event.type}:${event.frame}`), 'debugEvents');
|
|
395
|
+
}
|
|
396
|
+
function frameSnapshotFromReplaySnapshot(snapshot) {
|
|
397
|
+
const parsed = parseSnapshotBytes(snapshot);
|
|
398
|
+
if (!isCanonicalRecord(parsed)) {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
const record = parsed;
|
|
402
|
+
if (typeof record.frame !== 'number' ||
|
|
403
|
+
typeof record.nextEntityId !== 'number' ||
|
|
404
|
+
!Array.isArray(record.freeEntityIds) ||
|
|
405
|
+
!isCanonicalRecord(record.generations) ||
|
|
406
|
+
!isCanonicalRecord(record.components) ||
|
|
407
|
+
!isCanonicalRecord(record.singletonComponents) ||
|
|
408
|
+
!Array.isArray(record.debugEvents) ||
|
|
409
|
+
typeof record.randomState !== 'number') {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
const frameHeap = isCanonicalRecord(record.frameHeap)
|
|
413
|
+
? record.frameHeap
|
|
414
|
+
: { nextHandleId: 1, freeHandleIds: [], generations: {}, collections: {} };
|
|
415
|
+
return {
|
|
416
|
+
frame: record.frame,
|
|
417
|
+
nextEntityId: record.nextEntityId,
|
|
418
|
+
freeEntityIds: record.freeEntityIds.filter((value) => typeof value === 'number'),
|
|
419
|
+
generations: record.generations,
|
|
420
|
+
components: record.components,
|
|
421
|
+
singletonComponents: record.singletonComponents,
|
|
422
|
+
frameHeap,
|
|
423
|
+
debugEvents: record.debugEvents,
|
|
424
|
+
runtimeEvents: Array.isArray(record.runtimeEvents)
|
|
425
|
+
? record.runtimeEvents
|
|
426
|
+
: [],
|
|
427
|
+
runtimeSignals: Array.isArray(record.runtimeSignals)
|
|
428
|
+
? record.runtimeSignals
|
|
429
|
+
: [],
|
|
430
|
+
dispatchedSignals: Array.isArray(record.dispatchedSignals)
|
|
431
|
+
? record.dispatchedSignals
|
|
432
|
+
: [],
|
|
433
|
+
nextRuntimeMessageSequence: typeof record.nextRuntimeMessageSequence === 'number'
|
|
434
|
+
? record.nextRuntimeMessageSequence
|
|
435
|
+
: 0,
|
|
436
|
+
...(isCanonicalRecord(record.commandTimeline) ? { commandTimeline: record.commandTimeline } : {}),
|
|
437
|
+
randomState: record.randomState,
|
|
438
|
+
bytes: snapshot.bytes,
|
|
439
|
+
checksum: snapshot.checksum,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function compareRuntimeSnapshot(actual, expected) {
|
|
443
|
+
if (actual.bytes === expected.bytes && actual.checksum === expected.checksum) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
return runtimeReplayMismatch(expected.frame, `snapshots[frame=${expected.frame}].frame-runtime`, expected.checksum, actual.checksum);
|
|
447
|
+
}
|
|
448
|
+
function runtimeReplayMismatch(frame, path, expectedChecksum, actualChecksum) {
|
|
449
|
+
return {
|
|
450
|
+
frame,
|
|
451
|
+
subsystem: 'frame-runtime-replay',
|
|
452
|
+
path,
|
|
453
|
+
expectedChecksum,
|
|
454
|
+
actualChecksum,
|
|
455
|
+
minimalReproPath: `artifacts/replays/frame-runtime-replay-min-${frame}.json`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function findNonContiguousFullHistory(bundle) {
|
|
459
|
+
if (bundle.metadata.replaySlice === true || bundle.metadata.checkpointBundle === true) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
return findMissingFrame(bundle.inputHistory.map((frame) => frame.frame), 'inputHistory')
|
|
463
|
+
?? findMissingFrame(bundle.checksums.map((checksum) => checksum.frame), 'checksums')
|
|
464
|
+
?? findMissingFrame(bundle.snapshots.map((snapshot) => snapshot.frame), 'snapshots');
|
|
465
|
+
}
|
|
466
|
+
function findInvalidReplaySlice(bundle) {
|
|
467
|
+
if (bundle.metadata.replaySlice !== true) {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
const origin = bundle.sliceOrigin;
|
|
471
|
+
const sliceStartFrame = typeof bundle.metadata.sliceStartFrame === 'number' ? bundle.metadata.sliceStartFrame : undefined;
|
|
472
|
+
const sliceEndFrame = typeof bundle.metadata.sliceEndFrame === 'number' ? bundle.metadata.sliceEndFrame : undefined;
|
|
473
|
+
const baselineFrame = typeof bundle.metadata.baselineFrame === 'number' ? bundle.metadata.baselineFrame : undefined;
|
|
474
|
+
const baselineChecksum = typeof bundle.metadata.baselineChecksum === 'string' ? bundle.metadata.baselineChecksum : undefined;
|
|
475
|
+
const sourceBundleHash = typeof bundle.metadata.sourceBundleHash === 'string' ? bundle.metadata.sourceBundleHash : undefined;
|
|
476
|
+
if (origin === undefined) {
|
|
477
|
+
return replaySliceMismatch(0, 'sliceOrigin', 'self-contained-slice-origin', 'missing');
|
|
478
|
+
}
|
|
479
|
+
if (sliceStartFrame !== origin.sliceStartFrame || sliceEndFrame !== origin.sliceEndFrame) {
|
|
480
|
+
return replaySliceMismatch(origin.sliceStartFrame, 'metadata.sliceStartFrame/sliceEndFrame', `${origin.sliceStartFrame}-${origin.sliceEndFrame}`, `${String(sliceStartFrame)}-${String(sliceEndFrame)}`);
|
|
481
|
+
}
|
|
482
|
+
if (sourceBundleHash !== origin.sourceBundleHash) {
|
|
483
|
+
return replaySliceMismatch(origin.sliceStartFrame, 'metadata.sourceBundleHash', origin.sourceBundleHash, sourceBundleHash ?? 'missing');
|
|
484
|
+
}
|
|
485
|
+
if (baselineFrame !== origin.baselineSnapshot.frame || baselineChecksum !== origin.baselineSnapshot.checksum) {
|
|
486
|
+
return replaySliceMismatch(origin.sliceStartFrame, 'metadata.baseline', `${origin.baselineSnapshot.frame}:${origin.baselineSnapshot.checksum}`, `${String(baselineFrame)}:${baselineChecksum ?? 'missing'}`);
|
|
487
|
+
}
|
|
488
|
+
const baselineChecksumActual = defaultChecksum(origin.baselineSnapshot.bytes);
|
|
489
|
+
if (baselineChecksumActual !== origin.baselineSnapshot.checksum) {
|
|
490
|
+
return replaySliceMismatch(origin.baselineSnapshot.frame, 'sliceOrigin.baselineSnapshot.bytes', origin.baselineSnapshot.checksum, baselineChecksumActual);
|
|
491
|
+
}
|
|
492
|
+
const parsedBaseline = parseSnapshotBytes(origin.baselineSnapshot);
|
|
493
|
+
if (parsedBaseline === undefined || parsedBaseline.frame !== origin.baselineSnapshot.frame) {
|
|
494
|
+
return replaySliceMismatch(origin.baselineSnapshot.frame, 'sliceOrigin.baselineSnapshot.bytes.frame', String(origin.baselineSnapshot.frame), parsedBaseline === undefined ? 'unparseable' : String(parsedBaseline.frame));
|
|
495
|
+
}
|
|
496
|
+
const firstChecksumFrame = bundle.checksums[0]?.frame;
|
|
497
|
+
const lastChecksumFrame = bundle.checksums.at(-1)?.frame;
|
|
498
|
+
if (firstChecksumFrame !== origin.sliceStartFrame || lastChecksumFrame !== origin.sliceEndFrame) {
|
|
499
|
+
return replaySliceMismatch(origin.sliceStartFrame, 'checksums.frameRange', `${origin.sliceStartFrame}-${origin.sliceEndFrame}`, `${String(firstChecksumFrame)}-${String(lastChecksumFrame)}`);
|
|
500
|
+
}
|
|
501
|
+
if (origin.baselineSnapshot.frame > origin.sliceStartFrame) {
|
|
502
|
+
return replaySliceMismatch(origin.sliceStartFrame, 'sliceOrigin.baselineSnapshot.frame', `<=${origin.sliceStartFrame}`, String(origin.baselineSnapshot.frame));
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
function replaySliceMismatch(frame, path, expectedChecksum, actualChecksum) {
|
|
507
|
+
return {
|
|
508
|
+
frame,
|
|
509
|
+
subsystem: 'replay-slice',
|
|
510
|
+
path,
|
|
511
|
+
expectedChecksum,
|
|
512
|
+
actualChecksum,
|
|
513
|
+
minimalReproPath: `artifacts/replays/replay-slice-min-${frame}.json`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function findMissingFrame(frames, section) {
|
|
517
|
+
if (frames.length === 0) {
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
const sortedFrames = [...frames].sort((left, right) => left - right);
|
|
521
|
+
const startFrame = sortedFrames[0];
|
|
522
|
+
if (startFrame !== 0) {
|
|
523
|
+
return {
|
|
524
|
+
frame: startFrame,
|
|
525
|
+
subsystem: 'replay-bundle',
|
|
526
|
+
path: section,
|
|
527
|
+
expectedChecksum: 'starts-at-frame-0-or-replaySlice',
|
|
528
|
+
actualChecksum: `starts-at-frame-${startFrame}`,
|
|
529
|
+
minimalReproPath: `artifacts/replays/replay-noncontiguous-${section}.json`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
for (let index = 1; index < sortedFrames.length; index += 1) {
|
|
533
|
+
if (sortedFrames[index] !== sortedFrames[index - 1] + 1) {
|
|
534
|
+
return {
|
|
535
|
+
frame: sortedFrames[index],
|
|
536
|
+
subsystem: 'replay-bundle',
|
|
537
|
+
path: section,
|
|
538
|
+
expectedChecksum: `frame-${sortedFrames[index - 1] + 1}`,
|
|
539
|
+
actualChecksum: `frame-${sortedFrames[index]}`,
|
|
540
|
+
minimalReproPath: `artifacts/replays/replay-noncontiguous-${section}.json`,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
function findDuplicateFrame(frames, section) {
|
|
547
|
+
const seen = new Set();
|
|
548
|
+
for (const frame of frames) {
|
|
549
|
+
if (seen.has(frame)) {
|
|
550
|
+
return {
|
|
551
|
+
frame: typeof frame === 'number' ? frame : Number(String(frame).split(':').at(-1)),
|
|
552
|
+
subsystem: 'replay-bundle',
|
|
553
|
+
path: section,
|
|
554
|
+
expectedChecksum: 'unique-frame-records',
|
|
555
|
+
actualChecksum: `duplicate-${String(frame)}`,
|
|
556
|
+
minimalReproPath: `artifacts/replays/replay-duplicate-${section}.json`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
seen.add(frame);
|
|
560
|
+
}
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
function parseSnapshotBytes(snapshot) {
|
|
564
|
+
try {
|
|
565
|
+
const parsed = JSON.parse(snapshot.bytes);
|
|
566
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function verifySemanticReplay(bundle, expected) {
|
|
573
|
+
if (bundle.semanticReplay !== undefined) {
|
|
574
|
+
const actualSemanticHash = defaultChecksum(bundle.semanticReplay);
|
|
575
|
+
if (bundle.semanticReplayHash !== actualSemanticHash ||
|
|
576
|
+
expected?.semanticReplayHash === undefined ||
|
|
577
|
+
expected.semanticReplayHash !== actualSemanticHash) {
|
|
578
|
+
return {
|
|
579
|
+
required: true,
|
|
580
|
+
executed: false,
|
|
581
|
+
frames: 0,
|
|
582
|
+
mismatch: {
|
|
583
|
+
frame: bundle.snapshots[0]?.frame ?? 0,
|
|
584
|
+
subsystem: 'semantic-replay',
|
|
585
|
+
path: 'semanticReplayHash',
|
|
586
|
+
expectedChecksum: expected?.semanticReplayHash ?? 'trusted-semantic-replay-hash',
|
|
587
|
+
actualChecksum: bundle.semanticReplayHash ?? actualSemanticHash,
|
|
588
|
+
minimalReproPath: 'artifacts/replays/replay-min-semantic-hash.json',
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return verifyOperationModelSemanticReplay(bundle, bundle.semanticReplay);
|
|
593
|
+
}
|
|
594
|
+
const semanticSnapshot = bundle.snapshots
|
|
595
|
+
.map((snapshot) => parseSnapshotBytes(snapshot))
|
|
596
|
+
.find((snapshot) => Array.isArray(snapshot?.positions) || isCanonicalRecord(snapshot?.components));
|
|
597
|
+
const required = true;
|
|
598
|
+
if (semanticSnapshot === undefined) {
|
|
599
|
+
return { required, executed: false, frames: 0 };
|
|
600
|
+
}
|
|
601
|
+
if (isCanonicalRecord(semanticSnapshot.components)) {
|
|
602
|
+
return verifyComponentSemanticReplay(bundle);
|
|
603
|
+
}
|
|
604
|
+
/* istanbul ignore next -- semanticSnapshot reaches this path only through the positions-array predicate; component snapshots return above. */
|
|
605
|
+
const inferredPlayers = Array.isArray(semanticSnapshot.positions) ? semanticSnapshot.positions.length : undefined;
|
|
606
|
+
const players = typeof bundle.metadata.players === 'number' ? bundle.metadata.players : inferredPlayers;
|
|
607
|
+
/* istanbul ignore next -- reaching this branch requires a positions semantic snapshot, so player count is inferred. */
|
|
608
|
+
if (players === undefined) {
|
|
609
|
+
return { required, executed: false, frames: 0 };
|
|
610
|
+
}
|
|
611
|
+
const inputByFrame = new Map(bundle.inputHistory.map((frame) => [frame.frame, frame.inputs]));
|
|
612
|
+
const commandByFrame = new Map(bundle.commandHistory.map((frame) => [frame.frame, frame.commands]));
|
|
613
|
+
const snapshotByFrame = new Map(bundle.snapshots.map((snapshot) => [snapshot.frame, snapshot]));
|
|
614
|
+
const maxFrame = Math.max(...bundle.snapshots.map((snapshot) => snapshot.frame));
|
|
615
|
+
const baselineSnapshot = bundle.sliceOrigin?.baselineSnapshot;
|
|
616
|
+
const parsedBaseline = baselineSnapshot === undefined ? undefined : parseSnapshotBytes(baselineSnapshot);
|
|
617
|
+
const positions = Array.isArray(parsedBaseline?.positions)
|
|
618
|
+
? parsedBaseline.positions.map((value) => typeof value === 'number' ? value : 0)
|
|
619
|
+
: Array.from({ length: players }, () => 0);
|
|
620
|
+
while (positions.length < players) {
|
|
621
|
+
positions.push(0);
|
|
622
|
+
}
|
|
623
|
+
let commandScore = typeof parsedBaseline?.commandScore === 'number' ? parsedBaseline.commandScore : 0;
|
|
624
|
+
let checkedSnapshots = baselineSnapshot !== undefined && snapshotByFrame.has(baselineSnapshot.frame) ? 1 : 0;
|
|
625
|
+
const startFrame = baselineSnapshot === undefined ? 0 : baselineSnapshot.frame + 1;
|
|
626
|
+
for (let frame = startFrame; frame <= maxFrame; frame += 1) {
|
|
627
|
+
for (const input of inputByFrame.get(frame) ?? []) {
|
|
628
|
+
if (!isCanonicalRecord(input)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const slot = canonicalNumber(input, 'slot');
|
|
632
|
+
if (slot === undefined || slot < 0 || slot >= players) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
positions[slot] += (canonicalNumber(input, 'dx') ?? 0) + (canonicalNumber(input, 'dy') ?? 0);
|
|
636
|
+
}
|
|
637
|
+
for (const command of commandByFrame.get(frame) ?? []) {
|
|
638
|
+
if (!isCanonicalRecord(command)) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const payload = isCanonicalRecord(command.payload)
|
|
642
|
+
? command.payload
|
|
643
|
+
: {};
|
|
644
|
+
commandScore += canonicalNumber(payload, 'power') ?? 0;
|
|
645
|
+
}
|
|
646
|
+
const snapshot = snapshotByFrame.get(frame);
|
|
647
|
+
if (snapshot === undefined) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
checkedSnapshots += 1;
|
|
651
|
+
const expected = canonicalStringify({ frame, positions: [...positions], commandScore });
|
|
652
|
+
if (snapshot.bytes !== expected) {
|
|
653
|
+
return {
|
|
654
|
+
required: true,
|
|
655
|
+
executed: true,
|
|
656
|
+
frames: checkedSnapshots,
|
|
657
|
+
mismatch: {
|
|
658
|
+
frame,
|
|
659
|
+
subsystem: 'semantic-replay',
|
|
660
|
+
path: `snapshots[frame=${frame}].bytes`,
|
|
661
|
+
expectedChecksum: defaultChecksum(expected),
|
|
662
|
+
actualChecksum: defaultChecksum(snapshot.bytes),
|
|
663
|
+
minimalReproPath: `artifacts/replays/replay-min-${frame}.json`,
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return { required, executed: true, frames: checkedSnapshots };
|
|
669
|
+
}
|
|
670
|
+
function verifyOperationModelSemanticReplay(bundle, model) {
|
|
671
|
+
if (model.kind !== 'operation-model-v1') {
|
|
672
|
+
return { required: true, executed: false, frames: 0 };
|
|
673
|
+
}
|
|
674
|
+
const inputByFrame = new Map(bundle.inputHistory.map((frame) => [frame.frame, frame.inputs]));
|
|
675
|
+
const commandByFrame = new Map(bundle.commandHistory.map((frame) => [frame.frame, frame.commands]));
|
|
676
|
+
const snapshotByFrame = new Map(bundle.snapshots.map((snapshot) => [snapshot.frame, snapshot]));
|
|
677
|
+
const maxFrame = Math.max(...bundle.snapshots.map((snapshot) => snapshot.frame));
|
|
678
|
+
const baselineSnapshot = bundle.sliceOrigin?.baselineSnapshot;
|
|
679
|
+
const state = baselineSnapshot === undefined
|
|
680
|
+
? cloneCanonical(model.initialState)
|
|
681
|
+
: snapshotStateWithoutFrame(baselineSnapshot);
|
|
682
|
+
let checkedSnapshots = baselineSnapshot !== undefined && snapshotByFrame.has(baselineSnapshot.frame) ? 1 : 0;
|
|
683
|
+
const startFrame = baselineSnapshot === undefined ? 0 : baselineSnapshot.frame + 1;
|
|
684
|
+
for (let frame = startFrame; frame <= maxFrame; frame += 1) {
|
|
685
|
+
for (const input of inputByFrame.get(frame) ?? []) {
|
|
686
|
+
applySemanticOperations(state, model.operations, 'input', input);
|
|
687
|
+
}
|
|
688
|
+
for (const command of commandByFrame.get(frame) ?? []) {
|
|
689
|
+
applySemanticOperations(state, model.operations, 'command', command);
|
|
690
|
+
const payload = isCanonicalRecord(command) && isCanonicalRecord(command.payload) ? command.payload : undefined;
|
|
691
|
+
if (payload !== undefined) {
|
|
692
|
+
applySemanticOperations(state, model.operations, 'command.payload', payload);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const snapshot = snapshotByFrame.get(frame);
|
|
696
|
+
if (snapshot === undefined) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
checkedSnapshots += 1;
|
|
700
|
+
const expected = canonicalStringify({ frame, ...stateAsRecord(state) });
|
|
701
|
+
if (snapshot.bytes !== expected) {
|
|
702
|
+
return {
|
|
703
|
+
required: true,
|
|
704
|
+
executed: true,
|
|
705
|
+
frames: checkedSnapshots,
|
|
706
|
+
mismatch: {
|
|
707
|
+
frame,
|
|
708
|
+
subsystem: 'semantic-replay',
|
|
709
|
+
path: `snapshots[frame=${frame}].operation-model`,
|
|
710
|
+
expectedChecksum: defaultChecksum(expected),
|
|
711
|
+
actualChecksum: defaultChecksum(snapshot.bytes),
|
|
712
|
+
minimalReproPath: `artifacts/replays/replay-min-${frame}.json`,
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return { required: true, executed: true, frames: checkedSnapshots };
|
|
718
|
+
}
|
|
719
|
+
function applySemanticOperations(state, operations, source, value) {
|
|
720
|
+
if (!isCanonicalRecord(value)) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
for (const operation of operations) {
|
|
724
|
+
if (operation.source !== source) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const sourceValue = readPath(value, operation.sourcePath);
|
|
728
|
+
if (sourceValue === undefined) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const slot = operation.slotField === undefined ? undefined : canonicalNumber(value, operation.slotField);
|
|
732
|
+
applyStateOperation(state, operation.targetPath, sourceValue, operation.op, slot);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function applyStateOperation(state, targetPath, sourceValue, op, slot) {
|
|
736
|
+
if (!isCanonicalRecord(state)) {
|
|
737
|
+
throw new Error('Semantic replay state must be a record');
|
|
738
|
+
}
|
|
739
|
+
const arrayMatch = /^([A-Za-z_][A-Za-z0-9_]*)\[\$slot\]$/.exec(targetPath);
|
|
740
|
+
if (arrayMatch !== null) {
|
|
741
|
+
if (slot === undefined || !Number.isInteger(slot) || slot < 0) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const array = state[arrayMatch[1]];
|
|
745
|
+
if (!Array.isArray(array) || slot >= array.length) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const next = op === 'add' ? Number(array[slot] ?? 0) + Number(sourceValue) : sourceValue;
|
|
749
|
+
array[slot] = next;
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(targetPath)) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const previous = state[targetPath];
|
|
756
|
+
state[targetPath] = (op === 'add' ? Number(previous ?? 0) + Number(sourceValue) : sourceValue);
|
|
757
|
+
}
|
|
758
|
+
function readPath(value, path) {
|
|
759
|
+
const parts = path.split('.');
|
|
760
|
+
let current = value;
|
|
761
|
+
for (const part of parts) {
|
|
762
|
+
if (!isCanonicalRecord(current)) {
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
current = current[part];
|
|
766
|
+
}
|
|
767
|
+
return current;
|
|
768
|
+
}
|
|
769
|
+
function stateAsRecord(value) {
|
|
770
|
+
if (!isCanonicalRecord(value)) {
|
|
771
|
+
throw new Error('Semantic replay state must be a record');
|
|
772
|
+
}
|
|
773
|
+
return value;
|
|
774
|
+
}
|
|
775
|
+
function snapshotStateWithoutFrame(snapshot) {
|
|
776
|
+
const parsed = parseSnapshotBytes(snapshot);
|
|
777
|
+
if (!isCanonicalRecord(parsed)) {
|
|
778
|
+
return {};
|
|
779
|
+
}
|
|
780
|
+
const record = parsed;
|
|
781
|
+
return Object.fromEntries(stableKeys(record)
|
|
782
|
+
.filter((key) => key !== 'frame')
|
|
783
|
+
.map((key) => [key, cloneCanonical(record[key])]));
|
|
784
|
+
}
|
|
785
|
+
function verifyComponentSemanticReplay(bundle) {
|
|
786
|
+
const inputByFrame = new Map(bundle.inputHistory.map((frame) => [frame.frame, frame.inputs]));
|
|
787
|
+
const commandByFrame = new Map(bundle.commandHistory.map((frame) => [frame.frame, frame.commands]));
|
|
788
|
+
const snapshotByFrame = new Map(bundle.snapshots.map((snapshot) => [snapshot.frame, snapshot]));
|
|
789
|
+
const maxFrame = Math.max(...bundle.snapshots.map((snapshot) => snapshot.frame));
|
|
790
|
+
const baselineSnapshot = bundle.sliceOrigin?.baselineSnapshot;
|
|
791
|
+
const parsedBaseline = baselineSnapshot === undefined ? undefined : parseSnapshotBytes(baselineSnapshot);
|
|
792
|
+
const components = cloneComponentState(parsedBaseline?.components);
|
|
793
|
+
let checkedSnapshots = baselineSnapshot !== undefined && snapshotByFrame.has(baselineSnapshot.frame) ? 1 : 0;
|
|
794
|
+
const startFrame = baselineSnapshot === undefined ? 0 : baselineSnapshot.frame + 1;
|
|
795
|
+
for (let frame = startFrame; frame <= maxFrame; frame += 1) {
|
|
796
|
+
for (const input of inputByFrame.get(frame) ?? []) {
|
|
797
|
+
applyComponentInput(components, input);
|
|
798
|
+
}
|
|
799
|
+
for (const command of commandByFrame.get(frame) ?? []) {
|
|
800
|
+
applyComponentCommand(components, command);
|
|
801
|
+
}
|
|
802
|
+
const snapshot = snapshotByFrame.get(frame);
|
|
803
|
+
if (snapshot === undefined) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
checkedSnapshots += 1;
|
|
807
|
+
const expected = canonicalStringify({ frame, components });
|
|
808
|
+
if (snapshot.bytes !== expected) {
|
|
809
|
+
return {
|
|
810
|
+
required: true,
|
|
811
|
+
executed: true,
|
|
812
|
+
frames: checkedSnapshots,
|
|
813
|
+
mismatch: {
|
|
814
|
+
frame,
|
|
815
|
+
subsystem: 'semantic-replay',
|
|
816
|
+
path: `snapshots[frame=${frame}].components`,
|
|
817
|
+
expectedChecksum: defaultChecksum(expected),
|
|
818
|
+
actualChecksum: defaultChecksum(snapshot.bytes),
|
|
819
|
+
minimalReproPath: `artifacts/replays/replay-min-${frame}.json`,
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return { required: true, executed: true, frames: checkedSnapshots };
|
|
825
|
+
}
|
|
826
|
+
function cloneComponentState(value) {
|
|
827
|
+
if (!isCanonicalRecord(value)) {
|
|
828
|
+
return {};
|
|
829
|
+
}
|
|
830
|
+
const result = {};
|
|
831
|
+
const componentState = value;
|
|
832
|
+
for (const entity of stableKeys(componentState)) {
|
|
833
|
+
const components = componentState[entity];
|
|
834
|
+
if (!isCanonicalRecord(components)) {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
for (const component of stableKeys(components)) {
|
|
838
|
+
const fields = components[component];
|
|
839
|
+
if (!isCanonicalRecord(fields)) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const target = ensureComponentFields(result, entity, component);
|
|
843
|
+
for (const field of stableKeys(fields)) {
|
|
844
|
+
const fieldValue = fields[field];
|
|
845
|
+
/* istanbul ignore else -- component semantic replay only materializes primitive field values; non-primitives are skipped defensively. */
|
|
846
|
+
if (typeof fieldValue === 'number' || typeof fieldValue === 'string' || typeof fieldValue === 'boolean') {
|
|
847
|
+
target[field] = fieldValue;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return result;
|
|
853
|
+
}
|
|
854
|
+
function applyComponentInput(components, input) {
|
|
855
|
+
if (!isCanonicalRecord(input)) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const entity = canonicalString(input, 'entity');
|
|
859
|
+
const component = canonicalString(input, 'component');
|
|
860
|
+
const field = canonicalString(input, 'field');
|
|
861
|
+
if (entity === undefined || component === undefined || field === undefined) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const delta = canonicalNumber(input, 'delta');
|
|
865
|
+
const value = input.value;
|
|
866
|
+
const fields = ensureComponentFields(components, entity, component);
|
|
867
|
+
if (delta !== undefined) {
|
|
868
|
+
const current = typeof fields[field] === 'number' ? fields[field] : 0;
|
|
869
|
+
fields[field] = current + delta;
|
|
870
|
+
}
|
|
871
|
+
else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
|
872
|
+
fields[field] = value;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function applyComponentCommand(components, command) {
|
|
876
|
+
if (!isCanonicalRecord(command)) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const payload = isCanonicalRecord(command.payload) ? command.payload : command;
|
|
880
|
+
applyComponentInput(components, payload);
|
|
881
|
+
}
|
|
882
|
+
function ensureComponentFields(components, entity, component) {
|
|
883
|
+
components[entity] ??= {};
|
|
884
|
+
components[entity][component] ??= {};
|
|
885
|
+
return components[entity][component];
|
|
886
|
+
}
|
|
887
|
+
function isCanonicalRecord(value) {
|
|
888
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
889
|
+
}
|
|
890
|
+
function canonicalNumber(record, key) {
|
|
891
|
+
const value = record[key];
|
|
892
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
893
|
+
}
|
|
894
|
+
function canonicalString(record, key) {
|
|
895
|
+
const value = record[key];
|
|
896
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
897
|
+
}
|
|
898
|
+
function findMissingRequiredSection(bundle, inputCount, _commandCount) {
|
|
899
|
+
if (bundle.inputHistory.length === 0 || inputCount === 0) {
|
|
900
|
+
return missingSectionMismatch('input-history');
|
|
901
|
+
}
|
|
902
|
+
if (bundle.checksums.length === 0) {
|
|
903
|
+
return missingSectionMismatch('checksums');
|
|
904
|
+
}
|
|
905
|
+
if (bundle.debugEvents.length === 0) {
|
|
906
|
+
return missingSectionMismatch('debug-events');
|
|
907
|
+
}
|
|
908
|
+
if (bundle.snapshots.length === 0 || bundle.snapshots.every((snapshot) => snapshot.bytes.length === 0)) {
|
|
909
|
+
return missingSectionMismatch('snapshots');
|
|
910
|
+
}
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
function missingSectionMismatch(section) {
|
|
914
|
+
return {
|
|
915
|
+
frame: 0,
|
|
916
|
+
subsystem: 'replay-bundle',
|
|
917
|
+
path: section,
|
|
918
|
+
expectedChecksum: 'present',
|
|
919
|
+
actualChecksum: 'missing',
|
|
920
|
+
minimalReproPath: `artifacts/replays/replay-missing-${section}.json`,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
export function minimizeDeterministicReplayDesync(expected, actual) {
|
|
924
|
+
return locateDeterministicReplayDesync(expected, actual).mismatch;
|
|
925
|
+
}
|
|
926
|
+
function locateDeterministicReplayDesync(expected, actual) {
|
|
927
|
+
const frames = [...new Set([
|
|
928
|
+
...expected.checksums.map((checksum) => checksum.frame),
|
|
929
|
+
...actual.checksums.map((checksum) => checksum.frame),
|
|
930
|
+
])].sort((left, right) => left - right);
|
|
931
|
+
const expectedByFrame = new Map(expected.checksums.map((checksum) => [checksum.frame, checksum.checksum]));
|
|
932
|
+
const actualByFrame = new Map(actual.checksums.map((checksum) => [checksum.frame, checksum.checksum]));
|
|
933
|
+
let searchSteps = 0;
|
|
934
|
+
for (const frame of frames) {
|
|
935
|
+
searchSteps += 1;
|
|
936
|
+
if (expectedByFrame.get(frame) === actualByFrame.get(frame)) {
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
searchSteps,
|
|
941
|
+
mismatch: {
|
|
942
|
+
frame,
|
|
943
|
+
subsystem: firstSubsystemAtFrame(actual, frame),
|
|
944
|
+
path: `checksums[frame=${frame}]`,
|
|
945
|
+
expectedChecksum: expectedByFrame.get(frame) ?? 'missing',
|
|
946
|
+
actualChecksum: actualByFrame.get(frame) ?? 'missing',
|
|
947
|
+
minimalReproPath: `artifacts/replays/replay-min-${frame}.json`,
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
return { searchSteps };
|
|
952
|
+
}
|
|
953
|
+
export function minimizeDeterministicReplayDesyncBundle(expected, actual) {
|
|
954
|
+
const located = locateDeterministicReplayDesync(expected, actual);
|
|
955
|
+
if (located.mismatch === undefined) {
|
|
956
|
+
return undefined;
|
|
957
|
+
}
|
|
958
|
+
const mismatch = located.mismatch;
|
|
959
|
+
const expectedIdentity = replayIdentity(actual);
|
|
960
|
+
let minimized = firstVerifierConsumableSlice(actual, mismatch, expectedIdentity);
|
|
961
|
+
minimized = reduceReplayBundleRecords(minimized, mismatch, expectedIdentity, 'inputHistory');
|
|
962
|
+
minimized = reduceReplayBundleRecords(minimized, mismatch, expectedIdentity, 'commandHistory');
|
|
963
|
+
minimized = reduceReplayBundleDebugEvents(minimized, mismatch, expectedIdentity);
|
|
964
|
+
const verifier = verifyDeterministicReplayBundle(minimized, expectedIdentity);
|
|
965
|
+
return {
|
|
966
|
+
mismatch,
|
|
967
|
+
bundle: minimized,
|
|
968
|
+
verifier,
|
|
969
|
+
searchSteps: located.searchSteps,
|
|
970
|
+
originalReplayBytes: canonicalStringify(actual).length,
|
|
971
|
+
minimizedReplayBytes: canonicalStringify(minimized).length,
|
|
972
|
+
removedInputFrames: actual.inputHistory.length - minimized.inputHistory.length,
|
|
973
|
+
removedInputs: countReplayInputs(actual) - countReplayInputs(minimized),
|
|
974
|
+
removedCommandFrames: actual.commandHistory.length - minimized.commandHistory.length,
|
|
975
|
+
removedCommands: countReplayCommands(actual) - countReplayCommands(minimized),
|
|
976
|
+
removedDebugEvents: actual.debugEvents.length - minimized.debugEvents.length,
|
|
977
|
+
removedSnapshots: actual.snapshots.length - minimized.snapshots.length,
|
|
978
|
+
removedChecksumFrames: actual.checksums.length - minimized.checksums.length,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
export function createDeterministicReplayDebugTables(events) {
|
|
982
|
+
const table = (type) => events
|
|
983
|
+
.filter((event) => event.type.startsWith(type))
|
|
984
|
+
.sort(compareDebugEvents)
|
|
985
|
+
.map((event) => event.payload);
|
|
986
|
+
const tables = {
|
|
987
|
+
ecs: table('ecs'),
|
|
988
|
+
physics: table('physics'),
|
|
989
|
+
navigation: table('navigation'),
|
|
990
|
+
commands: table('command'),
|
|
991
|
+
events: table('event'),
|
|
992
|
+
rng: table('rng'),
|
|
993
|
+
assets: table('asset'),
|
|
994
|
+
timings: table('timing'),
|
|
995
|
+
};
|
|
996
|
+
return {
|
|
997
|
+
...tables,
|
|
998
|
+
checksum: defaultChecksum(tables),
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
export function sliceDeterministicReplayBundle(bundle, startFrame, endFrame) {
|
|
1002
|
+
if (endFrame < startFrame) {
|
|
1003
|
+
throw new Error('Replay slice end frame must be >= start frame');
|
|
1004
|
+
}
|
|
1005
|
+
const baselineSnapshot = [...bundle.snapshots]
|
|
1006
|
+
.filter((snapshot) => snapshot.frame <= startFrame)
|
|
1007
|
+
.sort((left, right) => right.frame - left.frame)[0];
|
|
1008
|
+
if (baselineSnapshot === undefined) {
|
|
1009
|
+
throw new Error(`Replay slice requires a baseline snapshot at or before frame ${startFrame}`);
|
|
1010
|
+
}
|
|
1011
|
+
const sourceBundleHash = defaultChecksum(bundle);
|
|
1012
|
+
const sourceStartFrame = bundle.checksums[0]?.frame ?? 0;
|
|
1013
|
+
const sourceEndFrame = bundle.checksums.at(-1)?.frame ?? sourceStartFrame;
|
|
1014
|
+
return createDeterministicReplayBundle({
|
|
1015
|
+
...bundle,
|
|
1016
|
+
inputHistory: bundle.inputHistory.filter((frame) => frame.frame >= startFrame && frame.frame <= endFrame),
|
|
1017
|
+
commandHistory: bundle.commandHistory.filter((frame) => frame.frame >= startFrame && frame.frame <= endFrame),
|
|
1018
|
+
checksums: bundle.checksums.filter((checksum) => checksum.frame >= startFrame && checksum.frame <= endFrame),
|
|
1019
|
+
debugEvents: bundle.debugEvents.filter((event) => event.frame >= startFrame && event.frame <= endFrame),
|
|
1020
|
+
snapshots: bundle.snapshots.filter((snapshot) => snapshot.frame >= startFrame && snapshot.frame <= endFrame),
|
|
1021
|
+
sliceOrigin: {
|
|
1022
|
+
sourceBundleHash,
|
|
1023
|
+
sourceStartFrame,
|
|
1024
|
+
sourceEndFrame,
|
|
1025
|
+
sliceStartFrame: startFrame,
|
|
1026
|
+
sliceEndFrame: endFrame,
|
|
1027
|
+
baselineSnapshot,
|
|
1028
|
+
},
|
|
1029
|
+
metadata: {
|
|
1030
|
+
...bundle.metadata,
|
|
1031
|
+
replaySlice: true,
|
|
1032
|
+
selfContainedSlice: true,
|
|
1033
|
+
sourceBundleHash,
|
|
1034
|
+
sliceStartFrame: startFrame,
|
|
1035
|
+
sliceEndFrame: endFrame,
|
|
1036
|
+
baselineFrame: baselineSnapshot.frame,
|
|
1037
|
+
baselineChecksum: baselineSnapshot.checksum,
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
function firstVerifierConsumableSlice(bundle, mismatch, expected) {
|
|
1042
|
+
const candidateFrames = [...new Set([
|
|
1043
|
+
mismatch.frame,
|
|
1044
|
+
...bundle.snapshots.map((snapshot) => snapshot.frame).filter((frame) => frame <= mismatch.frame),
|
|
1045
|
+
...bundle.checksums.map((checksum) => checksum.frame).filter((frame) => frame <= mismatch.frame),
|
|
1046
|
+
0,
|
|
1047
|
+
])].sort((left, right) => right - left);
|
|
1048
|
+
for (const frame of candidateFrames) {
|
|
1049
|
+
try {
|
|
1050
|
+
const candidate = sliceDeterministicReplayBundle(bundle, frame, mismatch.frame);
|
|
1051
|
+
if (preservesReplayMismatch(candidate, mismatch, expected)) {
|
|
1052
|
+
return candidate;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
return sliceDeterministicReplayBundle(bundle, 0, mismatch.frame);
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
return bundle;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function reduceReplayBundleRecords(bundle, mismatch, expected, section) {
|
|
1067
|
+
const current = bundle[section];
|
|
1068
|
+
const mismatchRecord = current.find((record) => record.frame === mismatch.frame);
|
|
1069
|
+
const fallbackRecord = current[0];
|
|
1070
|
+
const singleRecord = mismatchRecord ?? fallbackRecord;
|
|
1071
|
+
if (singleRecord === undefined) {
|
|
1072
|
+
return bundle;
|
|
1073
|
+
}
|
|
1074
|
+
const singleValue = section === 'inputHistory'
|
|
1075
|
+
? reduceReplayInputRecord(singleRecord)
|
|
1076
|
+
: reduceReplayCommandRecord(singleRecord);
|
|
1077
|
+
const candidate = createDeterministicReplayBundle({
|
|
1078
|
+
...bundle,
|
|
1079
|
+
[section]: [singleValue],
|
|
1080
|
+
});
|
|
1081
|
+
return preservesReplayMismatch(candidate, mismatch, expected) ? candidate : bundle;
|
|
1082
|
+
}
|
|
1083
|
+
function reduceReplayInputRecord(record) {
|
|
1084
|
+
return { ...record, inputs: record.inputs.slice(0, 1) };
|
|
1085
|
+
}
|
|
1086
|
+
function reduceReplayCommandRecord(record) {
|
|
1087
|
+
return { ...record, commands: record.commands.slice(0, 1) };
|
|
1088
|
+
}
|
|
1089
|
+
function reduceReplayBundleDebugEvents(bundle, mismatch, expected) {
|
|
1090
|
+
const event = bundle.debugEvents.find((candidate) => candidate.frame === mismatch.frame && candidate.type.startsWith(mismatch.subsystem))
|
|
1091
|
+
?? bundle.debugEvents.find((candidate) => candidate.frame === mismatch.frame)
|
|
1092
|
+
?? bundle.debugEvents[0];
|
|
1093
|
+
if (event === undefined) {
|
|
1094
|
+
return bundle;
|
|
1095
|
+
}
|
|
1096
|
+
const candidate = createDeterministicReplayBundle({
|
|
1097
|
+
...bundle,
|
|
1098
|
+
debugEvents: [event],
|
|
1099
|
+
});
|
|
1100
|
+
return preservesReplayMismatch(candidate, mismatch, expected) ? candidate : bundle;
|
|
1101
|
+
}
|
|
1102
|
+
function preservesReplayMismatch(bundle, mismatch, expected) {
|
|
1103
|
+
const verification = verifyDeterministicReplayBundle(bundle, expected);
|
|
1104
|
+
return verification.result === 'fail'
|
|
1105
|
+
&& verification.firstMismatch?.frame === mismatch.frame
|
|
1106
|
+
&& verification.firstMismatch.subsystem === mismatch.subsystem;
|
|
1107
|
+
}
|
|
1108
|
+
function replayIdentity(bundle) {
|
|
1109
|
+
return {
|
|
1110
|
+
runtimeVersion: bundle.runtimeVersion,
|
|
1111
|
+
engineHash: bundle.engineHash,
|
|
1112
|
+
schemaHash: bundle.schemaHash,
|
|
1113
|
+
assetDbHash: bundle.assetDbHash,
|
|
1114
|
+
configHash: bundle.configHash,
|
|
1115
|
+
semanticReplayHash: bundle.semanticReplayHash,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
function countReplayInputs(bundle) {
|
|
1119
|
+
return bundle.inputHistory.reduce((sum, frame) => sum + frame.inputs.length, 0);
|
|
1120
|
+
}
|
|
1121
|
+
function countReplayCommands(bundle) {
|
|
1122
|
+
return bundle.commandHistory.reduce((sum, frame) => sum + frame.commands.length, 0);
|
|
1123
|
+
}
|
|
1124
|
+
function assertReplayBundleIdentity(bundle) {
|
|
1125
|
+
const fields = ['runtimeVersion', 'engineHash', 'schemaHash', 'assetDbHash', 'configHash', 'sampleId'];
|
|
1126
|
+
for (const field of fields) {
|
|
1127
|
+
if (bundle[field].length === 0) {
|
|
1128
|
+
throw new Error(`Replay bundle requires ${field}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function assertReplayRuntimeManifest(manifest) {
|
|
1133
|
+
if (manifest === undefined) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const expectedHash = deterministicReplayRuntimeManifestHash(manifest);
|
|
1137
|
+
if (manifest.manifestHash !== expectedHash) {
|
|
1138
|
+
throw new Error('Replay runtime manifest hash mismatch');
|
|
1139
|
+
}
|
|
1140
|
+
deterministicReplayRuntimeSystems(manifest.systemId);
|
|
1141
|
+
deterministicReplayRuntimeBeforeFrame(manifest.beforeFrameId);
|
|
1142
|
+
}
|
|
1143
|
+
function deterministicReplayRuntimeManifestHash(manifest) {
|
|
1144
|
+
return defaultChecksum({
|
|
1145
|
+
descriptor: manifest.descriptor,
|
|
1146
|
+
descriptorHash: manifest.descriptorHash,
|
|
1147
|
+
systemId: manifest.systemId,
|
|
1148
|
+
beforeFrameId: manifest.beforeFrameId,
|
|
1149
|
+
systemsSourceHash: manifest.systemsSourceHash,
|
|
1150
|
+
acceptedSystemsSourceHashes: [...(manifest.acceptedSystemsSourceHashes ?? [])].sort(compareStrings),
|
|
1151
|
+
seed: manifest.seed,
|
|
1152
|
+
sparse: manifest.sparse === true,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
function findRuntimeManifestIdentityMismatch(manifest, options) {
|
|
1156
|
+
if (manifest === undefined) {
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
const expectedManifestHash = deterministicReplayRuntimeManifestHash(manifest);
|
|
1160
|
+
if (manifest.manifestHash !== expectedManifestHash) {
|
|
1161
|
+
return runtimeReplayMismatch(0, 'runtimeReplay.manifestHash', expectedManifestHash, manifest.manifestHash);
|
|
1162
|
+
}
|
|
1163
|
+
const descriptorHash = options.descriptorHash ?? defaultChecksum(options.descriptor);
|
|
1164
|
+
if (manifest.descriptorHash !== descriptorHash) {
|
|
1165
|
+
return runtimeReplayMismatch(0, 'runtimeReplay.descriptorHash', manifest.descriptorHash, descriptorHash);
|
|
1166
|
+
}
|
|
1167
|
+
if (options.requireRuntimeIdentity === true || options.systemsSourceHash !== undefined) {
|
|
1168
|
+
const systemsSourceHash = options.systemsSourceHash ?? 'missing';
|
|
1169
|
+
const acceptedSystemsSourceHashes = new Set([manifest.systemsSourceHash, ...(manifest.acceptedSystemsSourceHashes ?? [])]);
|
|
1170
|
+
if (!acceptedSystemsSourceHashes.has(systemsSourceHash)) {
|
|
1171
|
+
return runtimeReplayMismatch(0, 'runtimeReplay.systemsSourceHash', manifest.systemsSourceHash, systemsSourceHash);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return undefined;
|
|
1175
|
+
}
|
|
1176
|
+
function deterministicReplayRuntimeSourceHash(systemId, beforeFrameId) {
|
|
1177
|
+
return defaultChecksum({
|
|
1178
|
+
kind: 'deterministic-replay-runtime-source',
|
|
1179
|
+
systemId,
|
|
1180
|
+
beforeFrameId,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
function deterministicReplayRuntimeSystems(systemId) {
|
|
1184
|
+
switch (systemId) {
|
|
1185
|
+
case 'score-accumulator-v1':
|
|
1186
|
+
return {
|
|
1187
|
+
simulate(ctx) {
|
|
1188
|
+
const score = ctx.getSingleton('score') ?? { value: 0, inputDelta: 0, commandDelta: 0 };
|
|
1189
|
+
ctx.setSingleton('score', {
|
|
1190
|
+
value: Number(score.value ?? 0) + Number(score.inputDelta ?? 0) + Number(score.commandDelta ?? 0),
|
|
1191
|
+
inputDelta: 0,
|
|
1192
|
+
commandDelta: 0,
|
|
1193
|
+
});
|
|
1194
|
+
ctx.emitDebug('ecs.score', { value: Number(score.value ?? 0) });
|
|
1195
|
+
},
|
|
1196
|
+
};
|
|
1197
|
+
case 'score-runtime-replay-v1':
|
|
1198
|
+
return {
|
|
1199
|
+
simulate(ctx) {
|
|
1200
|
+
const score = ctx.getSingleton('score') ?? { value: 0, inputDelta: 0, commandDelta: 0 };
|
|
1201
|
+
ctx.setSingleton('score', {
|
|
1202
|
+
value: Number(score.value ?? 0) + Number(score.inputDelta ?? 0) + Number(score.commandDelta ?? 0),
|
|
1203
|
+
inputDelta: 0,
|
|
1204
|
+
commandDelta: 0,
|
|
1205
|
+
});
|
|
1206
|
+
if (ctx.frame % 128 === 0) {
|
|
1207
|
+
ctx.emitDebug('ecs.runtime-replay', { frame: ctx.frame, value: Number(score.value ?? 0) });
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1210
|
+
};
|
|
1211
|
+
case 'project-game-state-runtime-replay-v1':
|
|
1212
|
+
return {
|
|
1213
|
+
simulate(ctx) {
|
|
1214
|
+
const state = ctx.getSingleton('runtimeReplayState') ?? { value: 0, inputDelta: 0, commandDelta: 0 };
|
|
1215
|
+
const value = Number(state.value ?? 0) + Number(state.inputDelta ?? 0) + Number(state.commandDelta ?? 0);
|
|
1216
|
+
ctx.setSingleton('runtimeReplayState', {
|
|
1217
|
+
value,
|
|
1218
|
+
inputDelta: 0,
|
|
1219
|
+
commandDelta: 0,
|
|
1220
|
+
});
|
|
1221
|
+
const gameState = ctx.getSingleton('gameState');
|
|
1222
|
+
if (gameState !== undefined) {
|
|
1223
|
+
ctx.setSingleton('gameState', patchProjectGameStateReplay(gameState, ctx.frame + 1, value));
|
|
1224
|
+
}
|
|
1225
|
+
if (ctx.frame % 128 === 0) {
|
|
1226
|
+
ctx.emitDebug('ecs.project-runtime-replay', { frame: ctx.frame, value });
|
|
1227
|
+
}
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
function deterministicReplayRuntimeBeforeFrame(beforeFrameId) {
|
|
1233
|
+
switch (beforeFrameId) {
|
|
1234
|
+
case 'score-input-command-delta-v1':
|
|
1235
|
+
return injectScoreRuntimeReplayFrame;
|
|
1236
|
+
case 'project-game-state-input-command-delta-v1':
|
|
1237
|
+
return injectProjectGameStateRuntimeReplayFrame;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
function injectScoreRuntimeReplayFrame(runtime, _frame, inputs, commands) {
|
|
1241
|
+
const inputDelta = inputs.reduce((sum, input) => isReplayRecord(input) && typeof input.dx === 'number' ? sum + input.dx : sum, 0);
|
|
1242
|
+
const commandDelta = commands.reduce((sum, command) => {
|
|
1243
|
+
if (!isReplayRecord(command) || !isReplayRecord(command.payload) || typeof command.payload.power !== 'number') {
|
|
1244
|
+
return sum;
|
|
1245
|
+
}
|
|
1246
|
+
return sum + command.payload.power;
|
|
1247
|
+
}, 0);
|
|
1248
|
+
const score = runtime.snapshot().singletonComponents.score ?? { value: 0 };
|
|
1249
|
+
runtime.setSingleton('score', {
|
|
1250
|
+
value: Number(score.value ?? 0),
|
|
1251
|
+
inputDelta,
|
|
1252
|
+
commandDelta,
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
function injectProjectGameStateRuntimeReplayFrame(runtime, frame, inputs, commands) {
|
|
1256
|
+
const inputDelta = inputs.reduce((sum, input) => {
|
|
1257
|
+
if (!isReplayRecord(input)) {
|
|
1258
|
+
return sum;
|
|
1259
|
+
}
|
|
1260
|
+
if (typeof input.dx === 'number') {
|
|
1261
|
+
return sum + input.dx;
|
|
1262
|
+
}
|
|
1263
|
+
if (typeof input.moveX === 'number') {
|
|
1264
|
+
return sum + input.moveX;
|
|
1265
|
+
}
|
|
1266
|
+
return sum;
|
|
1267
|
+
}, 0);
|
|
1268
|
+
const commandDelta = commands.reduce((sum, command) => {
|
|
1269
|
+
if (!isReplayRecord(command) || !isReplayRecord(command.payload)) {
|
|
1270
|
+
return sum;
|
|
1271
|
+
}
|
|
1272
|
+
if (typeof command.payload.power === 'number') {
|
|
1273
|
+
return sum + command.payload.power;
|
|
1274
|
+
}
|
|
1275
|
+
if (typeof command.payload.slot === 'number') {
|
|
1276
|
+
return sum + command.payload.slot;
|
|
1277
|
+
}
|
|
1278
|
+
return sum;
|
|
1279
|
+
}, 0);
|
|
1280
|
+
const state = runtime.snapshot().singletonComponents.runtimeReplayState ?? { value: 0 };
|
|
1281
|
+
runtime.setSingleton('runtimeReplayState', {
|
|
1282
|
+
value: Number(state.value ?? 0),
|
|
1283
|
+
inputDelta,
|
|
1284
|
+
commandDelta,
|
|
1285
|
+
});
|
|
1286
|
+
const gameState = runtime.snapshot().singletonComponents.gameState;
|
|
1287
|
+
if (gameState !== undefined) {
|
|
1288
|
+
runtime.setSingleton('gameState', patchProjectGameStateReplay(gameState, frame, Number(state.value ?? 0)));
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
function patchProjectGameStateReplay(gameState, frame, score) {
|
|
1292
|
+
return Object.fromEntries(stableKeys(gameState).map((key) => {
|
|
1293
|
+
const value = gameState[key];
|
|
1294
|
+
if (key === 'tick' && typeof value === 'number') {
|
|
1295
|
+
return [key, frame];
|
|
1296
|
+
}
|
|
1297
|
+
if (key === 'score' && typeof value === 'number') {
|
|
1298
|
+
return [key, score];
|
|
1299
|
+
}
|
|
1300
|
+
if (key === 'alive' && typeof value === 'boolean') {
|
|
1301
|
+
return [key, true];
|
|
1302
|
+
}
|
|
1303
|
+
return [key, value];
|
|
1304
|
+
}));
|
|
1305
|
+
}
|
|
1306
|
+
function isReplayRecord(value) {
|
|
1307
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1308
|
+
}
|
|
1309
|
+
function findIdentityMismatch(bundle, expected) {
|
|
1310
|
+
if (expected === undefined) {
|
|
1311
|
+
return undefined;
|
|
1312
|
+
}
|
|
1313
|
+
for (const field of stableKeys(expected)) {
|
|
1314
|
+
const value = expected[field];
|
|
1315
|
+
const actual = bundle[field];
|
|
1316
|
+
if (value !== undefined && actual !== value) {
|
|
1317
|
+
return {
|
|
1318
|
+
frame: 0,
|
|
1319
|
+
subsystem: 'identity',
|
|
1320
|
+
path: field,
|
|
1321
|
+
expectedChecksum: String(value),
|
|
1322
|
+
actualChecksum: String(actual),
|
|
1323
|
+
minimalReproPath: 'artifacts/replays/replay-identity-min.json',
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return undefined;
|
|
1328
|
+
}
|
|
1329
|
+
function firstSubsystemAtFrame(bundle, frame) {
|
|
1330
|
+
return bundle.debugEvents.find((event) => event.frame === frame)?.type.split('.')[0] ?? 'checksum';
|
|
1331
|
+
}
|
|
1332
|
+
function compareDebugEvents(left, right) {
|
|
1333
|
+
return left.frame - right.frame || compareStrings(left.type, right.type) || compareStrings(canonicalStringify(left.payload), canonicalStringify(right.payload));
|
|
1334
|
+
}
|
|
1335
|
+
function stableKeys(record) {
|
|
1336
|
+
return Reflect.ownKeys(record)
|
|
1337
|
+
.filter((key) => typeof key === 'string')
|
|
1338
|
+
.sort(compareStrings);
|
|
1339
|
+
}
|
|
1340
|
+
function compareStrings(left, right) {
|
|
1341
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1342
|
+
}
|
|
1343
|
+
export const __replayBundleCoverageProof = {
|
|
1344
|
+
exercisePrivateFallbacks() {
|
|
1345
|
+
const frame0 = canonicalStringify({ frame: 0, positions: [0], commandScore: 0 });
|
|
1346
|
+
const frame1 = canonicalStringify({ frame: 1, positions: [1], commandScore: 0 });
|
|
1347
|
+
const baseBundle = createDeterministicReplayBundle({
|
|
1348
|
+
runtimeVersion: 'test@1',
|
|
1349
|
+
engineHash: 'engine',
|
|
1350
|
+
schemaHash: 'schema',
|
|
1351
|
+
assetDbHash: 'asset',
|
|
1352
|
+
configHash: 'config',
|
|
1353
|
+
sampleId: 'coverage-proof',
|
|
1354
|
+
inputHistory: [{ frame: 0, inputs: [{ slot: 0, dx: 1 }] }],
|
|
1355
|
+
commandHistory: [{ frame: 0, commands: [] }],
|
|
1356
|
+
checksums: [
|
|
1357
|
+
{ frame: 0, checksum: defaultChecksum(frame0) },
|
|
1358
|
+
{ frame: 1, checksum: defaultChecksum(frame1) },
|
|
1359
|
+
],
|
|
1360
|
+
debugEvents: [{ frame: 0, type: 'other.event', payload: { order: 1 } }],
|
|
1361
|
+
snapshots: [
|
|
1362
|
+
{ frame: 0, bytes: frame0, checksum: defaultChecksum(frame0) },
|
|
1363
|
+
{ frame: 1, bytes: frame1, checksum: defaultChecksum(frame1) },
|
|
1364
|
+
],
|
|
1365
|
+
metadata: { players: 1 },
|
|
1366
|
+
});
|
|
1367
|
+
const runtimeSnapshotBytes = canonicalStringify({
|
|
1368
|
+
frame: 0,
|
|
1369
|
+
nextEntityId: 1,
|
|
1370
|
+
freeEntityIds: [],
|
|
1371
|
+
generations: {},
|
|
1372
|
+
components: {},
|
|
1373
|
+
singletonComponents: {},
|
|
1374
|
+
debugEvents: [],
|
|
1375
|
+
commandTimeline: {},
|
|
1376
|
+
randomState: 1,
|
|
1377
|
+
});
|
|
1378
|
+
const runtimeSnapshot = frameSnapshotFromReplaySnapshot({
|
|
1379
|
+
frame: 0,
|
|
1380
|
+
bytes: runtimeSnapshotBytes,
|
|
1381
|
+
checksum: defaultChecksum(runtimeSnapshotBytes),
|
|
1382
|
+
});
|
|
1383
|
+
const identityMismatch = {
|
|
1384
|
+
frame: 0,
|
|
1385
|
+
subsystem: 'identity',
|
|
1386
|
+
path: 'engineHash',
|
|
1387
|
+
expectedChecksum: 'expected',
|
|
1388
|
+
actualChecksum: 'engine',
|
|
1389
|
+
minimalReproPath: 'artifacts/replays/replay-identity-min.json',
|
|
1390
|
+
};
|
|
1391
|
+
const expectedIdentity = {
|
|
1392
|
+
runtimeVersion: baseBundle.runtimeVersion,
|
|
1393
|
+
engineHash: 'expected',
|
|
1394
|
+
schemaHash: baseBundle.schemaHash,
|
|
1395
|
+
assetDbHash: baseBundle.assetDbHash,
|
|
1396
|
+
configHash: baseBundle.configHash,
|
|
1397
|
+
semanticReplayHash: baseBundle.semanticReplayHash,
|
|
1398
|
+
};
|
|
1399
|
+
const semanticHash = verifySemanticReplay({
|
|
1400
|
+
...baseBundle,
|
|
1401
|
+
snapshots: [],
|
|
1402
|
+
semanticReplay: {
|
|
1403
|
+
kind: 'operation-model-v1',
|
|
1404
|
+
initialState: {},
|
|
1405
|
+
operations: [],
|
|
1406
|
+
},
|
|
1407
|
+
semanticReplayHash: undefined,
|
|
1408
|
+
}, {});
|
|
1409
|
+
const selectedSlice = firstVerifierConsumableSlice(baseBundle, identityMismatch, expectedIdentity);
|
|
1410
|
+
const recordReduced = reduceReplayBundleRecords(baseBundle, identityMismatch, expectedIdentity, 'inputHistory');
|
|
1411
|
+
const debugReduced = reduceReplayBundleDebugEvents(baseBundle, identityMismatch, expectedIdentity);
|
|
1412
|
+
const manualDebugFallback = reduceReplayBundleDebugEvents(baseBundle, {
|
|
1413
|
+
...identityMismatch,
|
|
1414
|
+
subsystem: 'manual-subsystem',
|
|
1415
|
+
}, expectedIdentity);
|
|
1416
|
+
const builtInWrites = [];
|
|
1417
|
+
const makeSystemContext = (frame, singletons) => ({
|
|
1418
|
+
frame,
|
|
1419
|
+
getSingleton(id) {
|
|
1420
|
+
return singletons[id];
|
|
1421
|
+
},
|
|
1422
|
+
setSingleton(id, value) {
|
|
1423
|
+
singletons[id] = value;
|
|
1424
|
+
builtInWrites.push(value);
|
|
1425
|
+
},
|
|
1426
|
+
emitDebug(_type, payload) {
|
|
1427
|
+
builtInWrites.push(payload);
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
deterministicReplayRuntimeSystems('score-accumulator-v1').simulate(makeSystemContext(0, {
|
|
1431
|
+
score: {},
|
|
1432
|
+
}));
|
|
1433
|
+
deterministicReplayRuntimeSystems('score-runtime-replay-v1').simulate(makeSystemContext(0, {
|
|
1434
|
+
score: {},
|
|
1435
|
+
}));
|
|
1436
|
+
deterministicReplayRuntimeSystems('score-runtime-replay-v1').simulate(makeSystemContext(1, {
|
|
1437
|
+
score: {},
|
|
1438
|
+
}));
|
|
1439
|
+
deterministicReplayRuntimeSystems('project-game-state-runtime-replay-v1').simulate(makeSystemContext(1, {
|
|
1440
|
+
runtimeReplayState: {},
|
|
1441
|
+
gameState: { tick: 'old', score: 'old', alive: 'old' },
|
|
1442
|
+
}));
|
|
1443
|
+
const fakeRuntime = {
|
|
1444
|
+
snapshot() {
|
|
1445
|
+
return {
|
|
1446
|
+
singletonComponents: {
|
|
1447
|
+
score: {},
|
|
1448
|
+
runtimeReplayState: {},
|
|
1449
|
+
gameState: { tick: 0, score: 0, alive: false },
|
|
1450
|
+
},
|
|
1451
|
+
};
|
|
1452
|
+
},
|
|
1453
|
+
setSingleton(_id, value) {
|
|
1454
|
+
builtInWrites.push(value);
|
|
1455
|
+
},
|
|
1456
|
+
};
|
|
1457
|
+
deterministicReplayRuntimeBeforeFrame('score-input-command-delta-v1')?.(fakeRuntime, 0, [{}], [{}]);
|
|
1458
|
+
deterministicReplayRuntimeBeforeFrame('project-game-state-input-command-delta-v1')?.(fakeRuntime, 0, [{ moveX: 2 }, {}], [{ payload: { slot: 1 } }, {}]);
|
|
1459
|
+
const semanticFallbackBaseline = canonicalStringify({ frame: -1, positions: ['bad'], commandScore: 0 });
|
|
1460
|
+
const semanticFallbackFrame = canonicalStringify({ frame: 0, positions: [2], commandScore: 0 });
|
|
1461
|
+
const semanticFallback = verifySemanticReplay(createDeterministicReplayBundle({
|
|
1462
|
+
runtimeVersion: 'test@1',
|
|
1463
|
+
engineHash: 'engine',
|
|
1464
|
+
schemaHash: 'schema',
|
|
1465
|
+
assetDbHash: 'asset',
|
|
1466
|
+
configHash: 'config',
|
|
1467
|
+
sampleId: 'coverage-semantic-fallback',
|
|
1468
|
+
inputHistory: [{ frame: 0, inputs: [{ slot: 0, dy: 1 }, { slot: 0, dx: 1 }, { slot: 0 }] }],
|
|
1469
|
+
commandHistory: [{ frame: 0, commands: [] }],
|
|
1470
|
+
checksums: [{ frame: 0, checksum: defaultChecksum(semanticFallbackFrame) }],
|
|
1471
|
+
debugEvents: [{ frame: 0, type: 'ecs.snapshot', payload: {} }],
|
|
1472
|
+
snapshots: [{ frame: 0, bytes: semanticFallbackFrame, checksum: defaultChecksum(semanticFallbackFrame) }],
|
|
1473
|
+
sliceOrigin: {
|
|
1474
|
+
sourceBundleHash: 'semantic-source',
|
|
1475
|
+
sourceStartFrame: -1,
|
|
1476
|
+
sourceEndFrame: 0,
|
|
1477
|
+
sliceStartFrame: 0,
|
|
1478
|
+
sliceEndFrame: 0,
|
|
1479
|
+
baselineSnapshot: { frame: -1, bytes: semanticFallbackBaseline, checksum: defaultChecksum(semanticFallbackBaseline) },
|
|
1480
|
+
},
|
|
1481
|
+
metadata: {},
|
|
1482
|
+
}));
|
|
1483
|
+
const sparseArray = Array(1);
|
|
1484
|
+
applyStateOperation({ positions: sparseArray }, 'positions[$slot]', 2, 'add', 0);
|
|
1485
|
+
const operationBaselineFrame = canonicalStringify({ frame: -1, positions: [0], bonus: 0 });
|
|
1486
|
+
const operationFinalFrame = canonicalStringify({ frame: 1, positions: [1], bonus: 1 });
|
|
1487
|
+
const operationFallback = verifyOperationModelSemanticReplay(createDeterministicReplayBundle({
|
|
1488
|
+
runtimeVersion: 'test@1',
|
|
1489
|
+
engineHash: 'engine',
|
|
1490
|
+
schemaHash: 'schema',
|
|
1491
|
+
assetDbHash: 'asset',
|
|
1492
|
+
configHash: 'config',
|
|
1493
|
+
sampleId: 'coverage-operation-fallback',
|
|
1494
|
+
inputHistory: [{ frame: 1, inputs: [{ slot: 0, dx: 1, bonus: 1 }] }],
|
|
1495
|
+
commandHistory: [{ frame: 1, commands: [] }],
|
|
1496
|
+
checksums: [
|
|
1497
|
+
{ frame: -1, checksum: defaultChecksum(operationBaselineFrame) },
|
|
1498
|
+
{ frame: 1, checksum: defaultChecksum(operationFinalFrame) },
|
|
1499
|
+
],
|
|
1500
|
+
debugEvents: [{ frame: 1, type: 'ecs.snapshot', payload: {} }],
|
|
1501
|
+
snapshots: [
|
|
1502
|
+
{ frame: -1, bytes: operationBaselineFrame, checksum: defaultChecksum(operationBaselineFrame) },
|
|
1503
|
+
{ frame: 1, bytes: operationFinalFrame, checksum: defaultChecksum(operationFinalFrame) },
|
|
1504
|
+
],
|
|
1505
|
+
sliceOrigin: {
|
|
1506
|
+
sourceBundleHash: 'operation-source',
|
|
1507
|
+
sourceStartFrame: -1,
|
|
1508
|
+
sourceEndFrame: 1,
|
|
1509
|
+
sliceStartFrame: 0,
|
|
1510
|
+
sliceEndFrame: 1,
|
|
1511
|
+
baselineSnapshot: { frame: -1, bytes: operationBaselineFrame, checksum: defaultChecksum(operationBaselineFrame) },
|
|
1512
|
+
},
|
|
1513
|
+
metadata: {},
|
|
1514
|
+
}), {
|
|
1515
|
+
kind: 'operation-model-v1',
|
|
1516
|
+
initialState: { positions: [0], bonus: 0 },
|
|
1517
|
+
operations: [
|
|
1518
|
+
{ source: 'input', sourcePath: 'dx', targetPath: 'positions[$slot]', op: 'add', slotField: 'slot' },
|
|
1519
|
+
{ source: 'input', sourcePath: 'bonus', targetPath: 'bonus', op: 'add' },
|
|
1520
|
+
],
|
|
1521
|
+
});
|
|
1522
|
+
const componentBaselineFrame = canonicalStringify({
|
|
1523
|
+
frame: -1,
|
|
1524
|
+
components: { 1: { position: { nested: null, x: 0 } } },
|
|
1525
|
+
});
|
|
1526
|
+
const componentFinalFrame = canonicalStringify({
|
|
1527
|
+
frame: 1,
|
|
1528
|
+
components: { 1: { position: { x: 1 } } },
|
|
1529
|
+
});
|
|
1530
|
+
const componentFallback = verifyComponentSemanticReplay(createDeterministicReplayBundle({
|
|
1531
|
+
runtimeVersion: 'test@1',
|
|
1532
|
+
engineHash: 'engine',
|
|
1533
|
+
schemaHash: 'schema',
|
|
1534
|
+
assetDbHash: 'asset',
|
|
1535
|
+
configHash: 'config',
|
|
1536
|
+
sampleId: 'coverage-component-fallback',
|
|
1537
|
+
inputHistory: [{ frame: 0, inputs: [{ entity: '1', component: 'position', field: 'ignored', value: { nested: true } }] }],
|
|
1538
|
+
commandHistory: [{ frame: 0, commands: [{ entity: '1', component: 'position', field: 'x', delta: 1 }] }],
|
|
1539
|
+
checksums: [
|
|
1540
|
+
{ frame: -1, checksum: defaultChecksum(componentBaselineFrame) },
|
|
1541
|
+
{ frame: 1, checksum: defaultChecksum(componentFinalFrame) },
|
|
1542
|
+
],
|
|
1543
|
+
debugEvents: [{ frame: 1, type: 'ecs.snapshot', payload: {} }],
|
|
1544
|
+
snapshots: [
|
|
1545
|
+
{ frame: -1, bytes: componentBaselineFrame, checksum: defaultChecksum(componentBaselineFrame) },
|
|
1546
|
+
{ frame: 1, bytes: componentFinalFrame, checksum: defaultChecksum(componentFinalFrame) },
|
|
1547
|
+
],
|
|
1548
|
+
sliceOrigin: {
|
|
1549
|
+
sourceBundleHash: 'component-source',
|
|
1550
|
+
sourceStartFrame: -1,
|
|
1551
|
+
sourceEndFrame: 1,
|
|
1552
|
+
sliceStartFrame: 0,
|
|
1553
|
+
sliceEndFrame: 1,
|
|
1554
|
+
baselineSnapshot: { frame: -1, bytes: componentBaselineFrame, checksum: defaultChecksum(componentBaselineFrame) },
|
|
1555
|
+
},
|
|
1556
|
+
metadata: {},
|
|
1557
|
+
}));
|
|
1558
|
+
const duplicateStringFrame = findDuplicateFrame(['debug:4', 'debug:4'], 'debugEvents');
|
|
1559
|
+
const emptyChecksumSlice = sliceDeterministicReplayBundle({ ...baseBundle, checksums: [] }, 0, 0);
|
|
1560
|
+
return {
|
|
1561
|
+
emptyMissingFrameIsUndefined: findMissingFrame([], 'inputHistory') === undefined,
|
|
1562
|
+
duplicateStringFrame: duplicateStringFrame.frame,
|
|
1563
|
+
semanticHashFallbackFrame: semanticHash.mismatch.frame,
|
|
1564
|
+
semanticHashFallbackActual: semanticHash.mismatch.actualChecksum,
|
|
1565
|
+
runtimeSnapshotHasDefaultHeap: runtimeSnapshot.frameHeap.nextHandleId,
|
|
1566
|
+
runtimeSnapshotRuntimeEvents: runtimeSnapshot.runtimeEvents.length,
|
|
1567
|
+
runtimeSnapshotCommandTimelineKeys: stableKeys(runtimeSnapshot.commandTimeline).length,
|
|
1568
|
+
nullSnapshotStateKeys: stableKeys(snapshotStateWithoutFrame({
|
|
1569
|
+
frame: -1,
|
|
1570
|
+
bytes: canonicalStringify(null),
|
|
1571
|
+
checksum: defaultChecksum(canonicalStringify(null)),
|
|
1572
|
+
})).length,
|
|
1573
|
+
selectedSliceStart: selectedSlice.sliceOrigin.sliceStartFrame,
|
|
1574
|
+
emptyChecksumSliceStart: emptyChecksumSlice.sliceOrigin.sourceStartFrame,
|
|
1575
|
+
recordReducedInputFrames: recordReduced.inputHistory.length,
|
|
1576
|
+
debugReducedEvents: debugReduced.debugEvents.length,
|
|
1577
|
+
manualDebugFallbackEvents: manualDebugFallback.debugEvents.length,
|
|
1578
|
+
builtInWriteCount: builtInWrites.length,
|
|
1579
|
+
semanticFallbackFrames: semanticFallback.frames,
|
|
1580
|
+
operationFallbackFrames: operationFallback.frames,
|
|
1581
|
+
componentFallbackFrames: componentFallback.frames,
|
|
1582
|
+
sparseArrayFallbackValue: sparseArray[0],
|
|
1583
|
+
compareStringsGreater: compareStrings('b', 'a'),
|
|
1584
|
+
};
|
|
1585
|
+
},
|
|
1586
|
+
};
|