From 928d42220c947bfe106c46b76c3a9fafd88c6671 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:08:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(rng):=20=E5=AE=9E=E7=8E=B0=201.13c=20PRNG?= =?UTF-8?q?=2064=E4=BD=8D=20LCG=20=E4=B8=8E=20per-unit=20seed=20(Issue=20#?= =?UTF-8?q?87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/d2-rng.ts | 155 ++++++++++++++++++++++ tests/d2-rng.test.ts | 296 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 src/game/d2-rng.ts create mode 100644 tests/d2-rng.test.ts diff --git a/src/game/d2-rng.ts b/src/game/d2-rng.ts new file mode 100644 index 0000000..1565b34 --- /dev/null +++ b/src/game/d2-rng.ts @@ -0,0 +1,155 @@ +/** + * 1.13c Diablo II Linear Congruential Generator (LCG) PRNG and Per-Unit Seed. + * + * Gold standard references: + * - D2Common!6FD510B0 (SEED_RollRandomNumber / SEED_RollLimitedRandomNumber) + * - D2Common!6FD51180 (SEED_InitLowSeed / SEED_InitSeed) + * - D2Game!6FC211D0 (Unit seed operations / UNITS_RollRandom) + * + * In Diablo II 1.13c, PRNG state is maintained as a 64-bit value split into + * two 32-bit registers (nLowSeed and nHighSeed). The recurrence relation is: + * state64 = (uint64_t)nLowSeed * 0x6AC690C5 + nHighSeed + * nLowSeed = (uint32_t)state64 + * nHighSeed = (uint32_t)(state64 >> 32) + * + * Each unit (monsters, players, items, missiles) encapsulates its own seed state, + * preventing cross-entity RNG stream contamination and ensuring lockstep determinism. + */ + +/** Canonical 64-bit LCG multiplier in Diablo II 1.13c (0x6AC690C5 = 1791398085). */ +export const D2_PRNG_MULTIPLIER = 0x6ac690c5n + +/** Seed state representing the low and high 32-bit registers. */ +export interface D2SeedState { + lo: number + hi: number +} + +/** + * 1.13c Diablo II PRNG implementation with per-unit seed encapsulation. + */ +export class D2Rng { + private _lo = 0 + private _hi = 0 + + /** + * Initializes a new PRNG instance. + * + * @param seed - Optional initial 32-bit low seed or { lo, hi } state object. + * @param hi - Optional initial 32-bit high seed (defaults to 0 if not provided). + */ + constructor(seed?: number | D2SeedState, hi?: number) { + if (seed !== undefined) { + if (typeof seed === 'number') { + this.setSeed(seed, hi) + } else { + this.setSeed(seed.lo, seed.hi ?? hi) + } + } + } + + /** Current 32-bit low seed register. */ + get lo(): number { + return this._lo + } + + /** Current 32-bit high seed register. */ + get hi(): number { + return this._hi + } + + /** + * Updates the seed state registers. + * + * @param lo - Low 32-bit seed value. + * @param hi - High 32-bit seed value (defaults to 0). + */ + setSeed(lo: number, hi?: number): void { + this._lo = lo >>> 0 + this._hi = (hi ?? 0) >>> 0 + } + + /** + * Returns the current seed state as a { lo, hi } pair. + */ + getSeed(): D2SeedState { + return { lo: this._lo, hi: this._hi } + } + + /** + * Advances the 64-bit LCG state by one step: + * state64 = BigInt(lo) * 0x6AC690C5n + BigInt(hi) + * lo = Number(state64 & 0xFFFFFFFFn) >>> 0 + * hi = Number((state64 >> 32n) & 0xFFFFFFFFn) >>> 0 + */ + step(): void { + const state64 = BigInt(this._lo) * D2_PRNG_MULTIPLIER + BigInt(this._hi) + this._lo = Number(state64 & 0xffffffffn) >>> 0 + this._hi = Number((state64 >> 32n) & 0xffffffffn) >>> 0 + } + + /** + * Rolls a pseudo-random integer in `[0, max - 1]`. + * + * If max <= 0, returns 0 without advancing state. + * Otherwise advances state (`step()`). + * If max is a power of 2 (i.e. `(max & (max - 1)) === 0`): + * returns `(lo & (max - 1)) >>> 0` + * Else: + * returns `(lo % max) >>> 0` + * + * @param max - Exclusive upper bound. + * @returns Generated random integer in `[0, max - 1]`, or 0 if max <= 0. + */ + rand(max: number): number { + if (max <= 0) { + return 0 + } + this.step() + if ((max & (max - 1)) === 0) { + return (this._lo & (max - 1)) >>> 0 + } + return (this._lo % max) >>> 0 + } + + /** + * Rolls a pseudo-random integer in inclusive range `[min, max]`. + * + * If min >= max, returns min without advancing state. + * Otherwise span = max - min + 1, advances state (`step()`), + * and returns `min + ((lo % span) >>> 0)`. + * + * @param min - Lower bound (inclusive). + * @param max - Upper bound (inclusive). + * @returns Random integer in [min, max]. + */ + randRange(min: number, max: number): number { + if (min >= max) { + return min + } + const span = max - min + 1 + this.step() + return min + ((this._lo % span) >>> 0) + } + + /** + * Creates an independent copy of this PRNG instance preserving the exact current seed state. + */ + clone(): D2Rng { + const copy = new D2Rng() + copy.setSeed(this._lo, this._hi) + return copy + } + + /** + * Advances state and returns the updated 32-bit low seed value (matching SEED_RollRandomNumber). + */ + next(): number { + this.step() + return this._lo + } +} + +/** Alias for D2Rng to represent per-unit seed instances. */ +export const UnitSeed = D2Rng +export type UnitSeed = D2Rng diff --git a/tests/d2-rng.test.ts b/tests/d2-rng.test.ts new file mode 100644 index 0000000..dd8ebe4 --- /dev/null +++ b/tests/d2-rng.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test } from "vitest" +import { D2Rng, UnitSeed, D2_PRNG_MULTIPLIER } from "../src/game/d2-rng.ts" + +describe("1.13c Diablo II PRNG and Per-Unit Seed (D2Common!6FD510B0 / D2Game!6FC211D0)", () => { + describe("64-bit LCG state recurrence and known seed vectors", () => { + test("has the canonical 1.13c multiplier 0x6AC690C5 (1791398085)", () => { + expect(D2_PRNG_MULTIPLIER).toBe(0x6ac690c5n) + expect(Number(D2_PRNG_MULTIPLIER)).toBe(1791398085) + }) + + test("reproduces exact bitwise sequence for known vector lo = 1, hi = 0 across 10 steps", () => { + const rng = new D2Rng({ lo: 1, hi: 0 }) + expect(rng.lo).toBe(1) + expect(rng.hi).toBe(0) + + const expectedSteps = [ + { lo: 1791398085, hi: 0 }, + { lo: 1721382809, hi: 747178471 }, + { lo: 986833572, hi: 717975634 }, + { lo: 2963478662, hi: 411600752 }, + { lo: 930727566, hi: 1236044336 }, + { lo: 3622188406, hi: 388199365 }, + { lo: 4003526547, hi: 1510787143 }, + { lo: 3772775526, hi: 1669840372 }, + { lo: 3770833010, hi: 1573595882 }, + { lo: 1095064228, hi: 1572785674 }, + ] + + for (let i = 0; i < expectedSteps.length; i++) { + rng.step() + expect(rng.getSeed()).toEqual(expectedSteps[i]) + } + + // Re-running with a new instance yields exact same bitwise reproducibility + const freshRng = new D2Rng(1, 0) + for (let i = 0; i < expectedSteps.length; i++) { + freshRng.step() + expect(freshRng.lo).toBe(expectedSteps[i].lo) + expect(freshRng.hi).toBe(expectedSteps[i].hi) + } + }) + + test("matches 1.13c SEED_InitLowSeed / rollD2Random vector lo = 100, hi = 666", () => { + const rng = new D2Rng({ lo: 100, hi: 666 }) + // 666 + 0x6AC690C5 * 100 = 179139809166 + // low 32 bits = 3046150030 + // high 32 bits = 41 + rng.step() + expect(rng.lo).toBe(3046150030) + expect(rng.hi).toBe(41) + }) + }) + + describe("Random generation rand(max)", () => { + test("power-of-2 branch uses bitwise AND: (lo & (max - 1)) >>> 0", () => { + const rng = new D2Rng(42, 0) + + // Test power of 2: 1024 (1024 & 1023 === 0) + const roll = rng.rand(1024) + const expected = (rng.lo & 1023) >>> 0 + expect(roll).toBe(expected) + expect(roll).toBeGreaterThanOrEqual(0) + expect(roll).toBeLessThan(1024) + + // Test power of 2: 16 (16 & 15 === 0) + const roll16 = rng.rand(16) + expect(roll16).toBe((rng.lo & 15) >>> 0) + expect(roll16).toBeGreaterThanOrEqual(0) + expect(roll16).toBeLessThan(16) + + // Test power of 2: 2 (2 & 1 === 0) + const roll2 = rng.rand(2) + expect(roll2).toBe((rng.lo & 1) >>> 0) + expect(roll2 === 0 || roll2 === 1).toBe(true) + + // Test power of 2: 1 (1 & 0 === 0) + const roll1 = rng.rand(1) + expect(roll1).toBe(0) + }) + + test("non-power-of-2 branch uses modulo: (lo % max) >>> 0", () => { + const rng = new D2Rng(42, 0) + + // Test non-power of 2: 1000 (1000 & 999 !== 0) + const roll = rng.rand(1000) + const expected = (rng.lo % 1000) >>> 0 + expect(roll).toBe(expected) + expect(roll).toBeGreaterThanOrEqual(0) + expect(roll).toBeLessThan(1000) + + // Test non-power of 2: 100 + const roll100 = rng.rand(100) + expect(roll100).toBe((rng.lo % 100) >>> 0) + expect(roll100).toBeGreaterThanOrEqual(0) + expect(roll100).toBeLessThan(100) + + // Test non-power of 2: 7 + const roll7 = rng.rand(7) + expect(roll7).toBe((rng.lo % 7) >>> 0) + expect(roll7).toBeGreaterThanOrEqual(0) + expect(roll7).toBeLessThan(7) + }) + + test("returns 0 and does not advance state when max <= 0", () => { + const rng = new D2Rng(999, 123) + const initialSeed = rng.getSeed() + + expect(rng.rand(0)).toBe(0) + expect(rng.getSeed()).toEqual(initialSeed) + + expect(rng.rand(-1)).toBe(0) + expect(rng.getSeed()).toEqual(initialSeed) + + expect(rng.rand(-100)).toBe(0) + expect(rng.getSeed()).toEqual(initialSeed) + }) + }) + + describe("Range generation randRange(min, max)", () => { + test("returns min without advancing state when min >= max", () => { + const rng = new D2Rng(500, 100) + const initialSeed = rng.getSeed() + + // min > max + expect(rng.randRange(10, 5)).toBe(10) + expect(rng.getSeed()).toEqual(initialSeed) + + // min === max + expect(rng.randRange(7, 7)).toBe(7) + expect(rng.getSeed()).toEqual(initialSeed) + }) + + test("generates values within [min, max] when min < max", () => { + const rng = new D2Rng(12345, 678) + + for (let i = 0; i < 50; i++) { + const loBefore = rng.lo + const val = rng.randRange(1, 10) + // State must have advanced + expect(rng.lo).not.toBe(loBefore) + // Range check + expect(val).toBeGreaterThanOrEqual(1) + expect(val).toBeLessThanOrEqual(10) + // Check formula: span = 10, min + (lo % span) + expect(val).toBe(1 + ((rng.lo % 10) >>> 0)) + } + }) + + test("handles negative ranges correctly", () => { + const rng = new D2Rng(1, 0) + for (let i = 0; i < 30; i++) { + const val = rng.randRange(-10, -5) + expect(val).toBeGreaterThanOrEqual(-10) + expect(val).toBeLessThanOrEqual(-5) + const span = -5 - (-10) + 1 // 6 + expect(val).toBe(-10 + ((rng.lo % span) >>> 0)) + } + }) + + test("simulates standard 6-sided dice roll randRange(1, 6)", () => { + const rng = new D2Rng(888) + const rolled = new Set() + + for (let i = 0; i < 100; i++) { + const roll = rng.randRange(1, 6) + expect(roll).toBeGreaterThanOrEqual(1) + expect(roll).toBeLessThanOrEqual(6) + rolled.add(roll) + } + + // Over 100 rolls, all sides 1-6 should appear + for (let side = 1; side <= 6; side++) { + expect(rolled.has(side)).toBe(true) + } + }) + }) + + describe("Per-unit seed independence (UnitSeed)", () => { + test("UnitSeed alias points to D2Rng", () => { + expect(UnitSeed).toBe(D2Rng) + const unit = new UnitSeed(123) + expect(unit instanceof D2Rng).toBe(true) + }) + + test("separate unit seeds do not affect each other", () => { + const unitA = new D2Rng(100, 0) + const unitB = new D2Rng(200, 0) + + const initialB = unitB.getSeed() + + // Advance unitA 25 times + for (let i = 0; i < 25; i++) { + unitA.rand(100) + } + + // unitB must remain completely untouched + expect(unitB.getSeed()).toEqual(initialB) + + // Advance unitB once + const valB1 = unitB.rand(100) + + // Now create a fresh unit with initialB seed + const unitBClone = new D2Rng(200, 0) + const expectedValB1 = unitBClone.rand(100) + + expect(valB1).toBe(expectedValB1) + expect(unitB.getSeed()).toEqual(unitBClone.getSeed()) + }) + + test("interleaved execution between units produces identical results to sequential execution", () => { + // Sequential run + const unit1Seq = new D2Rng(111, 222) + const unit2Seq = new D2Rng(333, 444) + + const seq1Rolls = Array.from({ length: 10 }, () => unit1Seq.rand(50)) + const seq2Rolls = Array.from({ length: 10 }, () => unit2Seq.rand(50)) + + // Interleaved run + const unit1Interleaved = new D2Rng(111, 222) + const unit2Interleaved = new D2Rng(333, 444) + + const inter1Rolls: number[] = [] + const inter2Rolls: number[] = [] + + for (let i = 0; i < 10; i++) { + inter1Rolls.push(unit1Interleaved.rand(50)) + inter2Rolls.push(unit2Interleaved.rand(50)) + } + + expect(inter1Rolls).toEqual(seq1Rolls) + expect(inter2Rolls).toEqual(seq2Rolls) + expect(unit1Interleaved.getSeed()).toEqual(unit1Seq.getSeed()) + expect(unit2Interleaved.getSeed()).toEqual(unit2Seq.getSeed()) + }) + }) + + describe("Instance lifecycle, clone, and state mutation", () => { + test("constructor accepts undefined, number, or { lo, hi }", () => { + const empty = new D2Rng() + expect(empty.getSeed()).toEqual({ lo: 0, hi: 0 }) + + const fromNum = new D2Rng(12345) + expect(fromNum.getSeed()).toEqual({ lo: 12345, hi: 0 }) + + const fromNumAndHi = new D2Rng(12345, 678) + expect(fromNumAndHi.getSeed()).toEqual({ lo: 12345, hi: 678 }) + + const fromObj = new D2Rng({ lo: 555, hi: 999 }) + expect(fromObj.getSeed()).toEqual({ lo: 555, hi: 999 }) + }) + + test("setSeed masks values to unsigned 32-bit integers", () => { + const rng = new D2Rng() + rng.setSeed(-1, -1) + expect(rng.lo).toBe(0xffffffff) + expect(rng.hi).toBe(0xffffffff) + expect(rng.getSeed()).toEqual({ lo: 4294967295, hi: 4294967295 }) + }) + + test("clone() creates an exact, independent copy", () => { + const original = new D2Rng(777, 888) + original.step() + + const cloned = original.clone() + expect(cloned.getSeed()).toEqual(original.getSeed()) + + // Stepping cloned does not affect original + const clonedSeedBefore = cloned.getSeed() + original.step() + expect(cloned.getSeed()).toEqual(clonedSeedBefore) + expect(original.getSeed()).not.toEqual(cloned.getSeed()) + + // Next roll from clone produces expected value + const cloneRoll = cloned.rand(100) + const expectedOriginalClone = new D2Rng(clonedSeedBefore) + expect(cloneRoll).toBe(expectedOriginalClone.rand(100)) + }) + + test("getSeed() returns a new object preventing external mutation of internal state", () => { + const rng = new D2Rng(100, 200) + const seed = rng.getSeed() + seed.lo = 9999 + seed.hi = 9999 + expect(rng.lo).toBe(100) + expect(rng.hi).toBe(200) + }) + + test("next() advances state and returns low 32-bit register", () => { + const rng = new D2Rng(1, 0) + const val = rng.next() + expect(val).toBe(1791398085) + expect(rng.lo).toBe(1791398085) + expect(rng.hi).toBe(0) + }) + }) +})