diablo2-web/src/game/d2-rng.ts

294 lines
9.4 KiB
TypeScript

/**
* 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
// ---------------------------------------------------------------------------
// D2Common D2SeedStrc API (D2MOO source/D2Common/include/D2Seed.h, commit 5596f5c).
//
// This is the API the DRLG port (src/game/drlg/) uses. It mirrors D2MOO line by line:
// SEED_RollRandomNumber: lSeed = nHighSeed + 0x6AC690C5 * nLowSeed (64-bit), stored back
// SEED_RollLimitedRandomNumber: nMax <= 0 -> 0 without rolling; non power of two ->
// (uint32)roll % nMax; power of two -> roll & (nMax - 1)
// SEED_RollPercentage: roll % 100 over the FULL 64-bit state
// SEED_InitLowSeed: low = n, high = 666
// The 64-bit product is computed exactly with 16-bit limbs (no BigInt).
// ---------------------------------------------------------------------------
/** D2SeedStrc: two uint32 registers. `label` is assigned by an active trace sink only. */
export interface D2SeedStrc {
lo: number
hi: number
label?: string
}
/** Receives every seed initialisation and roll, in call order (see tools/d2moo-oracle/README.md). */
export interface D2SeedTraceSink {
/** SEED_InitLowSeed / SEED_InitSeed ('I') and SEED_SetSeeds ('S'). */
init(seed: D2SeedStrc, kind: 'I' | 'S'): void
/** SEED_RollRandomNumber ('R', arg 0), SEED_RollLimitedRandomNumber ('L'), SEED_RollPercentage ('P'). */
roll(seed: D2SeedStrc, kind: 'R' | 'L' | 'P', arg: number, result: number): void
/** D2CMP_10088_GetTiles ('T'). */
tile?(nType: number, nStyle: number, nSequence: number, n: number, nRaritySum: number): void
}
let seedTraceSink: D2SeedTraceSink | null = null
/** Installs (or removes, with null) the global seed trace sink. */
export function setD2SeedTraceSink(sink: D2SeedTraceSink | null): void {
seedTraceSink = sink
}
/** Returns the currently active seed trace sink, if any. */
export function getD2SeedTraceSink(): D2SeedTraceSink | null {
return seedTraceSink
}
export function newD2Seed(): D2SeedStrc {
return { lo: 0, hi: 0 }
}
const TWO_POW_32 = 4294967296
/** lSeed = nHighSeed + 0x6AC690C5 * nLowSeed evaluated exactly in 64 bits, stored back. */
function advanceD2Seed(seed: D2SeedStrc): void {
const lo = seed.lo
const a0 = lo & 0xffff
const a1 = lo >>> 16
const t0 = 0x90c5 * a0 // < 2^32
const t1 = 0x6ac6 * a0 + 0x90c5 * a1 // < 2^33
const t2 = 0x6ac6 * a1 // < 2^31
const low = t0 + (t1 % 65536) * 65536 + seed.hi // < 3 * 2^32, exact in a double
seed.lo = low % TWO_POW_32
// The product is < 2^63, so the high word cannot wrap.
seed.hi = t2 + Math.floor(t1 / 65536) + Math.floor(low / TWO_POW_32)
}
/**
* The LCG step some D2MOO functions inline (`pSeed.lSeed = nHighSeed + 1791398085i64 * nLowSeed`)
* instead of calling SEED_RollRandomNumber, e.g. sub_6FD823C0. Not reported to the trace sink,
* exactly like the native oracle.
*/
export function D2SEED_AdvanceInline(seed: D2SeedStrc): void {
advanceD2Seed(seed)
}
//D2Common.0x6FDAEAB0 (#10912)
export function SEED_InitSeed(seed: D2SeedStrc): void {
seed.lo = 1
seed.hi = 666
if (seedTraceSink) seedTraceSink.init(seed, 'I')
}
//D2Common.0x6FDAEAC0 (#10913)
export function SEED_InitLowSeed(seed: D2SeedStrc, nLowSeed: number): void {
seed.lo = nLowSeed >>> 0
seed.hi = 666
if (seedTraceSink) seedTraceSink.init(seed, 'I')
}
//D2Common.0x6FDAEAE0 (#10921)
export function SEED_SetSeeds(seed: D2SeedStrc, nLowSeed: number, nHighSeed: number): void {
seed.lo = nLowSeed >>> 0
seed.hi = nHighSeed >>> 0
if (seedTraceSink) seedTraceSink.init(seed, 'S')
}
/**
* D2Common.0x6FD78E30 SEED_RollRandomNumber. Returns the low 32 bits of the new state as an
* unsigned number; callers that use the full 64-bit value read `seed.hi` or use
* {@link SEED_RollRandomNumberMod64}.
*/
export function SEED_RollRandomNumber(seed: D2SeedStrc): number {
advanceD2Seed(seed)
if (seedTraceSink) seedTraceSink.roll(seed, 'R', 0, seed.lo)
return seed.lo
}
/**
* `SEED_RollRandomNumber(pSeed) % n` where the C++ operand is the uncast uint64 state
* (D2MOO does this in a few places, e.g. SEED_RollPercentage). One traced 'R' roll.
*/
export function SEED_RollRandomNumberMod64(seed: D2SeedStrc, n: number): number {
if (!Number.isInteger(n) || n <= 0 || n > 0x3ffffff) {
throw new RangeError(`SEED_RollRandomNumberMod64: modulus ${n} outside 1..2^26-1`)
}
advanceD2Seed(seed)
if (seedTraceSink) seedTraceSink.roll(seed, 'R', 0, seed.lo)
return ((seed.hi % n) * (TWO_POW_32 % n) + (seed.lo % n)) % n
}
//D2Common.0x6FD7D3E0 SEED_RollLimitedRandomNumber
export function SEED_RollLimitedRandomNumber(seed: D2SeedStrc, nMax: number): number {
let nResult = 0
if (nMax > 0) {
advanceD2Seed(seed)
if ((nMax - 1) & nMax) {
nResult = seed.lo % nMax
} else {
nResult = (seed.lo & (nMax - 1)) >>> 0
}
}
if (seedTraceSink) seedTraceSink.roll(seed, 'L', nMax, nResult)
return nResult
}
/** SEED_RollPercentage: `SEED_RollRandomNumber(pSeed) % 100` over the full 64-bit state. */
export function SEED_RollPercentage(seed: D2SeedStrc): number {
advanceD2Seed(seed)
const nResult = ((seed.hi % 100) * (TWO_POW_32 % 100) + (seed.lo % 100)) % 100
if (seedTraceSink) seedTraceSink.roll(seed, 'P', 100, nResult)
return nResult
}