@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +125 -0
- package/dist/authority.d.ts +170 -0
- package/dist/authority.js +235 -0
- package/dist/fingerprint.d.ts +64 -0
- package/dist/fingerprint.js +108 -0
- package/dist/fixture.d.ts +32 -0
- package/dist/fixture.js +136 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +32 -0
- package/dist/inputLog.d.ts +117 -0
- package/dist/inputLog.js +232 -0
- package/dist/lockstep.d.ts +181 -0
- package/dist/lockstep.js +291 -0
- package/dist/loopback.d.ts +115 -0
- package/dist/loopback.js +207 -0
- package/dist/rewind.d.ts +206 -0
- package/dist/rewind.js +212 -0
- package/dist/scriptSession.d.ts +77 -0
- package/dist/scriptSession.js +81 -0
- package/dist/simNumber.d.ts +103 -0
- package/dist/simNumber.js +156 -0
- package/dist/snapshotter.d.ts +43 -0
- package/dist/snapshotter.js +81 -0
- package/dist/transport.d.ts +63 -0
- package/dist/transport.js +40 -0
- package/dist/webrtc.d.ts +74 -0
- package/dist/webrtc.js +108 -0
- package/dist/websocket.d.ts +70 -0
- package/dist/websocket.js +105 -0
- package/dist/wire.d.ts +94 -0
- package/dist/wire.js +238 -0
- package/package.json +61 -0
- package/src/authority.ts +340 -0
- package/src/fingerprint.ts +143 -0
- package/src/fixture.ts +188 -0
- package/src/index.ts +95 -0
- package/src/inputLog.ts +262 -0
- package/src/lockstep.ts +374 -0
- package/src/loopback.ts +250 -0
- package/src/rewind.ts +337 -0
- package/src/scriptSession.ts +137 -0
- package/src/simNumber.ts +176 -0
- package/src/snapshotter.ts +98 -0
- package/src/transport.ts +69 -0
- package/src/webrtc.ts +141 -0
- package/src/websocket.ts +140 -0
- 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
|
+
}
|
package/src/simNumber.ts
ADDED
|
@@ -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
|
+
}
|