新增:模拟一致性检查页(/dev/sim-check.html),任意设备的浏览器重放黄金结果的整套战斗,逐帧与 Node 比较
This commit is contained in:
1 parent
bc50cac01f
commit
ce52512559
11 files changed
+826
No files matched your search
@@ -0,0 +1,212 @@
|
||||
// simcheck — whether a deterministic simulation gives the same bits in every JavaScript engine: the server's Node and
|
||||
// the players' browsers (Chrome / Edge, Firefox, Safari, on desktops and phones). A run of one scenario is traced:
|
||||
// state the simulation's observable numbers after every tick (the adapter's state(): positions, HP, timers …),
|
||||
// hashed to their exact IEEE-754 bits; each block of `every` ticks keeps one hash of its ticks' hashes
|
||||
// view what a viewer is shown (the adapter's view(): a wire snapshot), hashed at the end of every block and at the
|
||||
// end of the run
|
||||
// deep the hidden state (the adapter's deep(): every value reachable from the units), hashed every `deepEvery`
|
||||
// ticks (a multiple of `every`; the walk is costly) and at the end of the run
|
||||
// result the outcome's digest (the adapter's result())
|
||||
// A run elsewhere is compared with a reference run (Node) block by block: the first block that differs, at any level,
|
||||
// says when the two parted; a dump of that block's state vectors from both sides then names the first field that
|
||||
// differs (diffDumps).
|
||||
//
|
||||
// Integer operations only (Math.imul, shifts) over the numbers' bits: the hashes are the same in every engine. NaN is
|
||||
// hashed as one value (engines may keep different NaN payloads).
|
||||
//
|
||||
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
|
||||
// the same in both. No dependencies; Node and browsers.
|
||||
|
||||
const DV = new DataView(new ArrayBuffer(8));
|
||||
const rotl = (x, r) => (x << r) | (x >>> (32 - r));
|
||||
// murmur3's block mix and finaliser
|
||||
function mix(h, k) {
|
||||
k = Math.imul(k, 0xcc9e2d51); k = rotl(k, 15); k = Math.imul(k, 0x1b873593);
|
||||
h ^= k; h = rotl(h, 13);
|
||||
return (Math.imul(h, 5) + 0xe6546b64) | 0;
|
||||
}
|
||||
function fmix(h) {
|
||||
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
|
||||
return h >>> 0;
|
||||
}
|
||||
// the kind of an object by its tag, not instanceof: a simulation may run in another realm (a Node VM context) than the
|
||||
// hashing code, with its own Map / Set constructors
|
||||
const tag = (v) => Object.prototype.toString.call(v);
|
||||
const isList = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && tag(v) !== '[object DataView]');
|
||||
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
/** A 32-bit hash fed with words, numbers (their bits), strings and plain values. */
|
||||
class Hash {
|
||||
constructor(seed = 0x9747b28c) { this.h = seed | 0; this.n = 0; }
|
||||
word(w) { this.h = mix(this.h, w | 0); this.n++; return this; }
|
||||
num(x) {
|
||||
if (x !== x) return this.word(0x7ff80000).word(0);
|
||||
DV.setFloat64(0, x, true);
|
||||
return this.word(DV.getUint32(0, true)).word(DV.getUint32(4, true));
|
||||
}
|
||||
str(s) { this.word(0x5354 ^ s.length); for (let i = 0; i < s.length; i++) this.word(s.charCodeAt(i)); return this; }
|
||||
// null / undefined / booleans / numbers / strings / arrays / plain objects (keys in their own order, which is the
|
||||
// order the same code created them in, in any engine)
|
||||
value(v, depth = 0) {
|
||||
if (v === null || v === undefined) return this.word(v === null ? 0x6e756c6c : 0x756e6466);
|
||||
switch (typeof v) {
|
||||
case 'number': return this.num(v);
|
||||
case 'string': return this.str(v);
|
||||
case 'boolean': return this.word(v ? 0x74727565 : 0x66616c73);
|
||||
case 'bigint': return this.str(v.toString());
|
||||
case 'object': break;
|
||||
default: return this.word(0x66756e63);
|
||||
}
|
||||
if (depth > 32) return this.word(0x64656570);
|
||||
if (isList(v)) { this.word(0x41525259 ^ v.length); for (const x of v) this.value(x, depth + 1); return this; }
|
||||
const keys = Object.keys(v);
|
||||
this.word(0x4f424a54 ^ keys.length);
|
||||
for (const k of keys) { this.str(k); this.value(v[k], depth + 1); }
|
||||
return this;
|
||||
}
|
||||
done() { return fmix(this.h ^ this.n); }
|
||||
}
|
||||
|
||||
const hex = (u) => (u >>> 0).toString(16).padStart(8, '0');
|
||||
/** The hash of a list of numbers (a state vector). */
|
||||
function hashNumbers(v) { const h = new Hash(); for (let i = 0; i < v.length; i++) h.num(v[i]); return h.done(); }
|
||||
/** The hash of any plain value. */
|
||||
function hashValue(v) { return new Hash().value(v).done(); }
|
||||
|
||||
/**
|
||||
* The hash of everything reachable from roots: own properties (non-enumerable too, data properties only), arrays,
|
||||
* typed arrays, Maps, Sets; another root met on the way counts as its index, an object met twice counts once, the keys
|
||||
* in `skip` are left out (references back to shared definitions, process-wide counters …).
|
||||
* @param {object[]} roots @param {{ skip?: Iterable<string>, maxDepth?: number }} [o]
|
||||
*/
|
||||
function deepHash(roots, { skip = [], maxDepth = 8 } = {}) {
|
||||
const idx = new Map(roots.map((r, i) => [r, i])), seen = new Set(), skipped = new Set(skip), h = new Hash();
|
||||
const walk = (v, depth) => {
|
||||
if (v === null || v === undefined) { h.word(7); return; }
|
||||
const t = typeof v;
|
||||
if (t === 'number') { h.num(v); return; }
|
||||
if (t === 'string') { h.str(v); return; }
|
||||
if (t === 'boolean') { h.word(v ? 3 : 5); return; }
|
||||
if (t !== 'object' || depth > maxDepth) return;
|
||||
if (depth > 0 && idx.has(v)) { h.word(0x55550000 + idx.get(v)); return; }
|
||||
if (seen.has(v)) { h.word(11); return; }
|
||||
seen.add(v);
|
||||
if (isList(v)) { h.word(v.length); for (const x of v) walk(x, depth + 1); return; }
|
||||
const kind = tag(v);
|
||||
if (kind === '[object Map]') { h.word(v.size); for (const [k, x] of v) { walk(k, depth + 1); walk(x, depth + 1); } return; }
|
||||
if (kind === '[object Set]') { h.word(v.size); for (const x of v) walk(x, depth + 1); return; }
|
||||
for (const k of Object.getOwnPropertyNames(v)) {
|
||||
if (skipped.has(k)) continue;
|
||||
const d = Object.getOwnPropertyDescriptor(v, k);
|
||||
if (!d || !('value' in d) || typeof d.value === 'function') continue;
|
||||
h.str(k); walk(d.value, depth + 1);
|
||||
}
|
||||
};
|
||||
for (const r of roots) walk(r, 0);
|
||||
return h.done();
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of one scenario, traced as it goes; advance() runs it for a time budget (a page stays responsive), finish()
|
||||
* gives the trace. The adapter:
|
||||
* start(scenario) → run; step(run) → true when a tick was simulated, false when the run is over;
|
||||
* state(run) → number[] (after every tick); labels?(run) → string[] (the names of state's entries, for dumps);
|
||||
* view?(run) → plain value; deep?(run) → uint32; result(run) → string; maxTicks? (a runaway guard)
|
||||
* dump: [first, last] ticks whose state vectors (and labels) are kept.
|
||||
*/
|
||||
function tracer(adapter, scenario, { every = 30, deepEvery = every * 5, dump = null } = {}) {
|
||||
const run = adapter.start(scenario), max = adapter.maxTicks || 1e6;
|
||||
let t = 0, block = new Hash(), over = false;
|
||||
const blocks = [], views = [], deeps = [], dumps = {};
|
||||
const sample = (end) => {
|
||||
if (adapter.view) views.push(hashValue(adapter.view(run)));
|
||||
if (adapter.deep && (end || t % deepEvery === 0)) deeps.push(adapter.deep(run) >>> 0);
|
||||
};
|
||||
return {
|
||||
get ticks() { return t; },
|
||||
get done() { return over; },
|
||||
advance(budgetMs = Infinity) {
|
||||
const until = budgetMs === Infinity ? Infinity : now() + budgetMs;
|
||||
while (!over) {
|
||||
if (t >= max || !adapter.step(run)) {
|
||||
over = true;
|
||||
if (t % every) { blocks.push(block.done()); sample(true); } else if (adapter.deep && t % deepEvery) deeps.push(adapter.deep(run) >>> 0);
|
||||
break;
|
||||
}
|
||||
t++;
|
||||
const s = adapter.state(run);
|
||||
block.word(hashNumbers(s));
|
||||
if (dump && t >= dump[0] && t <= dump[1]) dumps[t] = { state: Array.from(s, (x) => (x !== x ? 'NaN' : x)), labels: adapter.labels ? adapter.labels(run) : null };
|
||||
if (t % every === 0) { blocks.push(block.done()); block = new Hash(); sample(false); }
|
||||
if ((t & 31) === 0 && now() > until) break;
|
||||
}
|
||||
return over;
|
||||
},
|
||||
finish() {
|
||||
while (!over) this.advance();
|
||||
return { ticks: t, blocks: blocks.map(hex).join(''), views: views.map(hex).join(''), deeps: deeps.map(hex).join(''), result: String(adapter.result(run)), ...(dump ? { dumps } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
/** A whole run's trace (see tracer). */
|
||||
function trace(adapter, scenario, opts) { return tracer(adapter, scenario, opts).finish(); }
|
||||
|
||||
const firstDiff = (a = '', b = '') => {
|
||||
const n = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < n; i += 8) if (a.slice(i, i + 8) !== b.slice(i, i + 8)) return i / 8;
|
||||
return -1;
|
||||
};
|
||||
/**
|
||||
* A trace against its reference. ok, or: `at` — where they first parted (level 'state' / 'view' / 'deep', the block,
|
||||
* its ticks [from, to]); `result` — the outcomes differ; `ticks` — the runs' lengths differ. The level whose difference
|
||||
* shows first is the one reported — by the tick it is seen at (a block's or a sample's last tick; a deep sample covers
|
||||
* deepEvery ticks), the finer level on a tie; a run whose state never differs but whose view does still counts.
|
||||
*/
|
||||
function compare(ref, got, every = 30, deepEvery = every * 5) {
|
||||
if (!ref) return { ok: false, missing: true };
|
||||
let at = null;
|
||||
for (const level of ['state', 'view', 'deep']) {
|
||||
const key = level === 'state' ? 'blocks' : level === 'view' ? 'views' : 'deeps', span = level === 'deep' ? deepEvery : every;
|
||||
const i = firstDiff(ref[key], got[key]);
|
||||
if (i < 0) continue;
|
||||
const to = Math.min((i + 1) * span, Math.max(ref.ticks, got.ticks));
|
||||
if (!at || to < at.to) at = { level, block: i, from: i * span + 1, to };
|
||||
}
|
||||
const result = ref.result !== got.result, ticks = ref.ticks !== got.ticks;
|
||||
return { ok: !at && !result && !ticks, at, result, ticks: ticks ? [ref.ticks, got.ticks] : null };
|
||||
}
|
||||
/** How bad a comparison is, for sorting and summaries: 3 the outcome differs, 2 what viewers see, 1 only inside. */
|
||||
function severity(c) {
|
||||
if (c.ok) return 0;
|
||||
if (c.missing || c.result || c.ticks) return 3;
|
||||
return c.at && c.at.level !== 'deep' ? 2 : 1;
|
||||
}
|
||||
|
||||
const bitsOf = (x) => {
|
||||
if (x === 'NaN') return 'NaN';
|
||||
DV.setFloat64(0, x, true);
|
||||
return hex(DV.getUint32(4, true)) + hex(DV.getUint32(0, true));
|
||||
};
|
||||
/**
|
||||
* Two dumps of the same ticks (tracer `dump`) compared field by field: the differences in tick order, each with the
|
||||
* tick, the entry's index and label, both values and their bits. Stops after `limit`.
|
||||
*/
|
||||
function diffDumps(a, b, limit = 20) {
|
||||
const out = [];
|
||||
const ticks = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].map(Number).sort((x, y) => x - y);
|
||||
for (const t of ticks) {
|
||||
const x = a[t], y = b[t];
|
||||
if (!x || !y) { out.push({ tick: t, index: -1, label: x ? 'missing on the other side' : 'missing in the reference' }); if (out.length >= limit) break; continue; }
|
||||
const n = Math.max(x.state.length, y.state.length);
|
||||
for (let i = 0; i < n && out.length < limit; i++) {
|
||||
const p = x.state[i], q = y.state[i];
|
||||
if (p === q || (p !== undefined && q !== undefined && bitsOf(p) === bitsOf(q))) continue;
|
||||
out.push({ tick: t, index: i, label: (x.labels && x.labels[i]) || (y.labels && y.labels[i]) || `#${i}`, ref: p, got: q, refBits: p === undefined ? '' : bitsOf(p), gotBits: q === undefined ? '' : bitsOf(q) });
|
||||
}
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const VERSION = 1;
|
||||
export { VERSION, Hash, hex, hashNumbers, hashValue, deepHash, tracer, trace, compare, severity, diffDumps };
|
||||
Reference in new issue
Block a user