-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathllms.txt
More file actions
83 lines (69 loc) · 22.2 KB
/
Copy pathllms.txt
File metadata and controls
83 lines (69 loc) · 22.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# @zakkster/lite-rollback
> Zero-GC binary ring buffer + GGPO-shaped rollback Session for browser-native
> deterministic netcode. Single-file ESM, zero dependencies. All fields share
> one `ArrayBuffer` per instance; a commit is one `TypedArray.set()` per field
> (memcpy). After construction there are zero allocations in the hot path.
> ~2.0 KB min+gz, MIT licensed.
The library exposes two factories on the same primitive. `createRollback`
is the pure binary ring buffer -- commit/rollback/peek/reset, no knowledge of
frames or inputs. `createSession` wraps it with an input ring, prediction, and
automatic rollback + fast-forward on misprediction. Both surfaces are zero-alloc
after construction. Sister packages (lite-rollback-local, lite-rollback-webrtc)
ship the transport layer; core ships only a runtime contract check
(`assertTransport`) and an in-memory reference implementation in the README.
## Core concepts
- **Rollback**: pure binary ring buffer over one `ArrayBuffer`. `createRollback({ capacity, fields })` validates and returns a frozen handle. `commit()` snapshots the live region into the next ring slot via `TypedArray.set()` (memcpy, zero alloc). `rollback(n=1)` restores live to `peek(n)` -- the state `n` commits back from the current tip -- AND discards those `n` slots (destructive -- non-destructive is `peek`); after the call `live === peek(0)`, symmetric with `peek`. `peek(n=0)` returns a stable typed-array map of the `n`-th most recent commit; do NOT mutate the returned views, they alias the ring. `reset()` clears `head`/`depth` but does NOT zero the backing buffer (intentional: a zero pass on 1 MB of state every reset would defeat the point).
- **Capacity**: 1..2^20. MUST be a power of two. The hot path uses `& mask` instead of `% capacity` -- a 10-15 cycle idiv collapses to a 1-cycle and. Powers-of-two are checked at construction; non-powers throw `RangeError`. At 60 Hz the natural picks are 8 (~133 ms tolerance), 16, 32, or 64 (~1 s).
- **Fields**: `Record<string, { type, length }>`. `type` is a typed-array constructor (Float32/Float64/Int32/Uint32/Int16/Uint16/Int8/Uint8/Uint8Clamped). `length` is element count. At construction the layout is resolved once and `(capacity + 1)` views are built per field -- one for live + one per ring slot, all aliasing the same backing buffer.
- **Session**: `createSession({ capacity, fields, numPlayers, inputWords=1, inputDelay=0, simulate, predict?, onDesync? })` owns a Rollback plus pre-allocated Uint32 buffers: an input ring (`capacity x numPlayers x inputWords`), a per-slot confirmation bitmask (`capacity` Uint32s, one bit per player), a `lastConfirmedInput` per-player buffer for the default predictor, an `inputScratch` buffer passed to `simulate` each step, and a 5-slot stats block. All pre-allocated; zero GC at runtime. At the first `step()` (and again after `reset()`) it commits a genesis snapshot -- `state(-1)`, the pre-frame-0 baseline -- so a frame-0 misprediction is recoverable (v1.1.0). It is captured at the first step, not at construction, so it snapshots whatever initial state you set (writing `session.fields` before the first step is snapshot-covered and survives a frame-0 rollback). Because it is a real ring commit, `depth()` counts it: 0 on a fresh/just-reset session, 2 after the first step (genesis + `state(0)`), `frames + 1` until it rolls off after `capacity` commits.
- **simulate**: `(fields, inputs, frame) => void`. Must mutate `fields` in place; must be deterministic. Inputs is a `Uint32Array` of length `numPlayers * inputWords`, indexed by `player * inputWords + word`.
- **predict**: optional `(player, frame, scratch, outOff) => void`. Writes `inputWords` Uint32s into `scratch[outOff..outOff+inputWords]`. Default: repeat last-confirmed input for the player. The "repeat" semantics is what GGPO did historically and remains a strong baseline; only override if you have a state-aware predictor.
- **step()**: 1) fillInputs (real where confirmed, predicted otherwise; predictions are persisted into the input ring so misprediction detection is a comparison), 2) `simulate(fields, inputs, frame)`, 3) `commit()`, 4) `frame++`. Zero alloc.
- **setInput(player, frame, input)** / **setLocalInput(input)**: write a confirmed input for a frame in the window `[frame - capacity + 1, frame + inputDelay]`. Marks the slot as confirmed (bit set) and updates the watermark. Out-of-window throws. `setLocalInput` targets frame `frame() + inputDelay` (the local input-delay write, v1.2.0). A future (delayed) write advances the confirmed watermark but does NOT advance the predictor baseline, so the startup frames `0..inputDelay-1` predict the neutral input on every client and stay in sync.
- **inputDelay** (v1.2.0): local input delay in frames. `setLocalInput` buffers the local player's input `inputDelay` frames before it takes effect (targets `frame() + inputDelay`), giving the network that lead time to deliver it -- the single biggest rollback-frequency reducer. Integer in `[0, capacity - 1]`; out of range throws `RangeError`. Default 0 (historical behaviour). When the channel delay is `<= inputDelay` the remote input arrives on the frame it is consumed and NO rollback fires. The step() wrap-clear clears `confirmedMask[(frame + inputDelay) & mask]` (the just-opened future slot) so the delayed input survives to consumption, and `feedRemoteInput`'s out-of-window floor rises by `inputDelay` so a delayed write can never clobber a still-redoable frame -- the effective late-input redo window shrinks from `capacity - 1` to `capacity - 1 - inputDelay` frames (size the ring at least `worst-case-lag + inputDelay + 1`). See `decisions/0004-input-delay.md`.
- **feedRemoteInput(player, frame, input)**: accept a remote input for some past or current frame. If the slot was previously predicted AND the incoming value differs AND `f < frame`, rolls back state to `state(f-1)` via `rb.rollback(frame - f)` (v1.0.2+ semantics: `rollback(n)` restores `peek(n)`; the pre-1.0.2 off-by-one used `frame - f + 1`), resets the frame counter to `f`, and replays through the original frame using `fillInputs` + `simulate` + `commit` each step. Returns `true` if a rollback was performed. A past frame too old to redo (below `frame - depth() + 1 + inputDelay`) is INERT -- it stores nothing (no slot write, no confirm) and returns `false`; its slot may alias a still-pending frame, so writing there would poison that frame (a pre-existing v1.1.0 out-of-window R-01, fixed in 1.2.0). The current frame (`f === frame`, not a redo) is still confirmed normally. The effective late-input redo window is `capacity - 1 - inputDelay` frames (v1.0.3 wrap-clear + v1.2.0 input-delay reservation): a past remote input older than that is out-of-window. A mismatch against an input ALREADY confirmed for that frame is a genuine two-client desync (not a misprediction): it does not rollback, returns `false`, increments `stats.desyncs`, and fires the `onDesync(frame, player)` callback if one was supplied (v1.1.0).
- **confirmedFrame()** (v1.1.0): the GGPO confirmed-frame watermark -- the highest frame at which EVERY player's input is real, not predicted. Computed as the minimum over players of each player's latest confirmed frame; monotone non-decreasing; `-1` until every player has confirmed. The value the input-delay / frame-advantage / checksum-exchange helpers key off. Zero alloc.
- **frameAdvantage()** (v1.2.0): the raw local-vs-remote frame lead, `latest-confirmed-frame(localPlayer) - confirmedFrame()`. Positive means the local client is running ahead of the slowest remote's confirmed inputs -- the caller's loop can stall to let them catch up (GGPO time-sync). Pure read of the per-player watermark; zero alloc.
- **checksumAt(frame, seed=0)** (v1.2.0): the 32-bit checksum of the COMMITTED state at `frame` -- pin it to `confirmedFrame()` so two clients compare the same committed frame rather than whatever live frame each is on. Reaches the committed slot through the ring's zero-alloc `checksumSlot` (never `stateChecksum(peek(...))`, which would allocate a view per field). Fail closed: a frame outside the committed window `[frame() - depth(), frame() - 1]` throws `RangeError`; a non-integer frame throws `TypeError`. Zero alloc.
- **packInputs(session, dest, baseFrame, playerMask, redundancy=4)** / **unpackInputs(session, src, feed?)** (v1.2.0): the zero-alloc GGPO-style wire codec the sister transports consume. `packInputs` writes the last `redundancy` frames of the session's inputs (ending at, inclusive, `baseFrame`) into the caller-owned `Uint8Array` `dest` and returns bytes written; `unpackInputs` decodes and applies each masked input via `feed` (default `session.feedRemoteInput`, oldest frame first) and returns the decoded `baseFrame`. Little-endian layout: `[baseFrame u32][playerMask u32]` 8-byte header (`CODEC_HEADER_BYTES`), then `redundancy` frames oldest-first x `numPlayers` x `inputWords` LE u32. `redundancy` defaults to 4, clamped to `capacity` and to `baseFrame + 1` (never a pre-genesis frame); it is recovered on unpack from the buffer length (`numPlayers`/`inputWords` are session config, not on the wire). `playerMask` marks which players are meaningful; the body reserves every player's slot for a fixed stride and unpack feeds only the masked players. Fail closed: a too-small `dest`, a below-header or misaligned `src`, or a bad argument throws; `baseFrame` must be `>= 0` (the advertised `packInputs(s, buf, s.confirmedFrame(), mask)` throws when nothing is confirmed yet -- guard with `if (s.confirmedFrame() >= 0) packInputs(...)`). Last-N redundancy means one dropped packet is recovered by the next. See `decisions/0005-wire-codec.md`.
- **stats** (v1.1.0): read-only cumulative counters over a pre-allocated Uint32 block -- `rollbacks`, `maxRollbackDepth`, `mispredictions`, `desyncs`, `fastForwardFrames`. Reading allocates nothing; counters reset with `reset()`. Makes rollback frequency and true desyncs observable without patching the hot path.
- **checksum(buf, seed=0)** / **stateChecksum(rb, seed=0)**: 32-bit non-cryptographic hash for desync detection. `checksum` accepts `ArrayBuffer` or any `ArrayBufferView`; zero alloc for views (allocates one Uint8Array view per call for raw ArrayBuffer). `stateChecksum` hashes across all fields of a Rollback/Session's live state via a Symbol-keyed handle to pre-built `Uint8Array` views; zero alloc per call. NOT suitable for security or content-addressing.
- **createPRNG(seed?)** / **nextPRNG(state, index)**: xorshift32. `createPRNG` returns the ergonomic closure-based API (`seed`/`state`/`setState`/`int32`/`float`/`range`). `nextPRNG` is the inlinable pure-function variant: takes a `Uint32Array` state + index, writes the new state back, returns the new value. Pair `nextPRNG` with a `Uint32Array(1)` rollback field for snapshot-safe RNG inside `simulate` with no closure overhead. Zero state (`state[index] === 0`) is illegal for xorshift32 (fixed point) -- the function self-seeds with `0x12345678` if encountered.
- **Transport**: an interface with `send(payload, peer?)`, `onMessage(handler)`, `close()`. Core ships only `assertTransport(t)` -- a runtime contract check that throws `TypeError` on missing methods. Sister packages (lite-rollback-local using BroadcastChannel; lite-rollback-webrtc using RTCDataChannel) implement the contract.
- **VERSION**: `"1.2.1"` literal export.
- **CONSTANTS**: `{ CAPACITY_MIN: 1, CAPACITY_MAX: 2^20, MAX_PLAYERS: 32, INPUT_WORDS_MAX: 16 }` frozen.
## Architecture invariants
- **One ArrayBuffer per Rollback/Session.** Sized to `Sum_field (capacity + 1) * length * bpe`. Two Sessions with the same shape own different buffers -- no accidental cross-state.
- **Live + capacity ring slots.** Each field has `capacity + 1` typed-array views into the same backing buffer: one "live" view (what user code mutates) plus `capacity` ring slot views. All slot views are stable references built once at construction; `peek(n)` returns the slot object literally (never a fresh allocation).
- **Memory layout, per field:** `[ live | slot 0 | slot 1 | ... | slot (capacity - 1) ]`. Fields are concatenated in declaration order.
- **`(head - n + 1) & mask` is the rollback restore index.** Bitwise AND on a signed Int32 in V8 yields the correct mod-2^k result even when `head - n + 1` is negative. Same formula handles wraparound transparently.
- **Predictions persist into the input ring.** When `fillInputs(f)` predicts player p at slot s, the predicted value is written into `inputRing[(s*numPlayers + p) * inputWords + w]`. Misprediction detection in `feedRemoteInput` is then just a comparison against `inputRing` -- which is why the rollback path only triggers on actual mismatches.
- **`feedRemoteInput` for already-confirmed slots does NOT auto-correct.** Mismatch in already-confirmed input is a desync signal, not a misprediction. State is NOT rolled back; the new value is written into the slot anyway. Since v1.1.0 the divergence is observable: `stats.desyncs` increments and the optional `onDesync(frame, player)` callback fires. The boolean return stays `false` for compatibility.
- **Out-of-window past frames are inert.** A `feedRemoteInput` for a past frame older than `frame - depth() + 1 + inputDelay` cannot be safely redone (the state is gone, and its ring slot may alias a still-pending frame). It stores NOTHING -- no slot write, no confirm -- and returns `false`. Storing bought nothing anyway: the default predictor reads `lastConfirmedInput`, never the raw slot. Only the current frame (`f === frame`, not a past redo) is confirmed via the normal path. (Writing the slot here was a pre-existing v1.1.0 R-01 -- a stale confirmed bit at the aliased current frame -- fixed in 1.2.0.)
- **`Session.commit` / `Session.rollback` / `Session.peek` are pass-throughs to the underlying Rollback.** These do NOT touch the frame counter -- they're for advanced users who want raw access to the state ring. Frame-aware rollback happens INSIDE `feedRemoteInput`. The Session's own `step()` is the only path that increments `frame`.
- **`Session.reset` zeroes live AND clears input ring.** Stronger than `Rollback.reset` (which only clears head/depth) because at the Session layer "reset" semantically means "start a new match". It also zeroes `stats` and the confirmed-frame watermark and re-arms the genesis commit (re-committed at the next first step), so `depth()` is 0 immediately after and 2 after the first post-reset step (v1.1.0).
- **The `_liveBytes` handle is Symbol-keyed, not string-keyed.** Used by `stateChecksum` to access pre-built `Uint8Array` views of each field's live region without per-call allocation. Symbol-keyed means it does NOT appear in `Object.keys`, `for..in`, `JSON.stringify`, or any other string-key enumeration -- safe internal handle, not a public field.
## Performance characteristics
- `Rollback.commit()` -- `O(Sum_field bytes)`. One `TypedArray.set()` per field, JIT-compiled to memcpy. ~110 ns for tiny state (16 entities x 3 fields), ~230 ns for medium (512 x 4), ~1500 ns for large (4 000 entities x 2 Float32 + 1 Uint8). Zero allocation.
- `Rollback.rollback(n)` -- `O(Sum_field bytes)` regardless of `n`. One memcpy per field. ~220 ns for 256-entity state. Zero allocation.
- `Rollback.peek(n)` -- `O(1)`. Returns a stable reference (never allocates). ~22 ns. Zero allocation.
- `Session.step` -- `O(Sum_field bytes + simulate cost)`. The Session's own overhead (fillInputs + commit + frame++) is ~50-80 ns; the rest is your `simulate`. ~180 ns total for a 2-player, 128-entity simulation with a tight integer-arithmetic body.
- `feedRemoteInput` worst case -- one rollback + N fast-forward steps where `N = frame - f`. Dominated by your `simulate` running N times.
- `checksum` / `stateChecksum` -- ~5 GB/s on the test machine (the xor-rotate-multiply loop is ~3 instructions per byte at the assembly level). Per-frame use as a desync canary is comfortable: 64 KB of state hashed per frame is <0.01 ms.
- **Total allocation budget at steady state: 0 bytes per frame.** Measured by `npm run test:gc`: 100k commits + rollbacks grows the heap by <64 KB total (down from MB/sec for naive `structuredClone` approaches).
## Browser & engine support
- Chrome / Edge 80+, Firefox 79+, Safari 15+ (iOS 15+): native, no polyfills.
- Node 18+: native.
- Bun, Deno: tested on recent versions; behaviour identical to Node.
- Web Worker: yes. The backing `ArrayBuffer` is transferable -- you can simulate in a worker and transfer the buffer to main for rendering (with the cost of neutering the original). For zero-copy two-way sharing use `SharedArrayBuffer` with the appropriate cross-origin isolation headers.
## Float determinism note
JavaScript guarantees IEEE 754 results for `+ - * /`, but NOT for transcendentals (`Math.sin`, `Math.exp`, etc.) across engines. If your `simulate` uses transcendentals, two clients on different browsers can desync even with identical inputs. Mitigations:
- Use only `+ - * /` and bitwise ops in `simulate`, or
- Ship deterministic LUTs for the transcendentals you need, or
- Accept the desync and use `stateChecksum` to detect + reset.
## Version notes
- **1.2.1** (current): docs-only patch, no code change beyond the `VERSION` constant. Adds a canonical Pong demo under `demo/` (NOT shipped in the npm tarball): `demo/sim.js` (pure ASCII `pongSimulate` + `FIELDS`), `demo/mirror.mjs` (headless two-Session determinism proof, 3600 frames, asserts `checksumAt(confirmedFrame())` equality), `demo/index.html` (single-tab mirror render) and `demo/tabs.html` (two-tab BroadcastChannel play over `@zakkster/lite-rollback-local`). README refreshed to the real test count (191 cases / 20 files), the full 1.2.0 API surface (`inputDelay`, `frameAdvantage`, `checksumAt`, `packInputs`/`unpackInputs`, `stats`/`onDesync`/`confirmedFrame`), and the T0/T1/T3/T4/T5/T6/T7/T8/T9 torture-tier list. The bench provenance stamp in `bench/bench-results.json` now also records `cpu` and `commit`.
- **1.2.0**: netcode ergonomics + the wire codec the sisters consume, all keyed off the 1.1.0 `confirmedFrame()` watermark. **Local input delay** (`inputDelay` option, `[0, capacity-1]`): `setLocalInput` targets `frame() + inputDelay`, buffering the local input so the network has that lead time to deliver it -- when the channel delay is `<= inputDelay`, zero rollbacks. The step() wrap-clear is generalized to clear `confirmedMask[(frame + inputDelay) & mask]` (the just-opened future slot) so the delayed input survives, and a future confirmation advances the watermark but not the predictor baseline so startup frames stay in sync (`decisions/0004-input-delay.md`). **`frameAdvantage()`**: the raw `localConfirmed - confirmedFrame()` lead for time-sync stalling. **`checksumAt(frame, seed?)`**: the checksum of the committed state at a frame (pin to `confirmedFrame()` for two-client checksum exchange), via a new zero-alloc per-slot byte-view route on the ring (`checksumSlot`) -- never `stateChecksum(peek(...))`. **Wire codec** `packInputs` / `unpackInputs`: zero-alloc, caller-owned `Uint8Array`, little-endian `[frame u32][playerMask u32]` header + last-N redundancy frames (`decisions/0005-wire-codec.md`). Zero-GC contract intact -- proven by torture T6 (`maxArrayBuffersGrowth: 0`, `stabilize: 'deep'`, 60k mixed ops including the codec + read helpers) and T5 (100k-frame differential fuzz vs a lockstep oracle). No breaking changes: `inputDelay` defaults to 0, all earlier surfaces unchanged.
- **1.1.0**: closes the two Session holes 1.0.3 recorded as known issues. **Frame 0 is now recoverable (R-02):** `createSession` commits a genesis snapshot (`state(-1)`) at the first `step()` (capturing any initial state set before it) and re-arms it on `reset()`, so a frame-0 misprediction can roll back to a real baseline instead of being baked in forever. Consequence: `depth()` now counts the genesis commit -- 0 on a fresh/reset session, 2 after the first step, `frames + 1` until it rolls off after `capacity` commits (unchanged at steady state). **Desync is now observable (R-03):** a confirmed-input mismatch increments `stats.desyncs` and fires an optional `onDesync(frame, player)` callback (the boolean return of `feedRemoteInput` is unchanged); `session.stats` also exposes `rollbacks` / `maxRollbackDepth` / `mispredictions` / `fastForwardFrames` over a pre-allocated block. Added `confirmedFrame()` (the per-player confirmed watermark). Torture T3 gains an unseeded-frame-0 sweep (the executable R-02 proof); `test/genesis.test.js` and `test/observability.test.js` add the named regressions. Zero-GC contract intact -- the additions are branch-local typed-array stores, proven by the T6 `maxArrayBuffersGrowth: 0` gate.
- **1.0.3**: fixes a silent desync (R-01) -- the per-slot confirmed-input mask was never cleared when a ring slot was reused a revolution later, so any session running past `capacity` frames read a revolution-old input as confirmed truth and suppressed the correction. `step()` now clears the entering slot's mask; the effective late-input redo window is `capacity - 1` frames. Also: `VERSION` synced across `index.js` / `index.d.ts` / `llms.txt` (R-04); this file's `feedRemoteInput` rollback math corrected from the pre-1.0.2 `frame - f + 1` to `frame - f` (R-05); torture gate added (`node --expose-gc test/torture.mjs`, tiers T0/T1/T3/T4/T6/T7/T9) with the two-client mirror tier that reproduces R-01. Known issues deferred to later releases: frame-0 mispredictions are not recoverable (a genesis commit is planned), and a confirmed-input mismatch (true desync) is reported only via `feedRemoteInput` returning `false`, indistinguishable from a benign no-op (observability is planned).
- **1.0.0**: initial public release. Two factories (`createRollback`, `createSession`), 9 supported typed-array field types, power-of-two capacity, default last-confirmed predictor + optional custom override, `feedRemoteInput`-driven rollback + fast-forward, `checksum`/`stateChecksum` desync detection, `createPRNG`/`nextPRNG` deterministic RNG, `assertTransport` contract for sister packages. 97 tests under `node:test`, 100% passing under `--expose-gc`. Hand-written TypeScript declarations with generics so `rb.fields.pos` types as the concrete `Float32Array` rather than a union over all typed arrays. Two benchmark scripts (`bench/throughput.js`, `bench/rollback-depth.js`) measure ops/sec and frame-budget consumption at varying depths. Internal `_liveBytes` handle is Symbol-keyed so it doesn't leak into `Object.keys` / `JSON.stringify` / `for..in`. Single-file ESM at `src/index.js`; sister packages (lite-rollback-local, lite-rollback-webrtc) implement the Transport contract.