---
title: RNG byte-level wire spec — the Stage 0 exit gate
status: normative
implements: packages/engine/src/rng/stream.ts
links: [.rea/knowledge/engine-game-contract.md §3.1, .rea/knowledge/provably-fair-crash.md]
---

# RNG byte-level wire spec

This document is **normative**, not a summary of the code. It exists because
[`engine-game-contract.md` §3.1](../.rea/knowledge/engine-game-contract.md) declares the byte-level
RNG algorithm part of the contract, not an implementation detail, and names it Stage 0's exit gate:

> An outsider can independently verify a finished resolution from published data — with their own
> tools, from a published byte-level spec and test vectors, without running anything of ours.

If you are that outsider: everything below is written so you can reimplement it in any language —
Python, Rust, Go, whatever you have HMAC-SHA256 in — and get **byte-identical output** to
`packages/engine/src/rng/stream.ts`, checkable against `test-vectors.json` in the same directory.

**Where "this document is authoritative" applies, and where it does not.** For the byte-level
algorithm — which bytes are consumed, in what order, and what numeric value results — if `stream.ts`
disagrees with this document, that is a bug in `stream.ts`; the "Resolved ambiguities" section at
the end records every place the two could plausibly have diverged on that axis and how it was
settled. **This does not extend to incidental API shape** — exact field names, argument order,
return-value structure — where `packages/engine/src/contract/entropy.ts`'s `Rng` and `Weighted<T>`
types (and `stream.ts`'s own signatures) are the source of truth, because those are TypeScript
interfaces this document merely describes in prose, not the other way round. This qualification
exists because it was needed, not pre-emptively: an earlier draft of the `selectWeighted` section
below stated a table shape and a return shape that both contradicted the real implementation, found
by an independent reimplementation built from this document's prose alone (see "External
verification"). That was a documentation bug, not an algorithm bug — the byte consumption was
identical either way — and it is fixed below, but it is the concrete reason this paragraph exists
rather than the blanket rule it replaced.

This document does **not** cover where `serverSeed`, `clientSeed` or `nonce` come from — the hash
chain, its checkpointing, its rotation, and how a chain resolves to a per-resolution
`EntropyTicket` are [`engine-game-contract.md` §3](../.rea/knowledge/engine-game-contract.md)'s
concern and U19's implementation. This document starts from the assumption that you already have
those three values for one resolution, and covers only what a deterministic byte stream built from
them looks like.

## Inputs

| Name | Type | Notes |
|---|---|---|
| `serverSeed` | string | A hex string (this project always uses lowercase hex — a link in a SHA-256 hash chain). **Used byte-for-byte as given, including case** — see "Byte encoding of the key" below. |
| `clientSeed` | string | External (a resolved Bitcoin block hash) or player-chosen. Used byte-for-byte as given. |
| `nonce` | non-negative integer | Decimal, no leading zeros, no separators — exactly what `${nonce}` produces in JavaScript for a non-negative safe integer. |

### Byte encoding of the key — resolved ambiguity

`serverSeed` is a hex-encoded string (e.g. `"2f3c1a9e7b5d..."`). There are two ways to turn that
into HMAC key bytes:

1. **Hex-decode** it to the 32 raw bytes the hex text represents.
2. **UTF-8-encode** the hex text itself — the ASCII bytes of the characters `'2'`, `'f'`, `'3'`,
   `'c'`, … — which is a different, longer byte sequence than (1).

`engine-game-contract.md` §3.1's phrase "`key = serverSeed bytes`" does not by itself disambiguate
between these. **This project uses (2): UTF-8-encode the hex string.** This was verified, not
guessed, against real-world precedent before being fixed here — see "External verification" below.
It also matches the message's own convention: `clientSeed` is mixed into the message as its literal
string form (`${clientSeed}:...`), and the key follows the same rule for consistency.

**This matters enormously and silently**: hex-decoding versus UTF-8-encoding the same `serverSeed`
string produces two completely different, unrelated keystreams with no error or warning either way
— both are valid HMAC keys of different lengths. A reimplementer who defaults to "of course you
decode the hex" will get self-consistent, plausible-looking, **wrong** output. If your language's
HMAC library takes a `bytes`/`Buffer` key, pass it `utf8_encode(serverSeed_string)`, not
`hex_decode(serverSeed_string)`.

## The keystream

```
block_i = HMAC-SHA256(key = utf8(serverSeed), message = utf8(`${clientSeed}:${nonce}:${i}`))
```

`i` is a decimal integer starting at 0, formatted the same way `nonce` is (no padding, no
separators). The keystream is the concatenation `block_0 || block_1 || block_2 || ...` — an
unbounded sequence of 32-byte SHA-256 HMAC digests. **The cursor is never capped.** A 312-card
shoe shuffle reads **430 bytes** deep into this stream — published as `test-vectors.json`'s
`"primary"` fixture's `shuffle312Consumed` field, not merely asserted here — most of the 311 draws
(`nextInt(i+1)` for `i` from 311 down to 1) need `n ≤ 256`, so `k=1` byte each; only the 56 draws
with `n > 256` (`i` from 311 down to 256) need `k=2`, and only occasionally more for a rejected
draw. Do not estimate this as "~2 bytes per draw across the board" — that overstates it by roughly
40% and makes a correct implementation's cursor look wrong against a hand-waved expectation.
`shuffle25Consumed` is 24 and `shuffle52Consumed` is 53, the same fixture. None of this needs
anything special at a block boundary — block 20 is
exactly as valid and exactly as cheap to recompute as block 0, because each block is a pure,
independent function of `(serverSeed, clientSeed, nonce, i)`. Nothing needs to be cached beyond the
one block a read currently touches.

**Worked example**, taken directly from `test-vectors.json`'s `"primary"` fixture
(`serverSeed = "2f3c1a9e7b5d4082c6a1f0e3b7d95c4a1e8f2b6d0937c5a4e1f8b2d6907e5c31"`,
`clientSeed = "u7-wire-spec-client-seed"`, `nonce = 0`):

```
block_0 = HMAC-SHA256(
  key = utf8("2f3c1a9e7b5d4082c6a1f0e3b7d95c4a1e8f2b6d0937c5a4e1f8b2d6907e5c31"),
  msg = utf8("u7-wire-spec-client-seed:0:0"),
) = a17dc913952ec7a4b52428599bee74857284efd375e45039c464e668993a1409   (hex, 32 bytes)
```

The first 64 bytes of the keystream (`block_0 || block_1`, hex) are `rawBytesHex` in that fixture.

## The cursor and `nextBytes(n)`

The cursor is a single non-negative integer: total bytes consumed so far, starting at 0. Reading
`n` bytes means: starting at the current cursor position, walk forward byte by byte through the
keystream above (computing whichever block(s) that range falls in), return those `n` bytes, and
advance the cursor by exactly `n`. There is no distinction between "cursor" as a block index plus
an offset and "cursor" as a flat byte count — they are the same thing; block index and in-block
offset are just `floor(cursor / 32)` and `cursor % 32`.

**`consumed()` returns the cursor's current value** — the total number of bytes read from the
keystream so far by any combination of the primitives below, in this `Rng`'s lifetime. It is what
every worked example in this document reports alongside a returned value, and it is how
`test-vectors.json`'s `mixedSequence` fixture checks that one primitive did not silently read more
of the stream than the next primitive expects to find undisturbed.

## `nextInt(n)` — rejection-sampled, unbiased integers

`n` must be a positive integer (`n ≥ 1`); `n = 0` is a caller error (the formula below divides by
zero) and this implementation throws rather than computing garbage.

```
k = the smallest integer such that 256^k >= n     # bytes needed to cover [0, n) without bias
limit = floor(256^k / n) * n     # the largest multiple of n that fits in k bytes
loop:
  u = next k bytes, interpreted big-endian as an unsigned integer
  if u < limit: return u % n
  # else: reject this draw and loop again — u >= limit is discarded
```

**Compute `k` by exact integer comparison, not `ceil(log(n) / log(256))`.** A real-valued logarithm
is unreliable exactly at powers of 256: `log(256)/log(256)` is not guaranteed to round to precisely
`1.0` in every language's floating-point implementation, and a `log` that rounds the wrong way at
`n = 256` returns `k = 2` instead of the correct `k = 1`, desynchronising the cursor from that draw
onward — the same hazard class `nextUint53()` (below) is devoted a whole section to. `k` is well
defined by simple doubling — start `capacity = 1`, double it (`capacity *= 256`) and increment `k`
until `capacity >= n` — with no logarithm and no floating point involved at all.

**A rejected draw still advances the cursor.** Each iteration of the loop above — accepted or
rejected — reads a *fresh* `k` bytes from wherever the cursor currently is; a rejected draw's bytes
are never reused or reinterpreted. This is deliberately called out because it is the classic place
two honest implementations of "the same" algorithm silently diverge: one that special-cases
"redraw = read `k` more bytes and try again" is correct, and one that (say) tries the next single
byte, or re-hashes only the failing byte, produces a keystream position that disagrees with this
one from that point forward, corrupting every draw afterward — not just the rejected one.

**Worked example — the fixture in `test-vectors.json`'s `"rejection"` entry**
(`serverSeed = "test-server-seed-for-u7-rejection-fixture"`, `clientSeed = "test-client-seed"`,
`nonce = 11`, calling `nextInt(7)`):

- `k = 1` (one byte covers `[0, 7)` — `256 >= 7`), `limit = floor(256/7)*7 = 252`.
- `block_0`'s first byte is `0xFF` (255). `255 >= 252` → **reject**. The cursor has now consumed 1
  byte for a draw that produced no result.
- Redraw: the next byte (`block_0`'s second byte) is `0xA5` (165). `165 < 252` → accept.
  `165 % 7 = 4`.
- `nextInt(7)` returns `4`, and the cursor has advanced by **2** bytes total for this one call.

A reimplementation that (incorrectly) used the first byte directly without the `limit` check would
compute `255 % 7 = 3` and consume only 1 byte — both numbers differ from the correct answer, which
is exactly why this fixture was chosen: the bug is observable in both the returned value and in
`consumed()`, not hidden behind a coincidence.

**The fixture above has `k = 1`, and at `k = 1` a subtly wrong "redraw" and the correct one are the
same operation.** "Draw a fresh `k` bytes" and "advance the cursor by one byte and re-read `k`
bytes" only disagree once `k ≥ 2` — at `k = 1`, sliding the window forward by one byte on rejection
*is* drawing a fresh byte, so a fixture at `k = 1` alone cannot distinguish the correct algorithm
from that specific wrong one. This is not hypothetical: fix-cycle 2 found a from-prose
reimplementation of an *earlier* draft of this document that made exactly that mistake and still
passed every `k = 1` fixture then published.

**Worked example — the fixture in `test-vectors.json`'s `"k2Rejection"` entry**
(`serverSeed = "k2-rejection-server-seed"`, `clientSeed = "k2-rejection-client-seed"`,
`nonce = 3`, calling `nextInt(40000)`):

- `k = 2` (`256^1 = 256 < 40000`, `256^2 = 65536 >= 40000`), `limit = floor(65536/40000)*40000 = 40000`.
- `block_0`'s first two bytes, big-endian, give `u = 48991`. `48991 >= 40000` → **reject**. The
  cursor has consumed 2 bytes for a draw that produced no result.
- Redraw: a **fresh** two bytes — `block_0`'s *third and fourth* bytes, not its second and third —
  give `u = 30088`. `30088 < 40000` → accept. `30088 % 40000 = 30088`.
- `nextInt(40000)` returns `30088`, and the cursor has advanced by **4** bytes total.

A sliding-window implementation (advance by 1 byte on rejection, re-read `k = 2` bytes from there)
would instead read `block_0`'s *second and third* bytes for the redraw — `u = 24437`, which is also
`< 40000` and so is silently accepted — returning `24437` after consuming only **3** bytes. Both
fields disagree with the correct answer above, which is exactly why this fixture exists: the
`k = 1` rejection fixture cannot tell these two algorithms apart, and this one can.

**The rejection test is `u < limit`, exclusive — the exact boundary `u === limit` must reject.**
`u === limit` is the one input value the in-range fuzz test (every accepted `nextInt(n)` output
lands in `[0, n)`) structurally cannot catch: whether `u === limit` is accepted or rejected, `u % n`
still lands in `[0, n)` either way, because `limit` is itself a multiple of `n`. A mutant that
accepts at `u === limit` is not "sometimes out of range" — it is *always* in range, just biased
toward the low end of `[0, n)` and one draw short of what the correct algorithm consumed.

**Worked example — the fixture in `test-vectors.json`'s `"boundaryRejection"` entry**
(`serverSeed = "boundary-rejection-server-seed"`, `clientSeed = "boundary-rejection-client-seed"`,
`nonce = 172`, calling `nextInt(10)`):

- `k = 1`, `limit = floor(256/10)*10 = 250`.
- `block_0`'s first byte is exactly `250` (`0xFA`) — `u === limit`, the exact boundary.
  `250 < 250` is **false** → **reject**. (A mutant testing `u <= limit` would instead accept here:
  `250 <= 250` is true.)
- Redraw: the next byte is `46`. `46 < 250` → accept. `46 % 10 = 6`.
- `nextInt(10)` returns `6`, having consumed **2** bytes. The `u <= limit` mutant would instead
  return `250 % 10 = 0` after consuming only **1** byte — both fields disagree with the correct
  answer, and this is the only fixture in this document that exercises `u === limit` at all.

**Degenerate case, `n = 1`:** the smallest `k` with `256^k >= 1` is `k = 0`. Reading 0 bytes yields `u = 0`,
`limit = floor(1/1)*1 = 1`, `0 < 1` always holds, so `nextInt(1)` always returns `0` and consumes
**zero** bytes. This is correct per the formula as written, not a special case bolted on — the
shuffle's own smallest draw is `nextInt(2)`, not `nextInt(1)`, so nothing in a shuffle fixture
exercises this case; `test-vectors.json`'s `"primary"` fixture publishes it separately as
`nextInt1: { value: 0, consumed: 0 }`, true for *any* seed (no bytes are ever read, so the
keystream's content cannot matter) but published concretely so a reimplementation that wastes a
byte here — reading one and discarding it instead of reading none — has something to fail against.

## `nextUint53()` — resolved ambiguity: exact arithmetic, not IEEE-754 double arithmetic

```
u56 = next 7 bytes, interpreted big-endian as an unsigned 56-bit integer   (EXACT — see below)
return floor(u56 / 8)
```

`engine-game-contract.md` §3.1's prose calls `u56` "exact in a double". **That parenthetical is not
reliable if taken as "accumulate the 7 bytes via ordinary floating-point multiply-add and it will
come out exact"** — it will not, in roughly 3 cases out of 10. `u56` can be as large as
`2^56 - 1 ≈ 7.2 × 10^16`, and an IEEE-754 double is only exact for integers up to `2^53 - 1`; a
naive accumulation (`acc = acc * 256 + byte`, seven times) rounds to the nearest representable
double at exactly the step where the true value first exceeds `2^53`, and that rounding error does
not average out — it changes the final `floor(u56 / 8)` result.

This was not theoretical — it was fuzz-tested before writing this document: 200,000 random 7-byte
inputs were run through both a naive double accumulation and an exact BigInt (arbitrary-precision
integer) computation of the same formula. **59,377 of 200,000 (≈29.7%) disagreed**, including the
worst case (`0xFFFFFFFFFFFFFF`, all bits set) rounding *up* to exactly `2^53` where the true
`floor(u56/8)` is `2^53 - 1` — one past the edge of what `Number.isSafeInteger` accepts.

**The fix does not change the formula — `floor(u56 / 8)` is exactly right — it changes what "exact"
means for computing it.** Assemble the 7 bytes as an arbitrary-precision (BigInt, `int` in Python,
etc.) unsigned integer, then right-shift by 3 bits (equivalently, integer-floor-divide by 8). The
result is at most 53 bits, and *that* is always exactly representable once converted to a native
float/double — the earlier claim is true of the **output**, just not of the **56-bit intermediate**
if you get there via double arithmetic. Any language with a native big-integer type (Python's `int`,
Rust's `u64`, Go's `uint64` or `big.Int`) computes this exactly without needing to think about it
further; only a from-scratch double-precision accumulation needs this warning.

## `nextFloat()`

```
nextFloat() = nextUint53() / 2^53
```

Division of two exactly-representable IEEE-754 values (`nextUint53()`'s result, which is `≤ 2^53-1`
and exact by construction above, and the constant `2^53`, which is exactly representable) is itself
exactly rounded per IEEE-754 — every conformant floating-point implementation, in any language,
produces the identical bit pattern for this division. `nextFloat()` consumes the same 7 bytes as
one `nextUint53()` call; it does not read anything extra.

## `nextBits(n)`

Reads `n` bytes, one bit per byte: `bit_j = byte_j & 1` (the least-significant bit). Returns an
array of `n` values, each `0` or `1`, and consumes exactly `n` bytes. Existing purely so a decision
that is genuinely one bit (plinko's left/right at each peg) reads as one call instead of a
`nextInt(2)` whose intent is easy to get subtly wrong across sixteen repeated calls.

## `shuffle` — total, descending Fisher-Yates

```
for i from length-1 down to 1:
  j = nextInt(i + 1)
  swap(items[i], items[j])
```

Descending, and **total** — every position from `length-1` down to `1` is visited; there is no
"partial shuffle" variant. A 20-of-80 keno draw is `shuffle(pool_of_80).slice(0, 20)`, never a
20-step partial Fisher-Yates over an 80-element array — those two procedures consume a different
number of bytes and, more importantly, are not the same probability distribution over which 20
elements come out, even though both are individually defensible shuffles. `shuffle` returns a new
array; the input is never mutated.

## `weighted(table)` / `selectWeighted(table, u53)`

Each entry in `table` is `{ item: T; weight: number }` — an arbitrary payload plus its weight, not
a name-to-weight map. `weight` must be a non-negative integer; the table's weights must sum to a
positive total (all-zero, or an empty table, is a caller error). `u53` must be a safe integer in
`[0, 2^53)` — exactly `nextUint53()`'s own range. All three are caller preconditions, not something
the formula below degrades gracefully under: this implementation throws rather than exhibiting
undefined behaviour if any is violated.

```
totalWeight = sum of table[i].weight for all i
t = floor(u53 * totalWeight / 2^53)
cumulative = 0
for index, entry in table (in order, index starting at 0):
  cumulative += entry.weight
  if cumulative > t: return { item: entry.item, index }
```

**The return value is `{ item: T; index: number }`, not the table entry itself** — the caller's
`item` and its position in `table`, not `{ item, weight }`. Given the preconditions above, this loop
always returns before falling off the end: `t` is strictly less than `totalWeight` (because
`u53 ≤ 2^53 - 1`), and `cumulative` reaches exactly `totalWeight` by the last entry, so some entry's
`cumulative > t` before or at the last one.

`rng.weighted(table)` is defined as exactly `selectWeighted(table, rng.nextUint53())`.
`selectWeighted` is also exported standalone, pure, taking no `Rng` at all — so a game whose weight
table depends on the player's own selection (the contract's example: wheel's chosen risk level)
can build that table and call `selectWeighted` directly from `settleStake`, which never receives an
`Rng` in the first place (only `deriveOutcome` does).

`u53 * totalWeight` can exceed `2^53` well before either factor alone does, so `t` and the
cumulative walk must use exact (BigInt/arbitrary-precision) arithmetic too, for the same reason as
`nextInt`.

**Worked example**, from `test-vectors.json`'s `weightedExample`
(`table = [{ item: 'common', weight: 90 }, { item: 'rare', weight: 9 }, { item: 'legendary', weight: 1 }]`,
`totalWeight = 100`):

| `u53` | `t = floor(u53·100 / 2^53)` | cumulative walk | result |
|---|---|---|---|
| `0` | `0` | `common: 90 > 0` | `{ item: 'common', index: 0 }` |
| `2^53 × 0.5` (`4503599627370496`) | `50` | `common: 90 > 50` | `{ item: 'common', index: 0 }` |
| `2^53 - 1` (`9007199254740991`) | `99` | `common: 90` (not `>99`), `rare: 99` (not `>99`), `legendary: 100 > 99` | `{ item: 'legendary', index: 2 }` |

Every sample above passes `u53` in directly, which exercises `selectWeighted`'s CDF walk but not
`rng.weighted`'s own wiring to `nextUint53()` — "`rng.weighted(table)` is defined as exactly
`selectWeighted(table, rng.nextUint53())`" was, until fix-cycle 2, prose with nothing behind it.
`test-vectors.json`'s `weightedExample.rngWeighted` closes that: calling `rng.weighted(table)` (the
same `table` above) on a fresh `Rng` returns `'common'` and consumes exactly **7** bytes — the same
cost as one `nextUint53()` call, never anything that scales with `table.length`.

## Test vectors

`packages/engine/src/rng/test-vectors.json`, shipped alongside this document, contains:

- A **primary fixture** — `serverSeed`/`clientSeed`/`nonce`, the first 64 raw keystream bytes (hex),
  the first 16 `nextInt(100)` values plus total bytes consumed by them, one `nextUint53()`/
  `nextFloat()` value, 16 `nextBits`, full 25-, 52- and 312-element shuffles (mines, a card deck, a
  six-deck shoe — the three sizes `engine-game-contract.md` §3.1 names explicitly) each paired with
  its own `*Consumed` byte count, and the `nextInt(1)` degenerate case (`nextInt1`).
- The **rejection fixture** worked through above (`k = 1`).
- A **`k2Rejection` fixture** and a **`boundaryRejection` fixture** (fix-cycle 2) — a rejection at
  `k = 2` and an exact `u === limit` rejection respectively, worked through above. Neither case is
  reachable through the `k = 1` rejection fixture or through the in-range fuzz test alone.
- A **`mixedSequence` fixture** — `nextInt`, `nextUint53`, `nextFloat`, `nextBits` and `shuffle`
  called in order on one `Rng`, with `consumed()` recorded after every step. Each fixture above
  starts a fresh `Rng` per primitive, which cannot catch a primitive that over-consumes bytes
  without its returned *value* also being wrong (a `nextFloat` that reads 14 bytes instead of 7
  would still pass the primary fixture, because nothing after it on that `Rng` observes the extra
  read). This fixture brackets every step's byte consumption against its neighbours, on the single
  stream shape a real `deriveOutcome` actually uses.
- The **`selectWeighted` worked example** above, plus **`weightedExample.rngWeighted`** (fix-cycle
  2) — `rng.weighted(table)` called on a real `Rng`, checking the wiring to `nextUint53()` and its
  byte cost, not just the pure `selectWeighted(table, u53)` CDF walk.

It is generated from the real implementation, not written by hand — see "Regenerating
test-vectors.json" below. **Every field of every fixture in this file is asserted against live
`createRng`/`selectWeighted` output in `packages/engine/src/rng/stream.test.ts`** — the file is
imported there, not merely produced alongside it — so a future change to `nextInt`, `shuffle` or
`selectWeighted` that alters any published number here fails `pnpm test` rather than shipping a
silently stale published artifact. (Fix-cycle 2 found this claim was aspirational for the `primary`
fixture and `weightedExample`, which nothing checked; it holds now.) `stream.test.ts` separately
cross-checks the raw keystream bytes against an independently-computed reference built on
`node:crypto` — a different HMAC-SHA256 code path from `stream.ts`'s `@noble/hashes` — so the
published bytes are not merely self-consistent with this module's own code, either.

### Regenerating test-vectors.json

Save the following as `generate-test-vectors.mjs` inside `packages/engine/src/rng/`, then run
`pnpm exec tsx generate-test-vectors.mjs > test-vectors.json` from that directory:

```js
import { createRng, selectWeighted } from './stream.ts';

function bytesToHex(bytes) {
  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}

function ticket(clientSeedValue, nonce) {
  return {
    chainId: 'test-vectors-chain',
    linkIndex: 1,
    clientSeed: { kind: 'player', value: clientSeedValue, rotatedAt: '2026-01-01T00:00:00.000Z' },
    nonce,
    bytesConsumed: null,
  };
}

function buildFixture(label, serverSeed, clientSeedValue, nonce, nextIntMax) {
  const t = ticket(clientSeedValue, nonce);

  const bytesRng = createRng(t, serverSeed);
  const rawBytesHex = bytesToHex(bytesRng.nextBytes(64));

  const intRng = createRng(t, serverSeed);
  const nextIntValues = Array.from({ length: 16 }, () => intRng.nextInt(nextIntMax));
  const nextIntBytesConsumed = intRng.consumed();

  const u53Rng = createRng(t, serverSeed);
  const nextUint53First = u53Rng.nextUint53();

  const floatRng = createRng(t, serverSeed);
  const nextFloatFirst = floatRng.nextFloat();

  const bitsRng = createRng(t, serverSeed);
  const nextBitsFirst16 = bitsRng.nextBits(16);

  const shuffle25Rng = createRng(t, serverSeed);
  const shuffle25 = shuffle25Rng.shuffle(Array.from({ length: 25 }, (_, i) => i));
  const shuffle25Consumed = shuffle25Rng.consumed();

  const shuffle52Rng = createRng(t, serverSeed);
  const shuffle52 = shuffle52Rng.shuffle(Array.from({ length: 52 }, (_, i) => i));
  const shuffle52Consumed = shuffle52Rng.consumed();

  const shuffle312Rng = createRng(t, serverSeed);
  const shuffle312 = shuffle312Rng.shuffle(Array.from({ length: 312 }, (_, i) => i));
  const shuffle312Consumed = shuffle312Rng.consumed();

  // The nextInt(1) degenerate case — true for ANY seed (k=0, no bytes ever
  // read), but published concretely so a reimplementation that wastes a
  // byte here has something to fail against (fix-cycle 2).
  const nextInt1Rng = createRng(t, serverSeed);
  const nextInt1Value = nextInt1Rng.nextInt(1);
  const nextInt1Consumed = nextInt1Rng.consumed();

  return {
    label,
    serverSeed,
    clientSeed: clientSeedValue,
    nonce,
    rawBytesHex,
    nextInt: { maxExclusive: nextIntMax, first16Values: nextIntValues, bytesConsumedByFirst16Draws: nextIntBytesConsumed },
    nextUint53First,
    nextFloatFirst,
    nextBitsFirst16,
    shuffle25,
    shuffle25Consumed,
    shuffle52,
    shuffle52Consumed,
    shuffle312,
    shuffle312Consumed,
    nextInt1: { value: nextInt1Value, consumed: nextInt1Consumed },
  };
}

const primary = buildFixture(
  'primary — general-purpose fixture',
  '2f3c1a9e7b5d4082c6a1f0e3b7d95c4a1e8f2b6d0937c5a4e1f8b2d6907e5c31',
  'u7-wire-spec-client-seed',
  0,
  100,
);

const rejection = (() => {
  const serverSeed = 'test-server-seed-for-u7-rejection-fixture';
  const clientSeedValue = 'test-client-seed';
  const nonce = 11;
  const t = ticket(clientSeedValue, nonce);
  const rng = createRng(t, serverSeed);
  const value = rng.nextInt(7);
  const consumedAfter = rng.consumed();
  return {
    label: 'rejection — a rejected draw still advances the cursor (k=1)',
    serverSeed,
    clientSeed: clientSeedValue,
    nonce,
    nextInt: { maxExclusive: 7, value, bytesConsumed: consumedAfter },
    note:
      'block(nonce=11) byte 0 is 0xFF (255), which is >= nextInt(7)\'s rejection limit ' +
      '(floor(256/7)*7 = 252); that draw is rejected and the cursor still advances past it. ' +
      'The accepted draw comes from byte 1 (165), giving 165 % 7 = 4 and a total of 2 bytes ' +
      'consumed for this single nextInt(7) call. A reimplementation that used byte 0 directly ' +
      '(without the rejection check) would compute 255 % 7 = 3 and consume only 1 byte — both ' +
      'observably wrong against this fixture. NOTE: at k=1, "draw a fresh k bytes on rejection" ' +
      'and "slide the window forward by 1 byte and reread" are the SAME operation, so this ' +
      'fixture alone cannot distinguish the correct algorithm from that specific wrong one — ' +
      'see the k2Rejection fixture below for the case that does.',
  };
})();

// k >= 2 rejection fixture (fix-cycle 2, BLOCKING 2): every fixture at k=1
// cannot distinguish "draw a fresh k bytes on rejection" from "slide the
// window forward by 1 byte and reread", because at k=1 those are the same
// operation. This fixture forces k=2 so the two algorithms diverge.
const k2Rejection = (() => {
  const serverSeed = 'k2-rejection-server-seed';
  const clientSeedValue = 'k2-rejection-client-seed';
  const nonce = 3;
  const t = ticket(clientSeedValue, nonce);
  const rng = createRng(t, serverSeed);
  const value = rng.nextInt(40000);
  const consumedAfter = rng.consumed();
  return {
    label: 'k2Rejection — nextInt rejects at k=2, not just k=1',
    serverSeed,
    clientSeed: clientSeedValue,
    nonce,
    nextInt: { maxExclusive: 40000, value, bytesConsumed: consumedAfter },
    note:
      'k=2 for n=40000 (256^2=65536 >= 40000, 256^1=256 < 40000); limit = ' +
      'floor(65536/40000)*40000 = 40000. block(nonce=3) bytes 0-1 give u=48991 >= 40000 -> ' +
      'reject (2 bytes consumed for nothing). A FRESH 2 bytes -- block(nonce=3) bytes 2-3 -- ' +
      'give u=30088 < 40000 -> accept, value=30088 % 40000=30088, 4 bytes consumed in total. ' +
      'A sliding-window implementation of the redraw (advance by 1 byte, reread k=2 bytes) ' +
      'would instead read bytes 1-2 (u=24437, also < 40000, silently accepted), returning ' +
      '24437 after consuming only 3 bytes -- both fields observably wrong against this fixture, ' +
      'though not against the k=1 rejection fixture above.',
  };
})();

// Exact rejection-boundary fixture (fix-cycle 2, BLOCKING 3): u === limit
// must reject (the test is u < limit, never u <= limit). The in-range fuzz
// test cannot catch a u <= limit mutant, because u % n still lands in
// [0, n) whether u === limit is accepted or rejected -- the mutant is
// biased and consumes one fewer draw, never merely "out of range".
const boundaryRejection = (() => {
  const serverSeed = 'boundary-rejection-server-seed';
  const clientSeedValue = 'boundary-rejection-client-seed';
  const nonce = 172;
  const t = ticket(clientSeedValue, nonce);
  const rng = createRng(t, serverSeed);
  const value = rng.nextInt(10);
  const consumedAfter = rng.consumed();
  return {
    label: 'boundaryRejection — u === limit must reject, not accept',
    serverSeed,
    clientSeed: clientSeedValue,
    nonce,
    nextInt: { maxExclusive: 10, value, bytesConsumed: consumedAfter },
    note:
      'n=10, k=1, limit=floor(256/10)*10=250. block(nonce=172) byte 0 is exactly 250 (0xFA) -- ' +
      'u === limit, the exact boundary. The rejection test is u < limit (never u <= limit), so ' +
      'this is rejected: 250 < 250 is false. The accepted draw comes from byte 1 (46, 46 < 250), ' +
      'value=46 % 10=6, 2 bytes consumed. A mutant testing u <= limit would instead accept at ' +
      'byte 0 directly: value=250 % 10=0, 1 byte consumed -- both fields differ from the correct ' +
      'answer above, and this is the only fixture in this file that exercises u === limit at all.',
  };
})();

const weightedExample = (() => {
  const table = [
    { item: 'common', weight: 90 },
    { item: 'rare', weight: 9 },
    { item: 'legendary', weight: 1 },
  ];
  const samples = [0, Math.floor((2 ** 53) * 0.5), 2 ** 53 - 1].map((u53) => ({
    u53,
    result: selectWeighted(table, u53),
  }));

  // rng.weighted(table) vector (fix-cycle 2): every sample above passes u53
  // in directly, which exercises the CDF walk but not rng.weighted's own
  // wiring to nextUint53() or its byte cost.
  const rngWeightedServerSeed = 'rng-weighted-server-seed';
  const rngWeightedClientSeed = 'rng-weighted-client-seed';
  const rngWeightedNonce = 9;
  const rngWeightedRng = createRng(
    ticket(rngWeightedClientSeed, rngWeightedNonce),
    rngWeightedServerSeed,
  );
  const rngWeightedItem = rngWeightedRng.weighted(table);
  const rngWeightedConsumed = rngWeightedRng.consumed();

  return {
    label: 'selectWeighted — ascending CDF walk',
    table,
    samples,
    rngWeighted: {
      serverSeed: rngWeightedServerSeed,
      clientSeed: rngWeightedClientSeed,
      nonce: rngWeightedNonce,
      item: rngWeightedItem,
      consumed: rngWeightedConsumed,
      note:
        'rng.weighted(table) is selectWeighted(table, rng.nextUint53()).item -- it costs ' +
        'exactly 7 bytes, the same as one nextUint53() call, never anything that scales with ' +
        'table.length.',
    },
  };
})();

// Mixed-consumption fixture (fix-cycle 1): every other fixture above starts
// each primitive from a FRESH createRng at cursor 0, so nothing observes
// whether an earlier call over-consumed. This one runs nextInt, nextUint53,
// nextFloat, nextBits and shuffle in sequence on a SINGLE Rng — the shape
// real deriveOutcome usage takes — and records consumed() after every step,
// so e.g. a nextFloat that double-reads (14 bytes instead of 7) is caught
// by its own bracketing consumed() values, not just by its returned value.
const mixedSequence = (() => {
  const serverSeed = 'mixed-sequence-server-seed';
  const clientSeedValue = 'mixed-sequence-client-seed';
  const nonce = 3;
  const t = ticket(clientSeedValue, nonce);
  const rng = createRng(t, serverSeed);

  const steps = [];
  const record = (call, value) => steps.push({ call, value, consumedAfter: rng.consumed() });

  record('nextInt(50)', rng.nextInt(50));
  record('nextUint53()', rng.nextUint53());
  record('nextFloat()', rng.nextFloat());
  record('nextBits(5)', rng.nextBits(5));
  record(
    'shuffle(10 items)',
    rng.shuffle(Array.from({ length: 10 }, (_, i) => i)),
  );

  return {
    label: 'mixedSequence — nextInt, nextUint53, nextFloat, nextBits, shuffle on one stream',
    serverSeed,
    clientSeed: clientSeedValue,
    nonce,
    steps,
    finalConsumed: rng.consumed(),
  };
})();

const output = {
  spec: 'docs/rng-wire-spec.md',
  generatedBy:
    'the script in docs/rng-wire-spec.md, section "Regenerating test-vectors.json", run via ' +
    'pnpm exec tsx generate-test-vectors.mjs > packages/engine/src/rng/test-vectors.json',
  fixtures: [primary, rejection, k2Rejection, boundaryRejection, mixedSequence],
  weightedExample,
};

console.log(JSON.stringify(output, null, 2));
```

The script is documented here rather than committed as a repository file — `test-vectors.json`
should reflect a deliberate, reviewed regeneration (e.g. after a real change to `stream.ts`), not
run silently as part of every `pnpm install`. Run `pnpm lint:fix` on the regenerated file afterward
— Biome reformats JSON array layout, and the script's own `JSON.stringify(output, null, 2)` output
does not match Biome's formatting byte-for-byte.

## External verification

`engine-game-contract.md` §3.1 requires "a third-party implementation must reproduce it", and the
U7 unit that implements this document requires **two** external oracles to agree before any of this
project's own arithmetic is trusted. What follows is the honest result of checking for both,
including the one that did not go as first expected, plus a stronger check added in fix-cycle 1
after review.

### 1. "Published bustabit vectors" — does not apply to this byte-stream, and here is why

[`provably-fair-crash.md`](../.rea/knowledge/provably-fair-crash.md) calls the hash-chain mechanism
"the bustabit approach". Bustabit's actual historical source (an AGPLv3-licensed archival copy,
`github.com/Dexon95/Bustabit`, `gameserver/server/lib.js`) was fetched and read directly rather than
assumed from memory. It contains exactly two relevant functions:

```js
exports.genGameHash = function(serverSeed) {
    return crypto.createHash('sha256').update(serverSeed).digest('hex');
};

exports.crashPointFromHash = function(serverSeed) {
    var hash = crypto.createHmac('sha256', serverSeed).update(clientSeed).digest('hex');
    if (divisible(hash, 101)) return 0;
    var h = parseInt(hash.slice(0,52/4),16);
    var e = Math.pow(2,52);
    return Math.floor((100 * e - h) / (e - h));
};
```

This confirms the chain mechanism (`genGameHash`: forward-generated SHA-256 chain, consumed in
reverse — exactly what `provably-fair-crash.md` describes, and squarely U19's territory, not this
document's). It also confirms that bustabit's **per-round formula is not this project's byte-stream
at all**: `crashPointFromHash` has no nonce, no block/cursor index, no rejection sampling — it takes
a single HMAC-SHA256 digest against a *fixed, chain-wide* client seed and extracts one crash point
from the top 52 bits directly. Bustabit only ever built one game; it never needed a general-purpose
`nextInt`/`shuffle`/`weighted` byte-stream serving eleven different games from one keystream, so it
never published test vectors for one. **There is no "published bustabit vector" this document's
`nextInt`, `nextUint53`, `shuffle` or `weighted` could be checked against — not because verification
was skipped, but because the artifact does not exist.** What bustabit's real algorithm *does*
confirm is the shared ancestry: HMAC-SHA256, keyed by the server-seed side of a hash chain, mixed
with an externally-sourced client seed — the same ingredients this project's keystream uses, just
composed into a richer, general-purpose wire format because this project serves more than one game
from it.

### 2. An independent reimplementation of this document, from the prose alone — the strongest check performed

**This is the check that actually answers §3.1's "an outsider can independently verify... from a
published byte-level spec and test vectors."** In fix-cycle 1, a reviewing agent had a *separate*
agent implement this document — this exact prose, this file — **in Python, explicitly forbidden
from reading `stream.ts` or `stream.test.ts`**, and permitted to compare against `test-vectors.json`
only after finishing. This tests the specification itself, not a second copy of the same
implementation idea wearing a different language — which is a stronger oracle than "an independent
third-party implementation" as originally asked for, because a from-prose reimplementation can only
succeed if the prose is actually sufficient, not merely if two people happened to arrive at the same
design.

**Result: 14 of 14 vectors matched on the first comparison, with no iteration.** The Python
implementation reproduced both worked examples in this document (the `block_0` digest under "The
keystream", and the rejection-fixture walk-through under `nextInt(n)`) before it was allowed to look
at `test-vectors.json` at all. Every trap this document explicitly warns about was closed correctly
on the first attempt: the `utf8(hexString)` key encoding (not hex-decoded), the rejected draw
advancing by a fresh `k` bytes rather than reusing or reinterpreting the rejected ones, the
descending direction of the Fisher-Yates shuffle, byte order at both `nextInt` and `nextUint53`, the
`nextInt(1)` degenerate case, and "the cursor is never capped."

The "Byte encoding of the key" section above was singled out as the most load-bearing prose in this
document — specifically because it names the *wrong* answer (hex-decoding) explicitly and says why
it is tempting, rather than only stating the right one. Anyone editing this document in the future:
**do not trim that section for brevity.** The reason it worked is that it pre-empts the mistake, not
just documents the fix.

The three defects fix-cycle 1 actually found — a truncated digest in the worked example, a
self-contradicting `selectWeighted` shape across three places in this document, and two coverage
gaps in `test-vectors.json` — did not affect this result: none of them are algorithm bugs. The
`block_0` truncation was a correct 31-byte prefix of a 32-byte value (caught on inspection, not
because it produced a wrong keystream), and the `selectWeighted` shape mismatch does not change byte
consumption or which item wins, only what the return value is *called*. All three are fixed
elsewhere in this document as of this revision.

**Fix-cycle 2 repeated this exercise with a second, independent Python implementation, again built
from this document's prose alone and again forbidden from reading `stream.ts` or `stream.test.ts`.
It matched all 18 worked examples in this document before being allowed to open
`test-vectors.json`, then matched 30 of 30 fixture values with zero iteration.** It independently
reproduced this document's own empirical claim about `nextUint53`'s naive-double-accumulation
failure rate — this document says "≈29.7%"; the second oracle's own fuzz run measured 29.74% — and
confirmed the fix-cycle-1 `selectWeighted` shape corrections hold in all four places they needed to
(the pseudocode, the return-value description, the worked-example table, and the generation
script). The exit-gate condition — an outsider reimplementing this document and landing on
byte-identical output — is now demonstrated twice, by two people who never saw each other's work or
this project's own code.

### 3. An independent, published, third-party keystream implementation

This project's keystream —
`HMAC-SHA256(serverSeed, "${clientSeed}:${nonce}:${round}")`, bytes consumed sequentially from
round 0 — is, byte for byte, the same construction the "Stake Originals" provably-fair scheme
(used across many crypto-casino "Originals" catalogues, not specific to any one operator's brand)
publishes and that a number of independent, unrelated third parties have reimplemented from public
documentation. One such package — `@cgfoundation/stake-provably-fair-verifier` (npm, MIT-family
license, `github.com/KyranRana/stake-provably-fair-verifier`, no relationship to this project or its
author) — was installed and read directly (not from memory). Its byte generator
(`Utils/GameSeedUtils.js`):

```js
static* _byteGenerator({serverSeed, clientSeed, nonce}) {
  let currentRound = 0;
  while (true) {
    const HASH = sha256.hmac.create(serverSeed);
    HASH.update(`${clientSeed}:${nonce}:${currentRound++}`);
    const BUFFER = HASH.digest();
    for (let i = 0; i < 32; i++) yield Number(BUFFER[i]);
  }
}
```

is textually the same construction as this document's keystream section. Its test suite
(`Tests/Games/Mines.test.js`) ships **real, previously-published Stake game data** — a
`(serverSeed, clientSeed, nonce range)` triple with 49 expected 24-item Mines results — sourced from
actual live outcomes, not synthesized for a test. That package's vendored SHA-256 (`js-sha256`, a
separate, independent implementation from both `node:crypto`'s OpenSSL-backed one and this project's
`@noble/hashes`) was run directly against that published fixture and reproduced its expected output
exactly, confirming the package genuinely implements the real scheme rather than merely claiming to.

`packages/engine/src/rng/stream.test.ts`'s `"External verification"` describe block asserts that
`createRng`'s raw keystream bytes for that exact real `(serverSeed, clientSeed, nonce=1)` triple
match an independently-computed `node:crypto` HMAC-SHA256 of the identical message — the primitive
this project's keystream shares with that package. **This is not a claim that this project's
`nextInt`/`shuffle` reproduce that package's published Mines picks** — they deliberately do not:
this project's `nextInt` is rejection-sampled (eliminating modulo bias entirely, at the cost of an
occasional extra byte), while the real Stake Originals scheme that package verifies selects via
`floor(float × n)` on a 4-byte float — a different, simpler, slightly biased draw. Only the shared
primitive, the HMAC-SHA256 byte keystream itself, is checked against it.

### 4. Three independent HMAC-SHA256 code paths agree

Before trusting `@noble/hashes` on the code path that decides every outcome in the system, its
output was cross-checked, for the same key and message, against `node:crypto` (OpenSSL) and the
`js-sha256` package vendored inside the third-party verifier above (a third, independent SHA-256
implementation) — all three produced identical digests across the fixtures exercised. This is why
`stream.ts`'s own doc comment describes the dependency choice as checked, not assumed.

## Why `@noble/hashes`, not `node:crypto` or WebCrypto

`packages/engine/src/rng/stream.ts` must run identically on the server (Node) and inside U16's
browser verifier. `node:crypto` does not exist in a browser. WebCrypto's `SubtleCrypto.digest`/
`sign` are **asynchronous**, which would force `createRng` — and therefore `deriveOutcome`, and
therefore every game file that calls it — to become `async` for no reason but the wrong tool
choice; `engine-game-contract.md`'s whole contract assumes synchronous, pure game functions.
`@noble/hashes` is audited, has zero runtime dependencies, is pure TypeScript, and is synchronous in
both environments. Depending on it is a real supply-chain decision on the one code path that decides
every payout in the system — not a casual addition — which is why it is named and justified both
here and in `stream.ts`'s own module doc comment, and why its output was independently
cross-checked (above) rather than trusted on the strength of its reputation alone.

`packages/engine/package.json` pins `@noble/hashes` to an **exact** version (`2.4.0`, no `^`), not a
caret range. `pnpm-lock.yaml` plus CI's `--frozen-lockfile` already stop an unreviewed version from
slipping in, but nothing about that mechanism re-runs "External verification" item 4's three-way
HMAC cross-check — the actual reason this dependency is trusted at all — when the resolved version
does change. **Bumping this dependency requires re-running that cross-check before the version bump
lands, not after.**

## Resolved ambiguities — a summary

For anyone comparing this document against `engine-game-contract.md` §3.1's terser prose:

1. **`key = serverSeed bytes`** means UTF-8-encode the hex string, not hex-decode it to 32 raw
   bytes. See "Byte encoding of the key" above.
2. **`u56 (exact in a double)`** is true of `nextUint53`'s final *result* (≤ 53 bits), not of the
   56-bit intermediate if computed via naive IEEE-754 double accumulation, which diverges from the
   exact value in ~30% of cases (empirically measured). Use an exact/arbitrary-precision integer
   type for the 7-byte assembly and the right-shift; only convert to a native float at the end. See
   "`nextUint53()`" above.
3. **"Published bustabit vectors"** do not exist for this document's byte-stream — bustabit's real,
   inspected source implements a narrower, single-purpose crash-point formula with no nonce, cursor
   or rejection sampling. See "External verification", item 1.
4. **`nextInt(1)`** is not called out explicitly by §3.1's prose; working the formula through gives
   `k = 0`, always returns `0`, consumes zero bytes. Not a special case, just worth stating so two
   implementations do not diverge on whether `nextInt(1)` should read a (wasted) byte.
5. **`k = ceil(log256(n))`** is stated in `engine-game-contract.md` §3.1 as a real-valued logarithm.
   Compute it as exact integer arithmetic instead — the smallest `k` with `256^k >= n` — because a
   floating-point `log(n)/log(256)` is not guaranteed to round to exactly the right side of an
   integer boundary at an exact power of 256, which would desynchronise the cursor from that draw
   onward. Same hazard class as item 2, smaller magnitude. See `nextInt(n)` above.

### Document corrections from fix-cycle 1

Found by an independent Python reimplementation built from this document's prose alone (see
"External verification", item 2) plus manual review. None of these are algorithm bugs — the
byte-level behaviour was already correct — but a reader working from a cached or printed copy of an
earlier revision should know what changed:

- The `block_0` worked example under "The keystream" was missing its last byte (`09`) — a truncated
  but otherwise correct SHA-256 HMAC digest, now the full 32 bytes / 64 hex characters.
- The `selectWeighted` section stated three different, mutually contradictory shapes for a table
  entry and its return value across the pseudocode, the worked example and the generation script.
  All three now read `{ item: T; weight: number }` for a table entry and `{ item: T; index: number }`
  for the return value, matching `stream.ts` and `Weighted<T>` (`entropy.ts`).
- `test-vectors.json` gained a `mixedSequence` fixture — `nextInt`, `nextUint53`, `nextFloat`,
  `nextBits` and `shuffle` called in sequence on one `Rng`, with `consumed()` recorded after each
  step — closing a gap where every other fixture started each primitive from a fresh `Rng` at
  cursor 0 and so could not catch one primitive silently over-consuming bytes at another's expense.
- The 312-card shoe byte estimate under "The keystream" was corrected from a hand-waved "roughly
  600–700 bytes" (≈40% high) to the measured values: 430 for `shuffle312`, 53 for `shuffle52`, 24
  for `shuffle25`.
- `nextInt(n)`'s `k` is now stated as exact integer arithmetic rather than a real-valued logarithm
  (item 5 above), and its domain (`n ≥ 1`) and `selectWeighted`'s domain (non-negative integer
  weights summing to a positive total; `u53` in `[0, 2^53)`) are now stated explicitly as caller
  preconditions rather than left implicit.
- The opening "this document is authoritative" rule was qualified to distinguish byte-level
  algorithm content (where it holds) from incidental API shape (where the actual TypeScript types
  are the source of truth) — the `selectWeighted` shape bug above is why.

### Document corrections from fix-cycle 2

Found by five independent reviews, including a second from-prose Python reimplementation (see
"External verification", item 2) that matched all 30 fixture values and all 18 worked examples.
None of these are algorithm bugs either — the byte-level behaviour was already correct in every
case — they are gaps in how much of it was actually checked, and one prose gap:

- `packages/engine/src/rng/stream.test.ts` did not import `test-vectors.json` at all. Only the
  `rejection` and `mixedSequence` fixtures had their numbers hand-copied into assertions; the
  `primary` fixture and `weightedExample` were asserted against nothing, including the three
  shuffle sizes `engine-game-contract.md` §3.1 names as required. `stream.test.ts` now imports the
  file directly and checks every field of every fixture against live `createRng`/`selectWeighted`
  output — see "Test vectors" above.
- No fixture ever forced a rejection at `k ≥ 2` (every one measured was `k = 1`), and at `k = 1`
  "draw a fresh `k` bytes on rejection" and "slide the window forward by one byte and reread" are
  the same operation — so that specific wrong implementation of the rejection redraw passed every
  fixture then published. The new `k2Rejection` fixture (`nextInt(40000)`, `k = 2`) closes this;
  see `nextInt(n)` above.
- The exact rejection boundary (`u === limit`) had no fixture, and the in-range fuzz test
  structurally cannot catch it: `u % n` lands in `[0, n)` whether `u === limit` is accepted or
  rejected, because `limit` is itself a multiple of `n`, so a `u <= limit` mutant is never
  "out of range" — only biased and one draw short. The new `boundaryRejection` fixture closes this;
  see `nextInt(n)` above.
- This document claimed the 312/52/25-card shuffle byte counts were "measured" in
  `test-vectors.json`, but no shuffle fixture carried a consumption field at all — the citation
  pointed at nothing. `shuffle25Consumed`/`shuffle52Consumed`/`shuffle312Consumed` now exist and
  are checked; see "The keystream" above.
- `consumed()` was used nine times across this document without ever being defined. One sentence
  now defines it, under "The cursor and `nextBytes(n)`".
- `nextInt(1)` and `rng.weighted(table)` were each described in prose but had no published vector —
  a byte-wasting `nextInt(1)` or a `rng.weighted` that costs more than 7 bytes would have passed
  every fixture then published. Both now have one; see `nextInt(n)` and `weighted(table)` above.
- `packages/engine/package.json` pinned `@noble/hashes` to `^2.4.0` rather than an exact version,
  so a minor/patch bump could resolve to a version whose output was never cross-checked. Now pinned
  to `2.4.0` exactly, with a note that a version bump requires re-running "External verification"
  item 4's cross-check before landing.
- `stream.ts`'s `nextInt` and `nextUint53` each wrote out the same big-endian-BigInt assembly loop.
  Extracted to one `bytesToBigIntBE` helper.
