Bug report: GAMEPLAY RNG can reach seed 0, after which every probability roll in the game succeeds
Game: Farthest Frontier v1.1.0 (Mono build)
Severity: Save-breaking. Silent — nothing is logged, no exception is thrown, and no in-game
indicator reveals it.
Reported by: SageDragoon
Observed: 2026-10-20, captured live with instrumentation
Summary
CERandom’s GAMEPLAY generator can end up with a seed of 0. Because RNG is a Lehmer
generator (x = 16807·x mod 2147483647), zero is an absorbing state — 16807 · 0 mod m = 0.
Once the seed is 0 it remains 0 for the rest of the session.
In that state:
RNG.Generate()returns0RNG.GenerateFloat()returns0fRNG.Range(min, max)returns exactlyminon every call
Every probability test in the game is written as either chance >= CERandom.Range(.., 0f, 1f) or
CERandom.Range(.., 0, 100) <= pct. With Range pinned to its minimum, every one of those
tests passes, forever.
The generator still exists, so CERandom.GeneratorExists(Type.GAMEPLAY) continues to report it
as healthy. There is no symptom available to the player other than the consequences.
Observed impact
A 526-villager town, healthy and stable, captured across three readings minutes apart:
19:49 GAMEPLAY seed 1410498401 0 sick of 526
19:53 GAMEPLAY seed 430619988 0 sick of 526
19:56 GAMEPLAY seed 0 519 sick of 526 (187 bedridden)
The in-game health panel at that moment:
519 villagers are sick
10 Influenza 260 Worms 97 Fractured Bone
90 Bee Sting 373 Typhoid 357 Cholera
26 Food Poisoning 5 Eagle Attack 8 Dysentery
10 Frostbite 10 Scurvy 5 Dehydration
Two details confirm the mechanism rather than a disease-balance problem:
-
The spawn gates were shut.
Disease.GetFactoredScorereturnsfalsewhen any factor
markedisRequiredevaluates to ~0, andRollDiseaseByDurationthen never rolls. At the time
of capture only one villager hadResidenceStocked_Soapnon-zero — yet 373 had Typhoid and
357 had Cholera, both of which require that factor. Those cases cannot have come from the
environmental roll. -
They came through contagion, which has no factor gate.
ImmuneSystemComponent.ProcessExposuretests
elapsedDays * spreadRatePerDay * diseaseSpreadMultiplier >= CERandom.Range(GAMEPLAY, 0f, 1f)
and callsContractDisease, which checks only immunity and current infection — never the
disease’s factors. WithRangereturning 0, every exposure converts on the first tick.
Personal (PD_) diseases show the complementary signature: each landed on exactly its gate
population. Instrumentation minutes earlier measured Occupation_HazardousJobs open for 82–92
villagers; the event produced 97 Fractured Bone. Bee Sting hit all 90 eligible, Eagle Attack all 5.
The same failure affects everything else that rolls. CatastropheManager.OnMonthPassed uses
CERandom.Range(GAMEPLAY, 0, 100) <= fireChancePct, so a fire starts every month, and
RollForSpreadFire uses Range(0f,1f) <= spreadChance, so every fire spreads. An earlier
session reached 16 simultaneous building fires.
Root cause
A valid Lehmer state can never reach 0 on its own — 0 lies outside the cycle. This was verified by
simulating Generate() with exact C# unchecked 32-bit semantics: 3000 random seeds from
[1, 2^31−2] and 3000 from [2^31, 2^32), 2000 steps each, produced no zero state. The seed has
to be written.
Assembly-CSharp contains exactly one place that writes the shared GAMEPLAY seed directly —
TerrainManager.SpawnTreePlantingSites():
RNG generator = CERandom.GetGenerator(CERandom.Type.GAMEPLAY);
uint seed = generator.Seed; // save
for (int i = num3; i <= num5 + 1; i += 2)
{
for (int j = num4; j <= num6 + 1; j += 2)
{
int num7 = i + j * num;
if (occupiedGrids.Contains(num7) || ...) continue;
generator.Seed = (uint)num7; // ← ZERO when i == 0 && j == 0
generator.Range(0, 3);
for (int k = -1; k < 1; k++)
for (int l = -1; l < 1; l++)
if (!agricultureManager.workCampValidTreeData[i + k, j + l]) // ← index −1
continue;
...
}
}
generator.Seed = seed; // restore — NOT in a finally
Two problems:
num7is zero at grid cell (0,0), so the shared gameplay seed is deliberately set to the
one value from which the generator cannot recover.- The restore is not protected. There is no
try/finally, so any exception between the
hijack and the restore leaves the seed at a grid index. The inner body indexes
workCampValidTreeData[i - 1, j - 1], which is out of bounds precisely at the boundary cells
whereiorjis 0 — the same cells that produce seed 0.
To be explicit about confidence: the state (seed == 0 causing mass simultaneous infection) is
directly observed and reproducible. The specific path that writes the zero is inferred — no
exception appeared in the MelonLoader log during the captured event, so the throw-before-restore
chain is not proven. It is offered as the most likely candidate because it is the only code found
that assigns this seed a caller-controlled value that can be zero.
Frequency — this is continuous, not a one-off
After adding a runtime guard that detects seed == 0 and re-seeds, the guard fired twelve times
in thirty-three seconds, at a near-perfectly regular interval:
20:18:41 repair #1 gaps: +3s +2s +3s +3s +2s +3s +3s +2s +3s +3s +3s
20:19:11 repair #12
Roughly one death every 2.7 real seconds — about twice per in-game day at normal speed. So the
seed is not reaching zero through some rare race; something writes it on a short, regular cycle.
The reason a player experiences this as a single catastrophic event rather than a recurring one is
that nothing in vanilla ever re-seeds it. The first zero is permanent for the session. Every
subsequent write is writing zero over zero.
The same save with the guard active: 1 sick of 547, across twelve seed deaths. Unguarded:
519 of 526. The guard changes nothing else about the simulation — it only replaces an invalid
state with a valid one.
Reproduction
Not yet reduced to deterministic steps, but two facts should make it tractable:
- The GAMEPLAY seed is deterministic per save. Two separate loads of the same save both began
at seed1410498401, as expected fromBeginGenerationderiving it from the map seed. The roll
sequence from load is therefore reproducible. - The zeroing is periodic, roughly every 2.7 s of real time, in a mature town (526 pop) with
work camps active. Observed twice in one session, in both cases within
seconds of closing the tech tree screen after spending points. A town large enough to have work
camps and tree-planting activity near the map origin appears relevant.
The reliable detector, regardless of trigger:
CERandom.GetGenerator(CERandom.Type.GAMEPLAY).Seed == 0
Once that is true the session is unrecoverable until something re-seeds. Loading a save calls
GenerateInternal → CERandom.BeginGeneration, which re-initialises GAMEPLAY, so reloading
fixes new rolls — but villagers already infected stay infected, which makes the bug look like it
survives a reload.
Suggested fixes
Any one of these closes it; the first two are the cheapest.
-
Guard the setter. Reject or remap 0 in
RNG.Seed:set { previous = (value == 0u || value > 2147483646u) ? 1u : value; }Zero is never a legitimate state for this generator, so this cannot change working behaviour.
-
Protect the restore in
SpawnTreePlantingSiteswithtry/finally, and offset the per-cell
seed so it can never be zero (generator.Seed = (uint)num7 + 1). -
Don’t hijack the shared generator at all. The function wants deterministic per-cell values;
a privateRNGinstance would do that without touching gameplay randomness.TerrainManager
already uses a privaterngfield elsewhere (GetNewTreePosition) for exactly this pattern. -
Fix the out-of-bounds read: the
k/lloops start at −1 without clampingi/jto ≥ 1.
Notes
- Reproduced with mods loaded (MelonLoader 0.7.0), but no mod writes this seed. Verified two
ways: source audit of the eleven mods built locally, and a metadata scan of every DLL in the
Mods folder, including third-party mods with no source available. Across all fourteen
assemblies there is exactly oneset_Seedreference, and it belongs to the Divine Hands repair
guard described below, added after this bug was captured. Mod usage ofCERandomis otherwise
read-only viaRange. The writes quoted above are vanilla. - A diagnostic and a runtime repair for this exist in the Divine Hands mod
(RngGuard), which detectsseed == 0and re-seeds from the clock. That is a workaround for
players, not a substitute for the engine fix.