From eeb12edb8fa0be22a4f34c5d52f3537b053a9d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Jan=20Fialka?= Date: Fri, 20 Feb 2026 13:14:38 +0100 Subject: [PATCH 1/2] [Space Lua]: Implement `math.random` and `math.randomseed` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `Math.random()` with a seedable `xoshiro256**` class `LuaPRNG` matching Lua semantics. Argument validation is in `LuaPRNG` class keeping `math.ts` a passthrough. State is seeded with 16 warmup discards as **required** by the `xoshiro256**` specification. Signed-off-by: Matouš Jan Fialka --- client/space_lua/stdlib/math.ts | 62 ++++------- client/space_lua/stdlib/math_test.lua | 115 ++++++++++++++++++++ client/space_lua/stdlib/prng.ts | 150 ++++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 44 deletions(-) create mode 100644 client/space_lua/stdlib/prng.ts diff --git a/client/space_lua/stdlib/math.ts b/client/space_lua/stdlib/math.ts index f83c0d3e8..c228d46de 100644 --- a/client/space_lua/stdlib/math.ts +++ b/client/space_lua/stdlib/math.ts @@ -5,6 +5,10 @@ import { LuaTable, } from "../runtime.ts"; import { isNegativeZero, isTaggedFloat } from "../numeric.ts"; +import { LuaPRNG } from "./prng.ts"; + +// One PRNG per module load, auto-seeded at startup +const prng = new LuaPRNG(); // Fast unwrap: avoids function call overhead for the common plain-number case function untagNumber(x: any): number { @@ -52,51 +56,21 @@ export const mathApi = new LuaTable({ random: new LuaBuiltinFunction((_sf, m?: number, n?: number) => { if (m !== undefined) m = untagNumber(m); if (n !== undefined) n = untagNumber(n); - - if (m === undefined && n === undefined) { - return Math.random(); - } - - if (!Number.isInteger(m)) { - throw new LuaRuntimeError( - "bad argument #1 to 'math.random' (integer expected)", - _sf, - ); - } - - if (n === undefined) { - if (m! == 0) { - const high = Math.floor(Math.random() * 0x100000000); - const low = Math.floor(Math.random() * 0x100000000); - let result = (BigInt(high) << 32n) | BigInt(low); - if (result & (1n << 63n)) { - result -= 1n << 64n; - } - return result; - } - if (m! < 1) { - throw new LuaRuntimeError( - "bad argument #1 to 'math.random' (interval is empty)", - _sf, - ); - } - return Math.floor(Math.random() * m!) + 1; - } - - if (!Number.isInteger(n!)) { - throw new LuaRuntimeError( - "bad argument #2 to 'math.random' (integer expected)", - _sf, - ); - } - - if (n! < m!) { - throw new LuaRuntimeError( - "bad argument #1 to 'math.random' (interval is empty)", - _sf, - ); + try { + return prng.random(m, n); + } catch (e: any) { + throw new LuaRuntimeError(e.message, _sf); } - return Math.floor(Math.random() * (n! - m! + 1)) + m!; + }), + /** + * Seeds the pseudo-random generator. With no arguments, uses a + * time-based seed. Returns the two seed integers used (Lua 5.4 contract). + */ + randomseed: new LuaBuiltinFunction((_sf, x?: number, y?: number) => { + if (x !== undefined) x = untagNumber(x); + if (y !== undefined) y = untagNumber(y); + const [s1, s2] = prng.randomseed(x, y); + return new LuaMultiRes([s1, s2]); }), // Basic functions diff --git a/client/space_lua/stdlib/math_test.lua b/client/space_lua/stdlib/math_test.lua index c6e4c7f73..b7db520b0 100644 --- a/client/space_lua/stdlib/math_test.lua +++ b/client/space_lua/stdlib/math_test.lua @@ -25,6 +25,13 @@ local function assertClose(a, b, eps, msg) end end +local function assertError(fn, msg) + local ok, err = pcall(fn) + if ok then + error((msg or "assertError failed") .. ": expected an error but none was raised") + end +end + -- math.type (basic) do assertEquals(math.type(0), "integer", "math.type(0)") @@ -161,3 +168,111 @@ do assertEquals(f2, 3, "floor(8*0.49)") assertEquals(f3, 4, "floor(9*0.5)") end + +-- random and randomseed +do + -- random(): float in [0, 1) + for _ = 1, 20 do + local v = math.random() + assertTrue(v >= 0 and v < 1, "random() in [0,1)") + assertEquals(math.type(v), "float", "random() returns float") + end + + -- random(n): integer in [1, n] + for _ = 1, 20 do + local v = math.random(10) + assertTrue(v >= 1 and v <= 10, "random(10) in [1,10]") + assertEquals(math.type(v), "integer", "random(n) returns integer") + end + + -- random(m, n): integer in [m, n] + for _ = 1, 20 do + local v = math.random(5, 10) + assertTrue(v >= 5 and v <= 10, "random(5,10) in [5,10]") + assertEquals(math.type(v), "integer", "random(m,n) returns integer") + end + + -- random(m, m): always returns m + for _ = 1, 5 do + assertEquals(math.random(7, 7), 7, "random(m,m) == m") + end + + -- random(0): raw 64-bit integer, type integer + local r0 = math.random(0) + assertEquals(math.type(r0), "integer", "random(0) returns integer") + + -- random(n) with n=1: always 1 + for _ = 1, 5 do + assertEquals(math.random(1), 1, "random(1) == 1") + end + + -- error: random(0.5) — non-integer arg1 + assertError(function() math.random(0.5) end) + + -- error: random(1, 0) — empty interval + assertError(function() math.random(1, 0) end) + + -- error: random(0) is valid (raw), but random(-1) is not + assertError(function() math.random(-1) end) + + -- error: random(1, 1.5) — non-integer arg2 + assertError(function() math.random(1, 1.5) end) + + -- randomseed arg validation + assertError(function() math.randomseed(0.5) end) + assertError(function() math.randomseed(1, 0.5) end) + assertError(function() math.randomseed(1/0) end) +end + +-- randomseed: determinism +do + -- same seed yields identical sequence + math.randomseed(42) + local a1, a2, a3 = math.random(), math.random(100), math.random(1, 50) + + math.randomseed(42) + local b1, b2, b3 = math.random(), math.random(100), math.random(1, 50) + + assertEquals(a1, b1, "randomseed: float sequence reproducible") + assertEquals(a2, b2, "randomseed: random(n) reproducible") + assertEquals(a3, b3, "randomseed: random(m,n) reproducible") + + -- different seeds yields different first values + math.randomseed(1) + local c1 = math.random() + math.randomseed(2) + local d1 = math.random() + assertTrue(c1 ~= d1, "different seeds give different values") + + -- two-argument seed: same pair yields same sequence + math.randomseed(123, 456) + local e1, e2 = math.random(), math.random() + math.randomseed(123, 456) + local f1, f2 = math.random(), math.random() + assertEquals(e1, f1, "randomseed(x,y): first value reproducible") + assertEquals(e2, f2, "randomseed(x,y): second value reproducible") + + -- two-argument seed: different second arg yields different sequence + math.randomseed(123, 456) + local g1 = math.random() + math.randomseed(123, 789) + local h1 = math.random() + assertTrue(g1 ~= h1, "randomseed(x,y1) ~= randomseed(x,y2)") + + -- no-args seed does not error and produces valid range + math.randomseed() + local i1 = math.random() + assertTrue(i1 >= 0 and i1 < 1, "randomseed(): range valid after auto-seed") + + -- return values: two integers (Lua 5.4 contract) + local s1, s2 = math.randomseed(99) + assertEquals(math.type(s1), "integer", "randomseed returns integer s1") + assertEquals(math.type(s2), "integer", "randomseed returns integer s2") + + -- re-seeding restores determinism after auto-seed + math.randomseed(7) + local j1 = math.random() + math.randomseed(7) + local j2 = math.random() + assertEquals(j1, j2, "determinism restored after re-seed") +end diff --git a/client/space_lua/stdlib/prng.ts b/client/space_lua/stdlib/prng.ts new file mode 100644 index 000000000..8916f621d --- /dev/null +++ b/client/space_lua/stdlib/prng.ts @@ -0,0 +1,150 @@ +// PRNG based on xoshiro256** for Space Lua + +export class LuaPRNG { + private state: BigUint64Array; + + constructor() { + this.state = new BigUint64Array(4); + this.autoSeed(); + } + + private rotl(x: bigint, k: number): bigint { + k = k & 63; + return ((x << BigInt(k)) | (x >> BigInt(64 - k))) & 0xFFFFFFFFFFFFFFFFn; + } + + private nextrand(): bigint { + const s = this.state; + const s0 = s[0]; + const s1 = s[1]; + const s2 = s[2]; + const s3 = s[3]; + + const res = this.rotl((s1 * 5n) & 0xFFFFFFFFFFFFFFFFn, 7) * 9n & + 0xFFFFFFFFFFFFFFFFn; + + const t = (s1 << 17n) & 0xFFFFFFFFFFFFFFFFn; + s[2] = s2 ^ s0; + s[3] = s3 ^ s1; + s[1] = s1 ^ s[2]; + s[0] = s0 ^ s[3]; + s[2] = s[2] ^ t; + s[3] = this.rotl(s[3], 45); + + return res; + } + + public setSeed(seed1: bigint, seed2: bigint = 0n): [bigint, bigint] { + const MASK = 0xFFFFFFFFFFFFFFFFn; + const s = this.state; + + const sm64 = (x: bigint): bigint => { + x = (x ^ (x >> 30n)) * 0xBF58476D1CE4E5B9n & MASK; + x = (x ^ (x >> 27n)) * 0x94D049BB133111EBn & MASK; + return (x ^ (x >> 31n)) & MASK; + }; + + s[0] = sm64(seed1 & MASK); + s[1] = sm64((seed1 & MASK) | 0xFFn); + s[2] = sm64(seed2 & MASK); + s[3] = sm64(0n); + + for (let i = 0; i < 16; i++) { + this.nextrand(); + } + + return [seed1, seed2]; + } + + private autoSeed(): [bigint, bigint] { + const t = BigInt(Date.now()); + const entropy = BigInt(Math.floor(performance.now() * 1000)); + return this.setSeed(t, entropy); + } + + private project(ran: bigint, n: bigint): bigint { + if (n === 0n) return 0n; + + let lim = n; + lim |= lim >> 1n; + lim |= lim >> 2n; + lim |= lim >> 4n; + lim |= lim >> 8n; + lim |= lim >> 16n; + lim |= lim >> 32n; + + while (true) { + ran &= lim; + if (ran <= n) return ran; + ran = this.nextrand(); + } + } + + // Validates that a number is a valid integer + private checkInteger(v: number, argN: number): void { + if (!isFinite(v)) { + throw new Error( + `bad argument #${argN} to 'random' (number has no integer representation)`, + ); + } + if (!Number.isInteger(v)) { + throw new Error( + `bad argument #${argN} to 'random' (number has no integer representation)`, + ); + } + } + + // `random()` yields float in [0, 1) + // `random(0)` yields raw 64-bit signed integer (all bits random) + // `random(n)` yields integer in [1, n] + // `random(m, n)` yields integer in [m, n] + public random(arg1?: number, arg2?: number): number | bigint { + const rv = this.nextrand(); + + if (arg1 === undefined) { + // Top 53 bits for full double precision + return Number(rv >> 11n) * (1.0 / 9007199254740992.0); + } + + this.checkInteger(arg1, 1); + + if (arg2 === undefined) { + if (arg1 === 0) { + // Raw 64-bit as signed bigint + const signed = rv > 0x7FFFFFFFFFFFFFFFn + ? rv - 0x10000000000000000n + : rv; + return signed; + } + if (arg1 < 1) { + throw new Error( + "bad argument #1 to 'random' (interval is empty)", + ); + } + return Number(this.project(rv, BigInt(arg1) - 1n) + 1n); + } + + this.checkInteger(arg2, 2); + + if (arg2 < arg1) { + throw new Error( + "bad argument #2 to 'random' (interval is empty)", + ); + } + return Number(this.project(rv, BigInt(arg2) - BigInt(arg1)) + BigInt(arg1)); + } + + // Returns [seed1, seed2] + public randomseed(arg1?: number, arg2?: number): [bigint, bigint] { + if (arg1 === undefined) { + return this.autoSeed(); + } + this.checkInteger(arg1, 1); + if (arg2 !== undefined) { + this.checkInteger(arg2, 2); + } + const s1 = BigInt(Math.trunc(arg1)); + const s2 = arg2 !== undefined ? BigInt(Math.trunc(arg2)) : 0n; + return this.setSeed(s1, s2); + } +} From afcdbaeb9e4ded5fce1287f55f6e688428bdbe4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Jan=20Fialka?= Date: Fri, 20 Feb 2026 13:31:03 +0100 Subject: [PATCH 2/2] Fix errors messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matouš Jan Fialka --- client/space_lua/stdlib/prng.ts | 45 +++++++++++++++------------------ 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/client/space_lua/stdlib/prng.ts b/client/space_lua/stdlib/prng.ts index 8916f621d..ecb9d51e0 100644 --- a/client/space_lua/stdlib/prng.ts +++ b/client/space_lua/stdlib/prng.ts @@ -80,20 +80,6 @@ export class LuaPRNG { } } - // Validates that a number is a valid integer - private checkInteger(v: number, argN: number): void { - if (!isFinite(v)) { - throw new Error( - `bad argument #${argN} to 'random' (number has no integer representation)`, - ); - } - if (!Number.isInteger(v)) { - throw new Error( - `bad argument #${argN} to 'random' (number has no integer representation)`, - ); - } - } - // `random()` yields float in [0, 1) // `random(0)` yields raw 64-bit signed integer (all bits random) // `random(n)` yields integer in [1, n] @@ -106,7 +92,11 @@ export class LuaPRNG { return Number(rv >> 11n) * (1.0 / 9007199254740992.0); } - this.checkInteger(arg1, 1); + if (!isFinite(arg1) || !Number.isInteger(arg1)) { + throw new Error( + "bad argument #1 to 'random' (number has no integer representation)", + ); + } if (arg2 === undefined) { if (arg1 === 0) { @@ -117,20 +107,19 @@ export class LuaPRNG { return signed; } if (arg1 < 1) { - throw new Error( - "bad argument #1 to 'random' (interval is empty)", - ); + throw new Error("bad argument #1 to 'random' (interval is empty)"); } return Number(this.project(rv, BigInt(arg1) - 1n) + 1n); } - this.checkInteger(arg2, 2); - - if (arg2 < arg1) { + if (!isFinite(arg2) || !Number.isInteger(arg2)) { throw new Error( - "bad argument #2 to 'random' (interval is empty)", + "bad argument #2 to 'random' (number has no integer representation)", ); } + if (arg2 < arg1) { + throw new Error("bad argument #2 to 'random' (interval is empty)"); + } return Number(this.project(rv, BigInt(arg2) - BigInt(arg1)) + BigInt(arg1)); } @@ -139,9 +128,15 @@ export class LuaPRNG { if (arg1 === undefined) { return this.autoSeed(); } - this.checkInteger(arg1, 1); - if (arg2 !== undefined) { - this.checkInteger(arg2, 2); + if (!isFinite(arg1) || !Number.isInteger(arg1)) { + throw new Error( + "bad argument #1 to 'randomseed' (number has no integer representation)", + ); + } + if (arg2 !== undefined && (!isFinite(arg2) || !Number.isInteger(arg2))) { + throw new Error( + "bad argument #2 to 'randomseed' (number has no integer representation)", + ); } const s1 = BigInt(Math.trunc(arg1)); const s2 = arg2 !== undefined ? BigInt(Math.trunc(arg2)) : 0n;