This report documents an investigation into the random number generator (RNG) implementation used in Old World for character generation, specifically examining whether consecutive characters (such as courtiers granted to multiple players choosing the same family) could have correlated traits.
Key Findings:
-
The game uses two different RNG algorithms: a Linear Congruential Generator (LCG) for seed derivation and a Park-Miller variant for actual random draws.
-
Consecutive character seeds are mathematically related through a single LCG step.
-
The Park-Miller implementation contains a bug where large seeds cause arithmetic underflow, leading to convergence of high-order bits after multiple iterations.
-
After 3 Park-Miller steps, the top 16 bits become identical between consecutive characters.
-
Despite this correlation, final trait selection remains statistically independent due to the
SeedToIntfunction discarding the correlated high bits through bit-shifting and modulo operations. -
The system is accidentally safe for current use cases, but the underlying RNG weakness could be exploitable if the game used larger die sizes or different bit extraction methods.
When a new character is created (including courtiers from family seat bonuses), the game assigns a deterministic seed based on the character's ID:
// From Game.cs
public virtual ulong getSeedForId(int iID)
{
RandomStruct pRandom = new RandomStruct(getFirstSeed());
for (int i = 0; i <= iID; ++i)
{
pRandom.NextAltSeed();
}
return pRandom.GetSeed();
}This means:
- Character 0's seed = LCG(FirstSeed)
- Character 1's seed = LCG(LCG(FirstSeed))
- Character N's seed = LCG^(N+1)(FirstSeed)
Consecutive characters have seeds related by exactly one LCG step.
// From Random.cs
public ulong NextAltSeed()
{
mulSeed = (mulSeed * 1103515245) + 12345;
return mulSeed;
}This is the classic glibc/ANSI C rand() formula operating on 64-bit unsigned integers. It's used exclusively for getSeedForId() to derive character seeds.
// From Random.cs
private const int IA = 16807;
private const long IM = long.MaxValue; // 9223372036854775807 - NEVER USED
private const int IQ = 127773;
private const int IR = 2836;
private ulong NextSeedNoUpdate()
{
ulong k = mulSeed / IQ;
return IA * (mulSeed - k * IQ) - IR * k;
}
public ulong NextSeed()
{
mulSeed = NextSeedNoUpdate();
return mulSeed;
}This is used for all actual random draws: Next(), NextFloat(), RandomPercent(), etc.
// From Random.cs
static public int SeedToInt(ulong ulSeed, int iRange)
{
return (int)((ulSeed >> 16) % (ulong)iRange);
}
public int Next(int iRange)
{
if (iRange == 0)
return 0;
return SeedToInt(NextSeed(), iRange);
}The Park-Miller constants (IQ=127773, IR=2836) were designed for a 31-bit modulus (2^31 - 1 = 2147483647). However:
- The code operates on 64-bit unsigned integers
- The modulus
IMis defined but never actually used - The subtraction
IA * (mulSeed - k * IQ) - IR * kcan produce negative results
When the result is negative, it underflows to a massive unsigned value:
Seed: 127773 (0x000000000001F31D)
k = seed // IQ = 1
Raw result: -2836
After mod 2^64: 18446744073709548780 (0xFFFFFFFFFFFFF4EC)
*** UNDERFLOW OCCURRED ***
Seed: 9223372036854775808 (0x8000000000000000)
k = 72185610706915
Raw result: -204718389855313949
After mod 2^64: 18242025683854237667 (0xFD28B1B585AF1FE3)
*** UNDERFLOW OCCURRED ***
For typical game seeds (large 64-bit values), underflow occurs frequently.
The underflow behavior causes consecutive seeds to converge toward identical high-order bits after multiple Park-Miller iterations.
Testing 1000 consecutive character pairs with FirstSeed=12345:
After 0 Park-Miller steps (initial LCG seeds):
Bits 48-63: 50% 49% 49% 50% 50% 52% 53% 51% 50% 50% 49% 53% 52% 50% 52% 49%
Bits 32-47: 50% 49% 49% 48% 51% 51% 50% 50% 52% 48% 53% 49% 50% 47% 48% 47%
Bits 16-31: 49% 47% 50% 50% 48% 47% 50% 51% 48% 49% 50% 49% 48% 47% 51% 48%
Bits 0-15: 49% 50% 50% 50% 51% 50% 50% 50% 50% 51% 47% 56% 62% 75% 50% 0%
All bits approximately 50% correlated (random/independent).
After 1 Park-Miller step:
Bits 48-63: 100% 100% 100% 100% 100% 57% 58% 48% 51% 49% 51% 53% 52% 52% 49% 49%
^^^^^^^^^^^^^^^^^^^^^^^^^^^
5 bits now 100% correlated
After 2 Park-Miller steps:
Bits 48-63: 100% 100% 100% 100% 100% 100% 100% 100% 100% 100% 52% 51% 50% 51% 52% 49%
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10 bits now 100% correlated
After 3 Park-Miller steps:
Bits 48-63: 100% 100% 100% 100% 100% 100% 100% 100% 100% 100% 100% 100% 100% 100% 100% 86%
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Top 16 bits almost entirely correlated
Characters 100 and 101 after 3 Park-Miller steps:
Char 100: 0xFA7129B960238F23
Char 101: 0xFA7142CE86BEA67C
^^^^
Top 16 bits IDENTICAL (0xFA71)
XOR: 0x00006B77E69D295F
30 bits differ (should be ~32 for independent)
Despite the high-bit correlation, the SeedToInt function accidentally neutralizes it:
return (int)((ulSeed >> 16) % (ulong)iRange);- Right shift by 16: Moves bits 16-63 into positions 0-47
- Correlated bits relocate: The correlated top bits (48-63) now occupy positions 32-47
- Modulo operation: For small ranges like 50 (archetype die), only the low ~6 bits matter
- Correlated bits discarded: The modulo operation effectively ignores positions 32-47
Testing 10,000 consecutive character pairs:
| Die Size | Same Result Rate | Expected (Independent) | Ratio |
|---|---|---|---|
| 50 | 2.06% | 2.00% | 1.03x |
| 100 | 1.04% | 1.00% | 1.04x |
| 5 | 20.02% | 20.00% | 1.00x |
| 3 | 32.99% | 33.33% | 0.99x |
All results fall within expected statistical variance for independent draws.
For archetype selection (5 outcomes, 10,000 pairs):
| FirstSeed | Chi-Square | Critical Value (p=0.05) | Independent? |
|---|---|---|---|
| 12345 | 25.72 | 26.30 | Yes |
| 98765432 | 12.89 | 26.30 | Yes |
| 0xDEADBEEF | 19.48 | 26.30 | Yes |
| 1 | 15.50 | 26.30 | Yes |
All tests pass the independence criterion.
For context, here's how courtier traits are generated:
// From Character.cs
public virtual void generateRatingsCourtier(CourtierType eCourtier)
{
// Step 1: Rating (e.g., Discipline 2-4 for Merchants)
for (RatingType eLoopRating = 0; eLoopRating < infos().ratingsNum(); eLoopRating++)
{
setRating(eLoopRating,
infos().courtier(eCourtier).maiRatingBase[eLoopRating] +
randomNext(infos().courtier(eCourtier).maiRatingRand[eLoopRating]));
}
// Step 2: Archetype trait
using (var randomMapScoped = CollectionCache.GetListScoped<(TraitType, int)>())
{
for (TraitType eLoopTrait = 0; eLoopTrait < infos().traitsNum(); ++eLoopTrait)
{
randomMapScoped.Value.Add((eLoopTrait,
infos().courtier(eCourtier).maiArchetypeDie[eLoopTrait]));
}
TraitType eTrait = infos().utils().randomDieMap(randomMapScoped.Value, nextRandomSeed());
if (eTrait != TraitType.NONE)
{
addTrait(eTrait);
}
}
// Step 3: Adjective trait (similar pattern)
// Step 4: doAdultTrait()
}The randomDieMap function:
// From Utils.cs
public virtual T randomDieMap<T>(List<(T, int)> mapDice, ulong seed, T defaultValue = default(T))
{
int iDieSize = 0;
foreach ((T, int weight) pair in mapDice)
{
iDieSize += pair.weight;
}
if (iDieSize > 0)
{
RandomStruct pRandom = new RandomStruct(seed);
int iRoll = pRandom.Next(iDieSize);
foreach ((T val, int weight) pair in mapDice)
{
if (iRoll < pair.weight)
return pair.val;
iRoll -= pair.weight;
}
}
return defaultValue;
}For two players both selecting Traders family, their Merchant courtiers have independent outcomes with these coincidental match probabilities:
| Attribute | Options | Match Probability |
|---|---|---|
| Archetype | 5 (equal weight) | 20% |
| Adjective | 10 (equal weight) | 10% |
| Discipline Rating | 3 values (2, 3, or 4) | 33% |
| Traits only | 5 × 10 | 2% |
| Full match | 5 × 10 × 3 | 0.67% |
These represent random chance, not systematic correlation.
The system is safe for current use cases because:
- Archetype dice use small total weights (50 for 5×10)
- Adjective dice use small total weights (100 for 10×10)
- The
>> 16shift and small modulo discard correlated high bits
The RNG weakness could become exploitable if:
- Large die sizes: If
iDieSize > 65536, the modulo would preserve correlated bits - High-bit extraction: Any code using
seed >> 48would see identical values for consecutive characters - Direct seed comparison: Game logic comparing raw seeds would find spurious matches
- Dead constant:
IM = long.MaxValueis defined but never used - Algorithm mismatch: 31-bit Park-Miller constants used with 64-bit arithmetic
- Missing underflow handling: Standard Park-Miller adds the modulus when result is negative
- Predictable seed relationship: Consecutive character seeds differ by one deterministic LCG step
The following Python script reproduces these findings:
#!/usr/bin/env python3
"""Old World RNG correlation test - replicates game's Random.cs"""
MOD_64 = 2**64
IA, IQ, IR = 16807, 127773, 2836
class RandomStruct:
def __init__(self, seed):
self.seed = seed if seed != 0 else (2**64 - 1)
def next_alt_seed(self):
self.seed = (self.seed * 1103515245 + 12345) % MOD_64
return self.seed
def next_seed(self):
k = self.seed // IQ
result = IA * (self.seed - k * IQ) - IR * k
self.seed = result % MOD_64
return self.seed
@staticmethod
def seed_to_int(seed, range_):
return ((seed >> 16) % range_) if range_ else 0
def next(self, range_):
return self.seed_to_int(self.next_seed(), range_) if range_ else 0
def get_seed_for_id(first_seed, char_id):
rng = RandomStruct(first_seed)
for _ in range(char_id + 1):
rng.next_alt_seed()
return rng.seed
# Test consecutive character correlation
first_seed = 12345
matches = 0
for char_id in range(10000):
seed_a = get_seed_for_id(first_seed, char_id)
seed_b = get_seed_for_id(first_seed, char_id + 1)
# Simulate 3 PM steps + archetype roll
rng_a, rng_b = RandomStruct(seed_a), RandomStruct(seed_b)
for _ in range(3):
rng_a.next_seed()
rng_b.next_seed()
arch_a = RandomStruct.seed_to_int(rng_a.seed, 50) // 10
arch_b = RandomStruct.seed_to_int(rng_b.seed, 50) // 10
if arch_a == arch_b:
matches += 1
print(f"Match rate: {matches/10000:.2%} (expected: 20.00%)")The Old World RNG implementation contains a flawed Park-Miller variant that causes consecutive character seeds to develop correlated high-order bits. However, the SeedToInt function's bit-shifting and modulo operations inadvertently discard these correlated bits, resulting in statistically independent trait selection.
The 20% archetype match rate and 2% full trait match rate between consecutive characters represent random chance, not exploitable correlation. Players selecting the same family will receive independently randomized courtiers.
The system works correctly by accident rather than by design. The underlying RNG weakness represents technical debt that could manifest as bugs if future code changes alter how random values are extracted from seeds.
Analysis performed: December 2024
Source files examined:
Reference/Source/Base/SystemCore/Random.csReference/Source/Base/Game/GameCore/Game.csReference/Source/Base/Game/GameCore/Character.csReference/Source/Base/Game/GameCore/Utils.csReference/XML/Infos/courtier.xmlReference/XML/Infos/bonus.xml