DocsPorting

Test Vectors

What's in the vector files, and a 60-line Python program that reproduces all 2,240 game results without the library.

"It matched on my three test rounds" is how wrong implementations ship. The repository carries vectors so that a port can be checked against thousands of rounds chosen to be awkward: an all-zero seed, an all-f seed, a one-character client seed, a client seed with spaces, nonces at 0, 255 and 65,535.

Vector Files

FileHolds
vectors/gfs-1.0.json4 commitments, 24 digests, 12 float extractions, 2,240 game results
vectors/gfs-1.0-crash.jsonOne 12-game chain with its secret, 48 results across two salts and two edges
vectors/gfs-1.0-sign.jsonEd25519 signing cases

They're generated by the reference implementation and then frozen. A published vector is never edited. If one turns out to be wrong, the fix is a new spec version with an erratum, because somebody's conformance run may already depend on the old bytes.

Work up through the first file in the order it's laid out. If your commitments are wrong nothing later can be right, and a digest mismatch tells you the problem is the HMAC key or the message, before any game arithmetic gets involved.

shape.mjs
import { readFile } from 'node:fs/promises';

const vectors = JSON.parse(await readFile('vectors/gfs-1.0.json', 'utf8'));
console.log(vectors.counts);
console.log(vectors.games[0]);
Output
{ commitments: 4, digests: 24, floats: 12, games: 2240 }
{
  game: 'dice',
  params: {},
  serverSeed: '0000000000000000000000000000000000000000000000000000000000000000',
  clientSeed: 'galabet',
  nonce: 0,
  cursor: 0,
  result: 75.69
}

Python Reference Runner

This is a complete second implementation of the single-player profile. It shares no code with the library. Read it as the spec in executable form.

run_vectors.py
import hashlib
import hmac
import json
import math
from collections import Counter

RANKS, SUITS = "A23456789TJQK", "CDHS"


def floats(server_seed, client_seed, nonce, count):
    out, cursor = [], 0
    while len(out) < count:
        message = f"{client_seed}:{nonce}:{cursor}".encode()
        digest = hmac.new(server_seed.encode(), message, hashlib.sha256).digest()
        out += [int.from_bytes(digest[i:i + 4], "big") / 2**32 for i in range(0, 32, 4)]
        cursor += 1
    return out[:count]


def shuffle(fs, size):
    items = list(range(size))
    for n, i in enumerate(range(size - 1, 0, -1)):
        j = math.floor(fs[n] * (i + 1))
        items[i], items[j] = items[j], items[i]
    return items


def play(v):
    p, game = v["params"], v["game"]
    need = {"plinko": p.get("rows", 16), "mines": 24, "keno": 39,
            "blackjack": 52 * p.get("decks", 1) - 1, "hilo": 52 * p.get("decks", 1) - 1}.get(game, 1)
    f = floats(v["serverSeed"], v["clientSeed"], v["nonce"], need)
    if game == "dice":
        return math.floor(f[0] * 10001) / 100
    if game == "limbo":
        return max(1, math.floor(1e8 / (f[0] * 1e8 + 1) * (1 - p.get("houseEdge", 0.01)) * 100) / 100)
    if game == "roulette":
        return math.floor(f[0] * 37)
    if game == "wheel":
        return math.floor(f[0] * p.get("segments", 10))
    if game == "plinko":
        path = [0 if x < 0.5 else 1 for x in f]
        return {"path": path, "bucket": sum(path)}
    if game == "mines":
        return sorted(shuffle(f, 25)[:p.get("mines", 3)])
    if game == "keno":
        return sorted(n + 1 for n in shuffle(f, 40)[:p.get("draws", 10)])
    cards = shuffle(f, 52 * p.get("decks", 1))
    return [RANKS[c % 52 % 13] + SUITS[c % 52 // 13] for c in cards]


with open("vectors/gfs-1.0.json") as handle:
    games = json.load(handle)["games"]

passed = Counter(v["game"] for v in games if play(v) == v["result"])
for game, total in Counter(v["game"] for v in games).items():
    print(f"{game:10} {passed[game]:4} of {total}")
Output
dice        112 of 112
limbo       336 of 336
roulette    112 of 112
wheel       336 of 336
plinko      336 of 336
mines       336 of 336
keno        336 of 336
blackjack   224 of 224
hilo        112 of 112

A few lines in there carry more weight than they look.

server_seed.encode() is the hex string turned into bytes as text. Replace it with bytes.fromhex(server_seed) and every game fails at once, which is at least an unambiguous signal.

shuffle takes its floats in order, one per swap, walking i downwards from the end. Walking upwards is also a valid Fisher-Yates, and gives different boards.

Limbo is written as one expression on purpose. It's the only mapper where the order of floating-point operations can change the last digit, and that ordering matches the reference.

The card line reads c % 52 first because a multi-deck shoe numbers its cards 0 to 52 × decks − 1 and labels repeat every 52.

What the Vectors Do Not Cover

These vectors exercise derivation and mapping. They don't test your nonce handling, your seed storage, whether you publish commitments before bets, or anything about payouts. A port can pass all 2,240 and still be part of an unfair casino. It can't pass them and compute a different result from everyone else, and that is the property they're for.