@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
package/src/rewind.ts ADDED
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Rewind and replay: the one mechanism both networking models sit on.
3
+ *
4
+ * **Prediction and rollback are the same operation seen from two sides.** A lockstep peer that
5
+ * guessed a remote input and then learned the real one has to unwind the ticks it computed from the
6
+ * guess. A predicting client whose authority disagrees has to unwind the ticks it computed from a
7
+ * world that was wrong. Both are: put the state back to tick *T*, correct what was wrong about *T*,
8
+ * and step forward again to where we were. So there is one driver, and §7 of the design is why
9
+ * there is not one per model.
10
+ *
11
+ * ---
12
+ *
13
+ * ## What this owns, and what it refuses to own
14
+ *
15
+ * It owns the ring of snapshots, the input log, and the replay. It owns **no** transport, no wire
16
+ * format and no knowledge of what an input *is*: a payload is bytes a consumer encodes, because an
17
+ * engine that knows a player presses a brake pedal has stopped being game-agnostic. `AGENTS.md`
18
+ * states that rule and this is the place most netcode breaks it.
19
+ *
20
+ * ## Why a snapshot is taken before the step and not after
21
+ *
22
+ * `advance(dt, T)` saves, then steps. So slot *T* holds the world **as it was when tick T began**,
23
+ * which is exactly what a rewind to *T* wants: the input for *T* is about to be applied, and
24
+ * applying it to the state after *T* would double it. Saving after the step would make every rewind
25
+ * land one tick late, which is the kind of error that produces a world that drifts slowly rather
26
+ * than one that breaks.
27
+ *
28
+ * ## Late inputs are batched into one rewind
29
+ *
30
+ * `supply` records the earliest tick that became wrong and returns; the rewind happens on the next
31
+ * `advance`. Three inputs arriving in one network drain, for ticks 40, 41 and 42, cost one replay
32
+ * from 40 instead of three replays from 40, 41 and 42. It also means a consumer cannot forget to
33
+ * ask for the rewind, since the thing they call every tick performs it.
34
+ *
35
+ * ## A tick can be stepped many times, and anything a step *emits* has to know that
36
+ *
37
+ * **This owns state, and an effect is not state.** A rewind restores the world and steps forward
38
+ * again, so a step for tick *T* runs once when it is first predicted and again after every
39
+ * correction that reaches back past it — and everything that step *emitted* the first time is
40
+ * emitted again. Snapshots put the world back; they cannot un-play a sound, un-send a packet or
41
+ * un-spawn a decal.
42
+ *
43
+ * The symptom is a doubled impact on a corrected frame, reported from outside and the reason this
44
+ * section exists. It is not a defect in the rewind: replaying a tick is the whole mechanism, and a
45
+ * step that is not re-runnable is a step that cannot be part of one.
46
+ *
47
+ * **What the engine will not do about it, and why.** Deduplicating an emission needs to know what
48
+ * counts as the same emission — which sound, from which event, at which tick — and that is a
49
+ * game's vocabulary rather than an engine's, the same line `AGENTS.md` draws about a payload being
50
+ * bytes. A table of tick and event id already emitted, cleared behind the rewind window, is about
51
+ * thirty lines in a consumer and knows exactly what an event is.
52
+ *
53
+ * **What to do instead**, in the order that costs least: keep effects out of the fixed step and
54
+ * emit them from the frame instead, reading state the step left behind; or, where an effect really
55
+ * belongs to a tick, gate it on that table. A session's `confirmed` — `LockstepSession` and
56
+ * `ScriptSession` both expose it — is the tick past which nothing can be rewound, so it is the
57
+ * watermark such a table can be cleared behind.
58
+ */
59
+ import type { InputLog } from './inputLog.ts';
60
+
61
+ /**
62
+ * Somewhere state can be put and taken back.
63
+ *
64
+ * Generic over its own slot type, so a rewind needs no serialization at all: a snapshot never
65
+ * leaves the process. `@driftengine/entities` supplies one implementation through
66
+ * `worldSnapshotter`, and a consumer whose simulation state is four typed arrays writes another in
67
+ * thirty lines without touching anything here. That is the `AGENTS.md` rule about platform
68
+ * capabilities applied to state.
69
+ */
70
+ export interface Snapshotter<S> {
71
+ /** A reusable slot. Called once per ring position, never on a tick. */
72
+ create(): S;
73
+ save(into: S): void;
74
+ restore(from: S): void;
75
+ /**
76
+ * A hash of what a slot holds, for comparing this peer's world against another's.
77
+ *
78
+ * **On the snapshotter rather than beside it, because the thing that knows how to save state is
79
+ * the thing that knows how to hash it.** A consumer with their own state writes one function and
80
+ * both mechanisms work; two seams would mean writing it twice and keeping them in step.
81
+ *
82
+ * Optional: a session that never compares worlds needs none.
83
+ */
84
+ digest?(from: S): string;
85
+ }
86
+
87
+ /**
88
+ * One fixed step. The signature `LoopHooks.simulate` has, so a consumer passes theirs straight in.
89
+ *
90
+ * **It may be called more than once for the same `tick`.** That is the mechanism rather than an
91
+ * edge case: every correction reaching back past a tick replays it. A step that only reads and
92
+ * writes simulation state is re-runnable by construction; one that plays a sound, sends a packet or
93
+ * appends to a log is not, and will do that thing again. See this file's header.
94
+ */
95
+ export type SimStep = (dt: number, tick: number) => void;
96
+
97
+ export interface RewindOptions<S> {
98
+ readonly step: SimStep;
99
+ readonly snapshotter: Snapshotter<S>;
100
+ readonly inputs: InputLog;
101
+ /**
102
+ * Seconds per fixed step, and it must be the loop's.
103
+ *
104
+ * Required rather than defaulted, because a replay that stepped a different delta from the run it
105
+ * is replaying produces a different world while looking entirely healthy.
106
+ */
107
+ readonly fixedDt: number;
108
+ /**
109
+ * How many ticks back a rewind can reach. Eight at 60 Hz is 133 ms.
110
+ *
111
+ * The cost is one snapshot per tick of depth, held for the life of the session. §15 of the design
112
+ * carries the measurement.
113
+ */
114
+ readonly depth?: number;
115
+ }
116
+
117
+ export class RewindLoop<S> {
118
+ readonly depth: number;
119
+ readonly fixedDt: number;
120
+ readonly inputs: InputLog;
121
+
122
+ private readonly step: SimStep;
123
+ private readonly snapshotter: Snapshotter<S>;
124
+ private readonly slots: S[] = [];
125
+ /** Which tick each ring position holds, or -1. Guards against a modulo aliasing two ticks. */
126
+ private readonly slotTick: Int32Array;
127
+
128
+ /** The last tick handed to `step`, or -1 before the first. */
129
+ private lastStepped = -1;
130
+ /** The earliest tick a late arrival invalidated, or -1. Consumed by the next `advance`. */
131
+ private dirtyFrom = -1;
132
+ private replaying = false;
133
+ private replays = 0;
134
+ private replayedTicks = 0;
135
+
136
+ constructor(options: RewindOptions<S>) {
137
+ this.depth = Math.max(1, Math.trunc(options.depth ?? 8));
138
+ this.fixedDt = options.fixedDt;
139
+ this.step = options.step;
140
+ this.snapshotter = options.snapshotter;
141
+ this.inputs = options.inputs;
142
+ this.slotTick = new Int32Array(this.depth).fill(-1);
143
+ for (let i = 0; i < this.depth; i++) this.slots.push(this.snapshotter.create());
144
+ }
145
+
146
+ /** The last tick that was stepped. -1 before anything has run. */
147
+ get tick(): number {
148
+ return this.lastStepped;
149
+ }
150
+
151
+ /** Whether `step` is being called by a replay. See `isReplaying` for why a consumer may care. */
152
+ get isReplaying(): boolean {
153
+ return this.replaying;
154
+ }
155
+
156
+ /** How many rewinds have happened, and how many ticks they re-ran. For a consumer's diagnostics. */
157
+ get stats(): { readonly replays: number; readonly replayedTicks: number } {
158
+ return { replays: this.replays, replayedTicks: this.replayedTicks };
159
+ }
160
+
161
+ /** The oldest tick a rewind can still reach, or -1 when nothing is retained. */
162
+ get earliest(): number {
163
+ let oldest = -1;
164
+ for (let i = 0; i < this.depth; i++) {
165
+ const at = this.slotTick[i] as number;
166
+ if (at >= 0 && (oldest < 0 || at < oldest)) oldest = at;
167
+ }
168
+ return oldest;
169
+ }
170
+
171
+ /**
172
+ * Take one tick: settle any pending rewind, save the world as this tick begins, then step.
173
+ *
174
+ * The tick comes from the loop rather than being counted here, so there is one source of truth
175
+ * for it. Handing back a tick already stepped is refused, because it would overwrite a snapshot
176
+ * a rewind may still need and there is no reading of it that is not a caller's bug.
177
+ */
178
+ advance(dt: number, tick: number): void {
179
+ if (tick <= this.lastStepped) {
180
+ throw new Error(
181
+ `tick ${tick} was already advanced (the last was ${this.lastStepped}). A rewind is ` +
182
+ '`supply` or `reconcile` followed by the next `advance`, never an `advance` of a tick ' +
183
+ 'that has run.',
184
+ );
185
+ }
186
+ this.settle();
187
+ this.record(tick);
188
+ this.run(dt, tick);
189
+ }
190
+
191
+ /**
192
+ * An input arrived. `false` when it is for a tick this loop can no longer reach.
193
+ *
194
+ * A refusal rather than a silent drop, because an input outside the rewind window means the
195
+ * session has diverged from this peer's world and nothing local can repair it — which is
196
+ * information a consumer needs, and is the point at which a lockstep session should stall rather
197
+ * than continue.
198
+ */
199
+ supply(participant: number, tick: number, payload: Uint8Array): boolean {
200
+ /*
201
+ * The log's window is asked before the log is written, because `InputLog.set` answers `false`
202
+ * for a refusal and for an input that changed nothing, and those want telling apart here: one
203
+ * is a session that has diverged beyond repair and the other is the ordinary case on a held
204
+ * control. The two windows are also not the same length — the log holds the lookahead as well
205
+ * as the rewind distance — so both are checked.
206
+ */
207
+ if (!this.inputs.holds(tick)) return false;
208
+
209
+ const changed = this.inputs.set(participant, tick, payload);
210
+ if (!changed) return true;
211
+
212
+ /* Only a tick that has already been *stepped* invalidates anything. An input arriving early is
213
+ simply an input, and needs no replay. */
214
+ if (tick > this.lastStepped) return true;
215
+ if (this.earliest < 0 || tick < this.earliest) return false;
216
+
217
+ this.dirtyFrom = this.dirtyFrom < 0 ? tick : Math.min(this.dirtyFrom, tick);
218
+ return true;
219
+ }
220
+
221
+ /**
222
+ * An authoritative state for `tick` arrived: put the world back there, let `apply` overwrite what
223
+ * the authority owns, and replay the local ticks since.
224
+ *
225
+ * **This is the operation a late input is not.** A late input means the ticks since were computed
226
+ * from a wrong *input*; an authoritative state means they were computed from a wrong *world*. So
227
+ * the snapshot for that tick is corrected and re-saved before the replay, and the local inputs are
228
+ * kept, which is what makes a correction converge instead of erasing what the player did.
229
+ *
230
+ * `false` when the tick is outside the window, which for a client means the correction is older
231
+ * than its own history and the honest response is to accept the authority's state whole.
232
+ */
233
+ reconcileSnapshot(tick: number, apply: () => void): boolean {
234
+ const slot = this.slotFor(tick);
235
+ if (slot < 0) return false;
236
+
237
+ const target = this.lastStepped;
238
+ this.snapshotter.restore(this.slots[slot] as S);
239
+ apply();
240
+ /* Re-saved, so a *second* correction for this tick starts from the corrected world rather than
241
+ from the prediction that has already been overruled once. */
242
+ this.snapshotter.save(this.slots[slot] as S);
243
+
244
+ this.replayFrom(tick, target);
245
+ this.dirtyFrom = -1;
246
+ return true;
247
+ }
248
+
249
+ /**
250
+ * Put the world back to the beginning of `tick` and replay to where it was.
251
+ *
252
+ * Public because lag compensation needs it — an authority rewinding to a shooter's view of the
253
+ * past is a real technique and a fairness policy an engine should not choose. The design refuses
254
+ * to implement that policy and exposes the machinery for it, with the reason in §16.
255
+ */
256
+ rewindTo(tick: number): boolean {
257
+ const slot = this.slotFor(tick);
258
+ if (slot < 0) return false;
259
+ const target = this.lastStepped;
260
+ this.snapshotter.restore(this.slots[slot] as S);
261
+ this.replayFrom(tick, target);
262
+ this.dirtyFrom = -1;
263
+ return true;
264
+ }
265
+
266
+ /** Perform a pending rewind, if a late arrival left one. Called by `advance`. */
267
+ private settle(): void {
268
+ if (this.dirtyFrom < 0) return;
269
+ const from = this.dirtyFrom;
270
+ this.dirtyFrom = -1;
271
+ const slot = this.slotFor(from);
272
+ if (slot < 0) return;
273
+ const target = this.lastStepped;
274
+ this.snapshotter.restore(this.slots[slot] as S);
275
+ this.replayFrom(from, target);
276
+ }
277
+
278
+ /**
279
+ * Re-run `from` through `to` inclusive, saving each tick's opening state as it goes.
280
+ *
281
+ * The snapshots are rewritten during a replay and that is required, not incidental: a second
282
+ * rewind to a tick inside this range has to find the state that tick began with *this* time, not
283
+ * the state it began with under the inputs that have since been corrected.
284
+ */
285
+ private replayFrom(from: number, to: number): void {
286
+ this.replaying = true;
287
+ this.replays += 1;
288
+ try {
289
+ for (let at = from; at <= to; at++) {
290
+ this.record(at);
291
+ this.replayedTicks += 1;
292
+ this.run(this.fixedDt, at);
293
+ }
294
+ } finally {
295
+ this.replaying = false;
296
+ }
297
+ this.lastStepped = to;
298
+ }
299
+
300
+ private run(dt: number, tick: number): void {
301
+ this.step(dt, tick);
302
+ if (tick > this.lastStepped) this.lastStepped = tick;
303
+ }
304
+
305
+ /** Save the world as `tick` begins into that tick's ring position. */
306
+ private record(tick: number): void {
307
+ const slot = tick % this.depth;
308
+ this.snapshotter.save(this.slots[slot] as S);
309
+ this.slotTick[slot] = tick;
310
+ }
311
+
312
+ /**
313
+ * A hash of the state as `tick` began, or null when that tick is not retained or the snapshotter
314
+ * offers no digest.
315
+ *
316
+ * **A slot rather than the live world, and that distinction is the whole of it.** The live world
317
+ * is at the newest tick and is *speculative*: it was computed from predictions for inputs that
318
+ * have not arrived. Two peers comparing their live worlds disagree constantly and correctly,
319
+ * because each has predicted what the other already knows. A slot at a confirmed tick was
320
+ * computed from inputs both of them have, which is the only state worth comparing — and getting
321
+ * this wrong halted a healthy session at tick 32 during development.
322
+ */
323
+ digestOf(tick: number): string | null {
324
+ const digest = this.snapshotter.digest;
325
+ if (digest === undefined) return null;
326
+ const slot = this.slotFor(tick);
327
+ if (slot < 0) return null;
328
+ return digest.call(this.snapshotter, this.slots[slot] as S);
329
+ }
330
+
331
+ /** The ring position holding `tick`, or -1 if it holds a different one. */
332
+ private slotFor(tick: number): number {
333
+ if (tick < 0) return -1;
334
+ const slot = ((tick % this.depth) + this.depth) % this.depth;
335
+ return this.slotTick[slot] === tick ? slot : -1;
336
+ }
337
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * What a script can see of a session, and the replicated scalars it can publish.
3
+ *
4
+ * **This exists because `drift/network` needs something to be a capability *of*.** The two session
5
+ * drivers have different shapes — a lockstep peer has no authority and an authoritative client has
6
+ * no confirmed-input watermark — and a script surface cannot be a union of two classes. So this is
7
+ * the one thing a script holds, and a consumer points it at whichever driver they are running.
8
+ *
9
+ * ---
10
+ *
11
+ * ## Replicated scalars are numbered, not named
12
+ *
13
+ * `replicate(slot, value)` writes into a fixed table of floats and `replicated(participant, slot)`
14
+ * reads another participant's. Numbered rather than named for the reason `drift/ui` addresses nodes
15
+ * by name and this does the opposite: a *name* would need a string map on a per-tick path and a
16
+ * miss would need an optional to answer with, which the language does not have. A slot is an index
17
+ * into an array a consumer sized, and an index out of range answers zero — total, and allocating
18
+ * nothing.
19
+ *
20
+ * **A handful of scalars, not a replication system.** This is for the values a script computes and
21
+ * another peer wants: a health bar, a charge level, a lap count. A world's worth of state goes
22
+ * through `Replicator`, which a consumer writes and which can pack whatever it likes. The line
23
+ * between them is that this one is reachable from a `.drs` file.
24
+ *
25
+ * ## It is its own `Replicator`
26
+ *
27
+ * The slot table is exactly the thing an authority publishes and a client applies, so this
28
+ * implements `encode` and `apply` rather than making a consumer write a third copy of the same
29
+ * loop. A consumer replicating anything larger supplies their own and ignores this.
30
+ */
31
+ import type { Replicator } from './authority.ts';
32
+
33
+ export interface ScriptSessionOptions {
34
+ readonly self: number;
35
+ readonly participants: number;
36
+ /** Replicated scalars per participant. Eight by default. */
37
+ readonly slots?: number;
38
+ /** Whether this peer's world is the authority. Fixed for the session's life. */
39
+ readonly authority?: boolean;
40
+ }
41
+
42
+ /** What a driver tells the façade about itself. Both session classes satisfy the parts they have. */
43
+ export interface SessionStatus {
44
+ readonly confirmed?: number;
45
+ readonly status?: string;
46
+ readonly reason?: string;
47
+ }
48
+
49
+ export class ScriptSession implements Replicator {
50
+ readonly self: number;
51
+ readonly authority: boolean;
52
+
53
+ private readonly slotCount: number;
54
+ private readonly values: Float64Array;
55
+ private participantCount: number;
56
+ private driver: SessionStatus | null = null;
57
+ private readonly view: DataView;
58
+
59
+ constructor(options: ScriptSessionOptions) {
60
+ this.self = Math.max(0, Math.trunc(options.self));
61
+ this.participantCount = Math.max(1, Math.trunc(options.participants));
62
+ this.slotCount = Math.max(1, Math.trunc(options.slots ?? 8));
63
+ this.authority = options.authority ?? false;
64
+ this.values = new Float64Array(this.participantCount * this.slotCount);
65
+ this.view = new DataView(this.values.buffer);
66
+ }
67
+
68
+ /** Point this at the driver whose status a script reads. */
69
+ follow(driver: SessionStatus): this {
70
+ this.driver = driver;
71
+ return this;
72
+ }
73
+
74
+ get participants(): number {
75
+ return this.participantCount;
76
+ }
77
+
78
+ /** The highest tick every participant's input has arrived for, or -1 when nothing tracks it. */
79
+ get confirmed(): number {
80
+ return this.driver?.confirmed ?? -1;
81
+ }
82
+
83
+ get halted(): boolean {
84
+ return this.driver?.status === 'halted';
85
+ }
86
+
87
+ get haltReason(): string {
88
+ return this.driver?.reason ?? '';
89
+ }
90
+
91
+ /** Publish a value in one of this peer's slots. Out of range does nothing. */
92
+ replicate(slot: number, value: number): void {
93
+ const at = this.indexOf(this.self, slot);
94
+ if (at < 0) return;
95
+ this.values[at] = value;
96
+ }
97
+
98
+ /** Read a participant's slot. Out of range answers zero, which is total and allocates nothing. */
99
+ replicated(participant: number, slot: number): number {
100
+ const at = this.indexOf(participant, slot);
101
+ return at < 0 ? 0 : (this.values[at] as number);
102
+ }
103
+
104
+ get slots(): number {
105
+ return this.slotCount;
106
+ }
107
+
108
+ /**
109
+ * Every slot, as bytes. See the header: this is its own `Replicator`.
110
+ *
111
+ * The tick is taken and ignored, and the parameter is written out rather than dropped: a shorter
112
+ * signature still satisfies the interface, and then a reader has to open `authority.ts` to learn
113
+ * that the second argument exists at all.
114
+ */
115
+ encode(into: Uint8Array, _tick = 0): number {
116
+ const bytes = this.values.byteLength;
117
+ if (into.length < bytes) return -1;
118
+ into.set(new Uint8Array(this.values.buffer, 0, bytes));
119
+ return bytes;
120
+ }
121
+
122
+ apply(from: Uint8Array, _tick = 0): void {
123
+ const bytes = Math.min(from.length, this.values.byteLength);
124
+ /*
125
+ * Copied a byte at a time through the view rather than by `set` on a typed array, because the
126
+ * incoming bytes are a subarray of the transport's buffer and their byte offset need not be a
127
+ * multiple of eight — a `Float64Array` view over it would throw on an unaligned offset.
128
+ */
129
+ for (let i = 0; i < bytes; i++) this.view.setUint8(i, from[i] as number);
130
+ }
131
+
132
+ private indexOf(participant: number, slot: number): number {
133
+ if (participant < 0 || participant >= this.participantCount) return -1;
134
+ if (slot < 0 || slot >= this.slotCount) return -1;
135
+ return participant * this.slotCount + slot;
136
+ }
137
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Fixed-point arithmetic, 48.16, for a simulation that wants determinism by construction.
3
+ *
4
+ * **Opt-in, and the gate is the default.** `scripts/determinism.mjs` plus `math/exact.ts` make a
5
+ * floating-point simulation reproducible across engines, and that is the recommended path: it costs
6
+ * nothing, changes no stored replay, and is what every package in this repository already does.
7
+ * This exists for a consumer who would rather not depend on an argument about IEEE 754 at all.
8
+ *
9
+ * **A value is an integer held in a double**, scaled by 2^16. Add, subtract, compare and negate are
10
+ * integer operations and are exact by inspection. Multiply and divide are where a fixed-point
11
+ * library is either right or quietly wrong, so they are §2 and §3 below.
12
+ *
13
+ * ---
14
+ *
15
+ * ## 1. Why 48.16 and not 32.32
16
+ *
17
+ * A double holds integers exactly to 2^53. Spending 32 bits on the fraction leaves 21 for the
18
+ * integer part — about two million, which as metres is a world 2,000 km across but as *raw values*
19
+ * leaves almost nothing for intermediate results. 16 fractional bits give a resolution of 15
20
+ * micrometres and an integer range of ±2^37, which is more world than anybody has and enough
21
+ * headroom that a multiply's terms stay exact.
22
+ *
23
+ * ## 2. Multiplication is exact wherever its result is representable
24
+ *
25
+ * The naive `a * b / ONE` overflows immediately: two raws of 2^40 multiply to 2^80 and a double
26
+ * stops being exact at 2^53. The fix is to split both operands at the fixed point —
27
+ * `a = A·2^16 + α` — and sum four terms:
28
+ *
29
+ * ```text
30
+ * a·b/2^16 = A·B·2^16 + A·β + α·B + ⌊α·β/2^16⌋
31
+ * ```
32
+ *
33
+ * Every term is exact for a reason: `A·β` and `α·B` are at most 2^37·2^16 = 2^53; `α·β` is at most
34
+ * 2^32; and `A·B·2^16` is the result's own integer part, so it is exact exactly when the result is
35
+ * representable. **The domain is therefore "results that fit", which is the only domain a caller
36
+ * can reason about.**
37
+ *
38
+ * The low term truncates toward negative infinity, which is a decision and not a rounding accident:
39
+ * `Math.floor` is exactly specified, `Math.round` is too, and either is reproducible — but flooring
40
+ * is the one that makes the decomposition's arithmetic hold, because `α` and `β` are defined as the
41
+ * remainders a floor produces.
42
+ *
43
+ * ## 3. Division is deterministic rather than exact, and that is the honest word
44
+ *
45
+ * `⌊(a/b)·2^16⌋` uses one IEEE division, which every conforming engine rounds identically, then an
46
+ * exact multiplication by a power of two and an exactly specified floor. The result may differ by a
47
+ * unit in the last place from the true quotient. **It cannot differ between machines**, which is the
48
+ * property this file exists for. A caller wanting exact division wants rationals, which is a
49
+ * different library.
50
+ *
51
+ * ## 4. Overflow saturates, and says so
52
+ *
53
+ * A result outside the representable range clamps to the end of it. Not a throw, because
54
+ * `AGENTS.md` forbids one in a frame loop; not a wrap, because a position that teleports from one
55
+ * edge of the world to the other is a bug that looks like a physics explosion. Saturation is
56
+ * deterministic, which is the one property that must not be given up — every peer saturates
57
+ * identically, so a session stays in agreement even while it is wrong.
58
+ */
59
+
60
+ /** Fractional bits. The whole format is this number. */
61
+ export const SIM_FRACTION_BITS = 16;
62
+
63
+ /** One, as a raw value. */
64
+ export const SIM_ONE = 65536;
65
+
66
+ /** The largest and smallest representable raws, which is a double's exact integer range. */
67
+ export const SIM_MAX = 9007199254740991;
68
+ export const SIM_MIN = -9007199254740991;
69
+
70
+ /** A fixed-point value: an integer, scaled by `SIM_ONE`, carried in a double. */
71
+ export type Sim = number;
72
+
73
+ /** From an ordinary number. Truncates toward negative infinity, as everything here does. */
74
+ export function simFrom(value: number): Sim {
75
+ return clamp(Math.floor(value * SIM_ONE));
76
+ }
77
+
78
+ /** Back to an ordinary number. Exact: a division by a power of two. */
79
+ export function simTo(value: Sim): number {
80
+ return value / SIM_ONE;
81
+ }
82
+
83
+ /** From an integer, exactly, with no rounding to think about. */
84
+ export function simFromInt(value: number): Sim {
85
+ return clamp(Math.trunc(value) * SIM_ONE);
86
+ }
87
+
88
+ export function simAdd(a: Sim, b: Sim): Sim {
89
+ return clamp(a + b);
90
+ }
91
+
92
+ export function simSub(a: Sim, b: Sim): Sim {
93
+ return clamp(a - b);
94
+ }
95
+
96
+ export function simNeg(a: Sim): Sim {
97
+ return -a;
98
+ }
99
+
100
+ export function simAbs(a: Sim): Sim {
101
+ return a < 0 ? -a : a;
102
+ }
103
+
104
+ export function simMin(a: Sim, b: Sim): Sim {
105
+ return a < b ? a : b;
106
+ }
107
+
108
+ export function simMax(a: Sim, b: Sim): Sim {
109
+ return a > b ? a : b;
110
+ }
111
+
112
+ /** -1, 0 or 1. Present so a caller compares through this file rather than by hand. */
113
+ export function simCompare(a: Sim, b: Sim): number {
114
+ return a < b ? -1 : a > b ? 1 : 0;
115
+ }
116
+
117
+ /**
118
+ * Multiply. Exact wherever the result is representable; see §2 of the header.
119
+ *
120
+ * The split is at the fixed point, so `high` is the integer part and `low` the fractional part
121
+ * scaled — both non-negative for a negative operand too, because `Math.floor` goes toward negative
122
+ * infinity and the remainder is taken against that.
123
+ */
124
+ export function simMul(a: Sim, b: Sim): Sim {
125
+ const aHigh = Math.floor(a / SIM_ONE);
126
+ const aLow = a - aHigh * SIM_ONE;
127
+ const bHigh = Math.floor(b / SIM_ONE);
128
+ const bLow = b - bHigh * SIM_ONE;
129
+
130
+ const product =
131
+ aHigh * bHigh * SIM_ONE + aHigh * bLow + aLow * bHigh + Math.floor((aLow * bLow) / SIM_ONE);
132
+
133
+ return clamp(product);
134
+ }
135
+
136
+ /** Divide. Deterministic rather than exact; see §3 of the header. Division by zero saturates. */
137
+ export function simDiv(a: Sim, b: Sim): Sim {
138
+ if (b === 0) return a < 0 ? SIM_MIN : a > 0 ? SIM_MAX : 0;
139
+ return clamp(Math.floor((a / b) * SIM_ONE));
140
+ }
141
+
142
+ /**
143
+ * Square root, by Newton's method on integers.
144
+ *
145
+ * **Not `Math.sqrt` of the scaled value**, even though `Math.sqrt` is exactly rounded and would be
146
+ * reproducible: the scaling back and forth introduces a rounding this file has no way to pin, and
147
+ * the whole argument for fixed point is that no floating-point reasoning is needed to believe it.
148
+ * Newton on integers converges in a bounded number of steps and every step is exact.
149
+ *
150
+ * Negative input answers zero, the way a length does. It is not an error worth a branch at every
151
+ * call site.
152
+ */
153
+ export function simSqrt(a: Sim): Sim {
154
+ if (a <= 0) return 0;
155
+
156
+ /* sqrt(a/ONE) * ONE = sqrt(a * ONE). The product is the one place range matters. */
157
+ const target = a * SIM_ONE;
158
+ if (!Number.isFinite(target) || target > SIM_MAX) {
159
+ /* Halve the argument twice, take the root, and double the result: sqrt(4x) = 2·sqrt(x). */
160
+ return clamp(2 * simSqrt(Math.floor(a / 4)));
161
+ }
162
+
163
+ let guess = Math.floor(a) || 1;
164
+ for (let i = 0; i < 40; i++) {
165
+ const next = Math.floor((guess + Math.floor(target / guess)) / 2);
166
+ if (next === guess || next === guess - 1) return clamp(next);
167
+ guess = next;
168
+ }
169
+ return clamp(guess);
170
+ }
171
+
172
+ function clamp(value: number): number {
173
+ if (value > SIM_MAX) return SIM_MAX;
174
+ if (value < SIM_MIN) return SIM_MIN;
175
+ return value;
176
+ }