@driftengine/network 3.61.0

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.
Files changed (49) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +125 -0
  4. package/dist/authority.d.ts +170 -0
  5. package/dist/authority.js +235 -0
  6. package/dist/fingerprint.d.ts +64 -0
  7. package/dist/fingerprint.js +108 -0
  8. package/dist/fixture.d.ts +32 -0
  9. package/dist/fixture.js +136 -0
  10. package/dist/index.d.ts +45 -0
  11. package/dist/index.js +32 -0
  12. package/dist/inputLog.d.ts +117 -0
  13. package/dist/inputLog.js +232 -0
  14. package/dist/lockstep.d.ts +181 -0
  15. package/dist/lockstep.js +291 -0
  16. package/dist/loopback.d.ts +115 -0
  17. package/dist/loopback.js +207 -0
  18. package/dist/rewind.d.ts +206 -0
  19. package/dist/rewind.js +212 -0
  20. package/dist/scriptSession.d.ts +77 -0
  21. package/dist/scriptSession.js +81 -0
  22. package/dist/simNumber.d.ts +103 -0
  23. package/dist/simNumber.js +156 -0
  24. package/dist/snapshotter.d.ts +43 -0
  25. package/dist/snapshotter.js +81 -0
  26. package/dist/transport.d.ts +63 -0
  27. package/dist/transport.js +40 -0
  28. package/dist/webrtc.d.ts +74 -0
  29. package/dist/webrtc.js +108 -0
  30. package/dist/websocket.d.ts +70 -0
  31. package/dist/websocket.js +105 -0
  32. package/dist/wire.d.ts +94 -0
  33. package/dist/wire.js +238 -0
  34. package/package.json +61 -0
  35. package/src/authority.ts +340 -0
  36. package/src/fingerprint.ts +143 -0
  37. package/src/fixture.ts +188 -0
  38. package/src/index.ts +95 -0
  39. package/src/inputLog.ts +262 -0
  40. package/src/lockstep.ts +374 -0
  41. package/src/loopback.ts +250 -0
  42. package/src/rewind.ts +337 -0
  43. package/src/scriptSession.ts +137 -0
  44. package/src/simNumber.ts +176 -0
  45. package/src/snapshotter.ts +98 -0
  46. package/src/transport.ts +69 -0
  47. package/src/webrtc.ts +141 -0
  48. package/src/websocket.ts +140 -0
  49. package/src/wire.ts +269 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * One machine's state is the truth, and everybody else predicts and corrects.
3
+ *
4
+ * **The correction is a rewind**, which is why this file is short. A client that mispredicted has
5
+ * computed some ticks from a world that was wrong; putting the authority's world back at that tick
6
+ * and replaying the local inputs over it is exactly `reconcileSnapshot`. There is no second
7
+ * mechanism here, only a different reason for reaching the same one.
8
+ *
9
+ * ---
10
+ *
11
+ * ## What crosses, and who decides
12
+ *
13
+ * A `Replicator` is supplied by the consumer. This package cannot know which of a game's fields are
14
+ * authoritative, which are cosmetic, or how to pack them — and an engine that did would be a
15
+ * particular game's engine. What it does own is *when*: the host publishes on a cadence, and the
16
+ * client applies at the tick the message names.
17
+ *
18
+ * ## Prediction is for the local participant only
19
+ *
20
+ * A client predicts *its own* entity from its own inputs, immediately, because the whole point is
21
+ * that a player's controls feel attached to their hands. It does **not** extrapolate other players
22
+ * forward. Extrapolation is a choice between showing a stale position and showing a wrong one, and
23
+ * which is worse depends on the game: a wrong position in a shooter decides a hit, and a stale one
24
+ * in a racing game is a car in the wrong place on a corner. So remote bodies are *interpolated*
25
+ * between two states the authority actually sent — `StateInterpolator` below — and anything more
26
+ * aggressive is a consumer's to write.
27
+ *
28
+ * ## What is not here
29
+ *
30
+ * **Lag compensation.** An authority rewinding the world to a shooter's view of the past, so their
31
+ * shot hits what was on their screen, is a real technique and a policy about who wins a disputed
32
+ * shot. `RewindLoop.rewindTo` is public so it can be built; choosing it for every game is not this
33
+ * package's business.
34
+ *
35
+ * **Interest management.** A world large enough that a client should not receive all of it needs
36
+ * spatial partitioning of replication state. Reversed by a consumer measuring replication bandwidth
37
+ * as their constraint.
38
+ */
39
+ import type { InputLog } from './inputLog.ts';
40
+ import type { RewindLoop } from './rewind.ts';
41
+ import { BROADCAST, type PeerId, type Transport } from './transport.ts';
42
+ import {
43
+ MESSAGE_INPUT,
44
+ MESSAGE_STATE,
45
+ createDecoded,
46
+ decode,
47
+ encodeInput,
48
+ encodeState,
49
+ } from './wire.ts';
50
+
51
+ /**
52
+ * What a consumer packs and unpacks.
53
+ *
54
+ * `encode` answers -1 when the state does not fit, for the reason the wire encoders do: the caller
55
+ * is a frame loop and `AGENTS.md` forbids throwing in one.
56
+ */
57
+ export interface Replicator {
58
+ encode(into: Uint8Array, tick: number): number;
59
+ apply(from: Uint8Array, tick: number): void;
60
+ }
61
+
62
+ export interface AuthorityOptions<S> {
63
+ readonly transport: Transport;
64
+ readonly loop: RewindLoop<S>;
65
+ readonly inputs: InputLog;
66
+ readonly replicator: Replicator;
67
+ /**
68
+ * How often the authoritative state is published, in ticks.
69
+ *
70
+ * **Three at 60 Hz is twenty a second**, which is what most shipped netcode settles near: often
71
+ * enough that a correction is small, rare enough that the bandwidth is a fraction of what a
72
+ * per-tick broadcast costs. A consumer whose state is tiny can publish every tick and one whose
73
+ * world is large should publish less and send deltas.
74
+ */
75
+ readonly stateEvery?: number;
76
+ /** Room for one encoded state. 8 KB by default; a state that does not fit is a state to delta. */
77
+ readonly maxStateBytes?: number;
78
+ }
79
+
80
+ /** The machine whose world is the truth. */
81
+ export class AuthorityHost<S> {
82
+ private readonly transport: Transport;
83
+ private readonly loop: RewindLoop<S>;
84
+ private readonly inputs: InputLog;
85
+ private readonly replicator: Replicator;
86
+ private readonly stateEvery: number;
87
+ private readonly outgoing: Uint8Array;
88
+ private readonly decoded = createDecoded();
89
+ private published = 0;
90
+
91
+ constructor(options: AuthorityOptions<S>) {
92
+ this.transport = options.transport;
93
+ this.loop = options.loop;
94
+ this.inputs = options.inputs;
95
+ this.replicator = options.replicator;
96
+ this.stateEvery = Math.max(1, Math.trunc(options.stateEvery ?? 3));
97
+ this.outgoing = new Uint8Array(16 + Math.max(64, options.maxStateBytes ?? 8192));
98
+ }
99
+
100
+ /** How many state messages have gone out. For a consumer measuring what replication costs. */
101
+ get publishedStates(): number {
102
+ return this.published;
103
+ }
104
+
105
+ poll(): void {
106
+ this.transport.drain((_from, message) => {
107
+ if (!decode(message, this.decoded)) return;
108
+ if (this.decoded.kind !== MESSAGE_INPUT) return;
109
+
110
+ const stride = this.decoded.payloadBytes;
111
+ for (let i = 0; i < this.decoded.count; i++) {
112
+ const tick = this.decoded.tick + i;
113
+ const at = this.decoded.payloadAt + i * stride;
114
+ if (this.inputs.isConfirmed(this.decoded.participant, tick)) continue;
115
+ this.loop.supply(this.decoded.participant, tick, message.subarray(at, at + stride));
116
+ }
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Take a tick, then publish if this is a publishing tick.
122
+ *
123
+ * **The state is encoded *after* the step**, so it describes the world at the end of `tick`, and
124
+ * a client applying it reconciles at `tick + 1`. Publishing before the step would send the world
125
+ * as it was when the tick began, which is the state the client already had.
126
+ */
127
+ advance(dt: number, tick: number): void {
128
+ this.loop.advance(dt, tick);
129
+ const earliest = this.loop.earliest;
130
+ if (earliest > 0) this.inputs.retain(earliest);
131
+
132
+ if (tick % this.stateEvery !== 0) return;
133
+ const bytes = this.replicator.encode(this.outgoing.subarray(16), tick);
134
+ if (bytes < 0) return;
135
+ const length = encodeState(this.outgoing, tick + 1, this.outgoing.subarray(16, 16 + bytes));
136
+ if (length < 0) return;
137
+ this.published += 1;
138
+ this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
139
+ }
140
+ }
141
+
142
+ export interface ClientOptions<S> {
143
+ readonly transport: Transport;
144
+ readonly loop: RewindLoop<S>;
145
+ readonly inputs: InputLog;
146
+ readonly replicator: Replicator;
147
+ readonly self: PeerId;
148
+ /** Predict the local participant. Off means wait for the authority, which is the control. */
149
+ readonly predict?: boolean;
150
+ /** Recent inputs carried in every packet, for the reason `LockstepSession` documents. */
151
+ readonly redundancy?: number;
152
+ }
153
+
154
+ /** A machine that predicts its own player and corrects toward the authority. */
155
+ export class PredictingClient<S> {
156
+ readonly self: PeerId;
157
+
158
+ private readonly transport: Transport;
159
+ private readonly loop: RewindLoop<S>;
160
+ private readonly inputs: InputLog;
161
+ private readonly replicator: Replicator;
162
+ private readonly predict: boolean;
163
+ private readonly redundancy: number;
164
+ private readonly recent: Uint8Array;
165
+ private readonly outgoing: Uint8Array;
166
+ private readonly decoded = createDecoded();
167
+
168
+ private recentFrom = -1;
169
+ private recentCount = 0;
170
+ private correctionsValue = 0;
171
+ private lastAppliedState = -1;
172
+
173
+ constructor(options: ClientOptions<S>) {
174
+ this.transport = options.transport;
175
+ this.loop = options.loop;
176
+ this.inputs = options.inputs;
177
+ this.replicator = options.replicator;
178
+ this.self = options.self;
179
+ this.predict = options.predict ?? true;
180
+ this.redundancy = Math.max(1, Math.trunc(options.redundancy ?? 4));
181
+ this.recent = new Uint8Array(this.redundancy * this.inputs.inputBytes);
182
+ this.outgoing = new Uint8Array(64 + this.redundancy * this.inputs.inputBytes);
183
+ }
184
+
185
+ /** How many authoritative states have been applied. Each one is a rewind. */
186
+ get corrections(): number {
187
+ return this.correctionsValue;
188
+ }
189
+
190
+ /**
191
+ * Send this input and, when predicting, apply it locally on the same tick.
192
+ *
193
+ * **No input delay**, unlike lockstep. A delay there buys exactness for everybody; here the
194
+ * authority is the source of truth anyway, so delaying the local input would add latency to the
195
+ * player's own controls and buy nothing.
196
+ */
197
+ submit(tick: number, payload: Uint8Array): void {
198
+ if (this.predict) this.inputs.set(this.self, tick, payload);
199
+ this.remember(tick, payload);
200
+
201
+ const length = encodeInput(
202
+ this.outgoing,
203
+ this.self,
204
+ this.recentFrom,
205
+ this.recent,
206
+ this.inputs.inputBytes,
207
+ this.recentCount,
208
+ );
209
+ if (length < 0) return;
210
+ this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
211
+ }
212
+
213
+ poll(): void {
214
+ this.transport.drain((_from, message) => {
215
+ if (!decode(message, this.decoded)) return;
216
+ if (this.decoded.kind !== MESSAGE_STATE) return;
217
+
218
+ const tick = this.decoded.tick;
219
+ /* An older state than one already applied is a reordered packet, not news. */
220
+ if (tick <= this.lastAppliedState) return;
221
+
222
+ const payload = message.subarray(
223
+ this.decoded.payloadAt,
224
+ this.decoded.payloadAt + this.decoded.payloadBytes,
225
+ );
226
+
227
+ /*
228
+ * **A state for a tick the ring no longer holds is applied whole rather than reconciled.**
229
+ * There is nothing left to replay over it — the local history that far back is gone — so the
230
+ * honest response is to accept the authority's world and carry on from there. That is a
231
+ * visible jump, and it is what a client that has been away should see.
232
+ */
233
+ const applied = this.loop.reconcileSnapshot(tick, () => this.replicator.apply(payload, tick));
234
+ if (!applied) this.replicator.apply(payload, tick);
235
+
236
+ this.lastAppliedState = tick;
237
+ this.correctionsValue += 1;
238
+ });
239
+ }
240
+
241
+ advance(dt: number, tick: number): void {
242
+ this.loop.advance(dt, tick);
243
+ const earliest = this.loop.earliest;
244
+ if (earliest > 0) this.inputs.retain(earliest);
245
+ }
246
+
247
+ private remember(tick: number, payload: Uint8Array): void {
248
+ const stride = this.inputs.inputBytes;
249
+ if (this.recentFrom < 0 || tick !== this.recentFrom + this.recentCount) {
250
+ this.recentFrom = tick;
251
+ this.recentCount = 0;
252
+ }
253
+ if (this.recentCount === this.redundancy) {
254
+ this.recent.copyWithin(0, stride);
255
+ this.recentFrom += 1;
256
+ this.recentCount -= 1;
257
+ }
258
+ const at = this.recentCount * stride;
259
+ for (let i = 0; i < stride; i++) this.recent[at + i] = payload[i] ?? 0;
260
+ this.recentCount += 1;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Two authoritative states and where between them to draw.
266
+ *
267
+ * **Rendering at a delay is what stops a remote body teleporting.** States arrive every few ticks
268
+ * and unevenly; drawing the newest one puts a body wherever the last packet said, which on a jittery
269
+ * link is a stutter. Drawing *between* the two most recent, a fixed distance behind the newest, costs
270
+ * that distance in latency and buys smooth motion — and the latency is honest, because the position
271
+ * shown is one the authority actually published rather than one guessed forward.
272
+ *
273
+ * This owns the timing and none of the content: `sample` answers which two payloads and how far
274
+ * between, and the consumer decodes and blends. It cannot do otherwise, since the payload's shape is
275
+ * the consumer's.
276
+ */
277
+ export class StateInterpolator {
278
+ readonly delayTicks: number;
279
+
280
+ private readonly ticks: Int32Array;
281
+ private readonly payloads: Uint8Array[];
282
+ private readonly lengths: Int32Array;
283
+ private count = 0;
284
+ private cursor = 0;
285
+
286
+ constructor(capacity = 8, maxBytes = 8192, delayTicks = 4) {
287
+ const slots = Math.max(2, Math.trunc(capacity));
288
+ this.ticks = new Int32Array(slots).fill(-1);
289
+ this.lengths = new Int32Array(slots);
290
+ this.payloads = Array.from({ length: slots }, () => new Uint8Array(maxBytes));
291
+ this.delayTicks = Math.max(0, Math.trunc(delayTicks));
292
+ }
293
+
294
+ /** Keep a state. The bytes are copied, because the transport reuses its buffer. */
295
+ push(tick: number, payload: Uint8Array): void {
296
+ const slot = this.cursor;
297
+ const room = this.payloads[slot] as Uint8Array;
298
+ const length = Math.min(payload.length, room.length);
299
+ room.set(payload.subarray(0, length));
300
+ this.lengths[slot] = length;
301
+ this.ticks[slot] = tick;
302
+ this.cursor = (this.cursor + 1) % this.ticks.length;
303
+ if (this.count < this.ticks.length) this.count += 1;
304
+ }
305
+
306
+ /**
307
+ * The two states to blend at `renderTick`, and how far between them, or null.
308
+ *
309
+ * `alpha` is clamped to [0, 1]: past the newest state it holds there rather than extrapolating,
310
+ * which is the same choice `TickTrace.sample` makes at the end of a recording and for the same
311
+ * reason — a character who has finished should look like they finished.
312
+ */
313
+ sample(renderTick: number): { from: Uint8Array; to: Uint8Array; alpha: number } | null {
314
+ if (this.count === 0) return null;
315
+ const target = renderTick - this.delayTicks;
316
+
317
+ let before = -1;
318
+ let after = -1;
319
+ for (let i = 0; i < this.ticks.length; i++) {
320
+ const at = this.ticks[i] as number;
321
+ if (at < 0) continue;
322
+ if (at <= target && (before < 0 || at > (this.ticks[before] as number))) before = i;
323
+ if (at > target && (after < 0 || at < (this.ticks[after] as number))) after = i;
324
+ }
325
+
326
+ if (before < 0 && after < 0) return null;
327
+ if (before < 0) return { from: this.slice(after), to: this.slice(after), alpha: 0 };
328
+ if (after < 0) return { from: this.slice(before), to: this.slice(before), alpha: 1 };
329
+
330
+ const fromTick = this.ticks[before] as number;
331
+ const toTick = this.ticks[after] as number;
332
+ const span = toTick - fromTick;
333
+ const alpha = span <= 0 ? 1 : Math.min(1, Math.max(0, (target - fromTick) / span));
334
+ return { from: this.slice(before), to: this.slice(after), alpha };
335
+ }
336
+
337
+ private slice(slot: number): Uint8Array {
338
+ return (this.payloads[slot] as Uint8Array).subarray(0, this.lengths[slot] as number);
339
+ }
340
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A hash of simulation state, so a divergence has a tick number attached to it.
3
+ *
4
+ * **The value is not detection, it is *localisation*.** Two peers whose worlds have drifted apart
5
+ * will notice eventually, because something visible will differ; what they cannot do without this is
6
+ * say *when* it started. A fingerprint per confirmed tick turns "we are out of sync" into "we
7
+ * disagreed first at tick 4,182", and the tick is the whole of the debugging: it names the inputs
8
+ * that were live, the entities that moved, and the one step to re-run under a debugger.
9
+ *
10
+ * Two FNV-1a streams over the bytes, which is the shape `fingerprintColliders` and
11
+ * `fingerprintChemistry` already use here. Sixty-four bits from two thirty-two-bit streams rather
12
+ * than one, because a thirty-two-bit hash collides at about seventy thousand samples by the birthday
13
+ * bound and a session is sixty samples a second.
14
+ *
15
+ * ---
16
+ *
17
+ * ## Floats are hashed as bits, and that is the point
18
+ *
19
+ * A hash of `x.toString()` or of a rounded value would agree across a difference of one ulp, which
20
+ * is exactly the difference this exists to find. So every number goes in as its eight bytes.
21
+ *
22
+ * **A consequence worth stating: `-0` and `+0` hash differently, and `NaN` hashes as whatever
23
+ * payload produced it.** Both are correct — two peers holding different zeroes have genuinely
24
+ * diverged, and a simulation that has produced a NaN has a bigger problem than a fingerprint.
25
+ */
26
+ import type { WorldSnapshot } from '@driftengine/entities';
27
+
28
+ const OFFSET_BASIS = 0x811c9dc5;
29
+ const PRIME = 0x01000193;
30
+
31
+ /**
32
+ * An FNV-1a pair, fed bytes and numbers, answering sixteen hex characters.
33
+ *
34
+ * A class rather than a function over one buffer, because state worth fingerprinting is rarely one
35
+ * buffer: a consumer combines a world, a generator's position and whatever they keep beside both,
36
+ * and the order they feed it in is theirs to fix.
37
+ */
38
+ export class Fingerprint {
39
+ private low = OFFSET_BASIS;
40
+ private high = OFFSET_BASIS;
41
+ private readonly scratch = new DataView(new ArrayBuffer(8));
42
+
43
+ /** Start again, so one instance serves a whole session without allocating. */
44
+ reset(): this {
45
+ this.low = OFFSET_BASIS;
46
+ this.high = OFFSET_BASIS;
47
+ return this;
48
+ }
49
+
50
+ byte(value: number): this {
51
+ const b = value & 0xff;
52
+ this.low = Math.imul(this.low ^ b, PRIME) >>> 0;
53
+ /*
54
+ * The high stream consumes the low stream's *current* state rather than the same byte, which is
55
+ * what makes the two halves independent. Two streams fed identical input with different bases
56
+ * stay correlated and the pair is worth barely more than one of them.
57
+ */
58
+ this.high = Math.imul(this.high ^ (this.low & 0xff), PRIME) >>> 0;
59
+ return this;
60
+ }
61
+
62
+ /** A 32-bit integer, low byte first. */
63
+ int32(value: number): this {
64
+ const v = value | 0;
65
+ this.byte(v);
66
+ this.byte(v >>> 8);
67
+ this.byte(v >>> 16);
68
+ this.byte(v >>> 24);
69
+ return this;
70
+ }
71
+
72
+ /** A double, as its eight bytes. See the header for why not as text. */
73
+ float(value: number): this {
74
+ this.scratch.setFloat64(0, value);
75
+ for (let i = 0; i < 8; i++) this.byte(this.scratch.getUint8(i));
76
+ return this;
77
+ }
78
+
79
+ /** A typed array's bytes, up to `count` elements, or all of them. */
80
+ array(values: ArrayLike<number> & { BYTES_PER_ELEMENT?: number }, count = values.length): this {
81
+ const limit = Math.min(count, values.length);
82
+ this.int32(limit);
83
+ if (values instanceof Float64Array || values instanceof Float32Array) {
84
+ for (let i = 0; i < limit; i++) this.float(values[i] as number);
85
+ return this;
86
+ }
87
+ for (let i = 0; i < limit; i++) this.int32(values[i] as number);
88
+ return this;
89
+ }
90
+
91
+ /** Sixteen lowercase hex characters. */
92
+ digest(): string {
93
+ return hex(this.high) + hex(this.low);
94
+ }
95
+ }
96
+
97
+ function hex(value: number): string {
98
+ return (value >>> 0).toString(16).padStart(8, '0');
99
+ }
100
+
101
+ /**
102
+ * A world snapshot's fingerprint.
103
+ *
104
+ * **The stores are walked in id order rather than in map order.** A `Map` iterates in insertion
105
+ * order, and two peers create their stores in whatever order their code first asked for them —
106
+ * which is a property of the load path, not of the world. Sorting makes the hash a function of the
107
+ * state instead of a function of the state and the startup sequence.
108
+ *
109
+ * The allocator goes in first, and `highWater` with it: two worlds holding identical components on
110
+ * identical handles but differing in how many slots have ever been used are genuinely different,
111
+ * because the next spawn differs.
112
+ */
113
+ export function fingerprintSnapshot(slot: WorldSnapshot, into = new Fingerprint()): string {
114
+ into.reset();
115
+
116
+ const allocator = slot.allocator;
117
+ into.int32(allocator.highWater);
118
+ into.int32(allocator.aliveCount);
119
+ into.array(allocator.generations, allocator.highWater);
120
+ into.array(allocator.live, allocator.highWater);
121
+ into.array(allocator.free, allocator.freeCount);
122
+
123
+ const ids = [...slot.stores.keys()].sort((a, b) => a - b);
124
+ into.int32(ids.length);
125
+ for (const id of ids) {
126
+ const store = slot.stores.get(id);
127
+ if (store === undefined) continue;
128
+ into.int32(id);
129
+ into.int32(store.count);
130
+ into.array(store.dense, store.count);
131
+
132
+ /* Columns by name, sorted, for the reason the stores are: a schema's field order is a property
133
+ of the declaration and a map's order is a property of construction. */
134
+ const fields = [...store.columns.keys()].sort();
135
+ for (const field of fields) {
136
+ const column = store.columns.get(field);
137
+ if (column === undefined || Array.isArray(column)) continue;
138
+ into.array(column, store.count);
139
+ }
140
+ }
141
+
142
+ return into.digest();
143
+ }
package/src/fixture.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * One simulation, written twice: once in doubles behind the exactness gate, once in fixed point.
3
+ *
4
+ * **This is `SimNumber`'s consumer, and it exists because of a rule.** `AGENTS.md`: a feature
5
+ * nothing turns on is written rather than ported. An opt-in numeric type with no user in the
6
+ * repository would ship as untested surface area whose first real consumer finds its bugs — so the
7
+ * conformance fixture is written in it, and the exit test, the cross-machine script and the browser
8
+ * check all run both arms.
9
+ *
10
+ * **The two arms are not expected to agree with each other**, and a test asserting they did would be
11
+ * asserting something false: fixed point truncates where floating point rounds, so the arms diverge
12
+ * by design within the first few ticks. What each must be is *reproducible* — identical to itself
13
+ * across a rewind, across a process, and across a machine.
14
+ *
15
+ * ---
16
+ *
17
+ * ## What it simulates, and why it is this and not something simpler
18
+ *
19
+ * Bodies on a ring, each steered toward the one ahead and slowed by drag. It is chosen for what it
20
+ * exercises rather than for what it depicts:
21
+ *
22
+ * - **A transcendental per body per tick**, because that is where two engines disagree. The
23
+ * floating arm calls `exactSin`/`exactCos`; the fixed arm uses a table, since a fixed-point sine
24
+ * is a table by construction.
25
+ * - **A square root**, which is the operation `Math.sqrt` is exactly rounded for and `simSqrt` has
26
+ * to earn by Newton iteration.
27
+ * - **A division**, which is the one operation the fixed arm is deterministic at rather than exact.
28
+ * - **Accumulated rotation**, so a one-ulp difference on tick one is visible by tick a thousand
29
+ * instead of staying below the noise.
30
+ *
31
+ * A simulation of one body moving in a straight line would be reproducible on any implementation of
32
+ * anything, and would prove nothing.
33
+ */
34
+ import {
35
+ SIM_ONE,
36
+ type Sim,
37
+ simAdd,
38
+ simDiv,
39
+ simFrom,
40
+ simMul,
41
+ simSqrt,
42
+ simSub,
43
+ simTo,
44
+ } from './simNumber.ts';
45
+ import { Fingerprint } from './fingerprint.ts';
46
+
47
+ export const FIXTURE_BODIES = 12;
48
+
49
+ /** How many entries the fixed arm's sine table holds over a full turn. A power of two, so the */
50
+ /* index arithmetic is a mask rather than a modulo, and the same index is reached on every engine. */
51
+ const TABLE_SIZE = 1024;
52
+ const TABLE_MASK = TABLE_SIZE - 1;
53
+ const TWO_PI = 6.283185307179586;
54
+
55
+ /**
56
+ * The trigonometry, supplied rather than imported.
57
+ *
58
+ * **`@driftengine/core` is a devDependency here and must stay one.** This package imports
59
+ * `@driftengine/entities` at runtime and nothing else, which is the property that lets an
60
+ * authoritative host run it with no renderer in its module graph — and `boundaries.test.mjs`
61
+ * asserts it against the manifest rather than trusting the prose. So the fixture takes its
62
+ * reproducible sine and cosine as a capability, the way everything else in this package takes its
63
+ * dependencies, and a caller passes core's `exactSin` and `exactCos`.
64
+ */
65
+ export interface FixtureMath {
66
+ sin(radians: number): number;
67
+ cos(radians: number): number;
68
+ }
69
+
70
+ export interface FixtureResult {
71
+ /** A hash of the whole state at the end. What another machine compares. */
72
+ readonly digest: string;
73
+ /** The first body's position, for a check that wants a number rather than a hash. */
74
+ readonly x: number;
75
+ readonly y: number;
76
+ }
77
+
78
+ /**
79
+ * The pair of arms, sharing one sine table and one starting state.
80
+ *
81
+ * `runFloat` uses only what `scripts/determinism.mjs` permits: arithmetic, `Math.sqrt`, and the
82
+ * supplied transcendentals. `runFixed` uses only `simNumber`.
83
+ */
84
+ export function createFixture(math: FixtureMath): {
85
+ runFloat(ticks: number): FixtureResult;
86
+ runFixed(ticks: number): FixtureResult;
87
+ } {
88
+ /*
89
+ * The fixed arm's sine, as a table built once from the floating arm's own function.
90
+ *
91
+ * **A table rather than a fixed-point polynomial**, and the reason is the point of the whole arm:
92
+ * a polynomial would be arithmetic whose exactness needs an argument, and a table is a list of
93
+ * numbers. It is built from the same `sin` the floating arm calls, so the two arms start from one
94
+ * function rather than from two people's idea of one, and it is built here rather than on a tick.
95
+ */
96
+ const table = new Int32Array(TABLE_SIZE);
97
+ for (let i = 0; i < TABLE_SIZE; i++) {
98
+ table[i] = Math.round(math.sin((i / TABLE_SIZE) * TWO_PI) * SIM_ONE);
99
+ }
100
+
101
+ const simSin = (angle: Sim): Sim => {
102
+ /* Angle in turns rather than radians, so the index is a multiply and a mask. */
103
+ const turns = simDiv(angle, simFrom(TWO_PI));
104
+ const index = Math.floor((turns / SIM_ONE) * TABLE_SIZE) & TABLE_MASK;
105
+ return table[index] as number;
106
+ };
107
+ const simCos = (angle: Sim): Sim => simSin(simAdd(angle, simFrom(TWO_PI / 4)));
108
+
109
+ return { runFloat, runFixed };
110
+
111
+ function runFloat(ticks: number): FixtureResult {
112
+ const x = new Float64Array(FIXTURE_BODIES);
113
+ const y = new Float64Array(FIXTURE_BODIES);
114
+ const angle = new Float64Array(FIXTURE_BODIES);
115
+ const speed = new Float64Array(FIXTURE_BODIES);
116
+
117
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
118
+ x[i] = i * 0.37;
119
+ y[i] = -i * 0.11;
120
+ angle[i] = i * 0.19;
121
+ speed[i] = 1 + i * 0.013;
122
+ }
123
+
124
+ const dt = 1 / 60;
125
+ for (let tick = 0; tick < ticks; tick++) {
126
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
127
+ const ahead = (i + 1) % FIXTURE_BODIES;
128
+ const dx = (x[ahead] as number) - (x[i] as number);
129
+ const dy = (y[ahead] as number) - (y[i] as number);
130
+ const distance = Math.sqrt(dx * dx + dy * dy);
131
+ const steer = distance > 0 ? dy / (distance + 1) : 0;
132
+
133
+ angle[i] = (angle[i] as number) + steer * dt;
134
+ speed[i] = (speed[i] as number) * 0.99 + 0.02;
135
+ x[i] = (x[i] as number) + math.cos(angle[i] as number) * (speed[i] as number) * dt;
136
+ y[i] = (y[i] as number) + math.sin(angle[i] as number) * (speed[i] as number) * dt;
137
+ }
138
+ }
139
+
140
+ const fingerprint = new Fingerprint();
141
+ fingerprint.array(x).array(y).array(angle).array(speed);
142
+ return { digest: fingerprint.digest(), x: x[0] as number, y: y[0] as number };
143
+ }
144
+
145
+ /** The fixed arm. The same simulation, in `Sim`. */
146
+ function runFixed(ticks: number): FixtureResult {
147
+ const x = new Float64Array(FIXTURE_BODIES);
148
+ const y = new Float64Array(FIXTURE_BODIES);
149
+ const angle = new Float64Array(FIXTURE_BODIES);
150
+ const speed = new Float64Array(FIXTURE_BODIES);
151
+
152
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
153
+ x[i] = simFrom(i * 0.37);
154
+ y[i] = simFrom(-i * 0.11);
155
+ angle[i] = simFrom(i * 0.19);
156
+ speed[i] = simFrom(1 + i * 0.013);
157
+ }
158
+
159
+ const dt = simFrom(1 / 60);
160
+ const one = simFrom(1);
161
+ const drag = simFrom(0.99);
162
+ const gain = simFrom(0.02);
163
+
164
+ for (let tick = 0; tick < ticks; tick++) {
165
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
166
+ const ahead = (i + 1) % FIXTURE_BODIES;
167
+ const dx = simSub(x[ahead] as number, x[i] as number);
168
+ const dy = simSub(y[ahead] as number, y[i] as number);
169
+ const distance = simSqrt(simAdd(simMul(dx, dx), simMul(dy, dy)));
170
+ const steer = distance > 0 ? simDiv(dy, simAdd(distance, one)) : 0;
171
+
172
+ angle[i] = simAdd(angle[i] as number, simMul(steer, dt));
173
+ speed[i] = simAdd(simMul(speed[i] as number, drag), gain);
174
+ const step = simMul(speed[i] as number, dt);
175
+ x[i] = simAdd(x[i] as number, simMul(simCos(angle[i] as number), step));
176
+ y[i] = simAdd(y[i] as number, simMul(simSin(angle[i] as number), step));
177
+ }
178
+ }
179
+
180
+ const fingerprint = new Fingerprint();
181
+ fingerprint.array(x).array(y).array(angle).array(speed);
182
+ return {
183
+ digest: fingerprint.digest(),
184
+ x: simTo(x[0] as number),
185
+ y: simTo(y[0] as number),
186
+ };
187
+ }
188
+ }