From 6ea87b7ea834dba5621cd6b7b47c46a7bb2cb7bc Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 08:45:34 +0200 Subject: [PATCH] sky(phase-3c): port retail FUN_00501990 DayGroup picker (uniform LCG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompile agent located the retail DayGroup selection function at FUN_00501990 (chunk_00500000.c:1276). It is a straight-line 32-bit signed LCG — NOT a ChanceOfOccur-weighted CDF. Replaces the SplitMix64 approximation from Phase 3a. Algorithm (verbatim from the decompile): seed = year * secondsPerDay + dayOfYear // TimeOfDay+0x64/+0x10/+0x68 hash = seed * 0x6A42FDB2 + 0x8ABE1652 // signed 32-bit LCG index = floor(dayGroupCount * (uint)hash / 2^32) if (index >= dayGroupCount) index = 0 // float-rounding safety Uniform over all DayGroups. Dereth's 20 groups all carry ChanceOfOccur=5.0 so uniform matches the statistical intent; the weighted walk Phase 3a attempted is NOT what retail does. The SecondsPerDay multiplier is load-bearing — without it, adjacent years would share adjacent LCG seeds and divergence from retail would recur annually. Result (this session's local ACE): server: PY106 ColdMeet 17 MorntideAndHalf, ticks=291130073 → year=106, dayOfYear=(106×0 + 17 across ColdMeet) via DerethDateTime → retail picker returns a deterministic uniform index from LCG. Acdream and retail now agree on the pick for any (Year, DayOfYear) since both drive from the same server PortalYearTicks. Changes: - src/AcDream.Core/World/DerethDateTime.cs: add Year(ticks) and DayOfYear(ticks) helpers (match retail TimeOfDay+0x64 / +0x68). - src/AcDream.Core/World/SkyDescLoader.cs: - SelectDayGroupIndex signature: (year, secondsPerDay, dayOfYear) instead of the flat dayIndex used by the SplitMix64 approximation. - Body: retail LCG line-by-line port with decompile citations. - ACDREAM_DAY_GROUP env var still overrides (for A/B verification). - src/AcDream.App/Rendering/GameWindow.cs: RefreshSkyForCurrentDay now feeds Year / DayOfYear / SecondsPerDay=7620 to the picker instead of a flat dayIndex. Composite `year*360+dayOfYear` still tracked internally as the day-change key for provider-rebuild idempotence. - docs/research/2026-04-23-daygroup-selection.md committed with the full decompile trail (new agent-produced research). Build + 717 tests green. User visual verification (retail side-by-side) next. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../research/2026-04-23-daygroup-selection.md | 225 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 19 +- src/AcDream.Core/World/DerethDateTime.cs | 31 +++ src/AcDream.Core/World/SkyDescLoader.cs | 123 ++++++---- 4 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 docs/research/2026-04-23-daygroup-selection.md diff --git a/docs/research/2026-04-23-daygroup-selection.md b/docs/research/2026-04-23-daygroup-selection.md new file mode 100644 index 0000000..97f4dd4 --- /dev/null +++ b/docs/research/2026-04-23-daygroup-selection.md @@ -0,0 +1,225 @@ +# DayGroup Selection — Retail Decompile Hunt + +**Date:** 2026-04-23 +**Status:** RESEARCH COMPLETE — algorithm recovered, ready for C# port. +**Companion docs:** `2026-04-23-sky-retail-verbatim.md` (full sky pipeline map). + +--- + +## 0. TL;DR + +Retail's DayGroup picker is a **simple integer LCG mixed with `(YEAR, DAY_OF_YEAR)` from the live `TimeOfDay` struct**, scaled by `DayGroupCount`. It is **not** a weighted walk over `ChanceOfOccur` — it's uniform over all DayGroups. Dereth's 20 DayGroups all carry `ChanceOfOccur = 5.0f` anyway, so uniform matches the intent. The algorithm lives in `FUN_00501990` (previously mis-labeled "deterministic PES roll" in the sky-verbatim doc; it is actually **SkyDesc::PickCurrentDayGroup**). + +--- + +## 1. Callers + +| Call site | File : line | Purpose | +|---|---|---| +| `FUN_00508010` (sky render) calls `FUN_004ff420()` | `chunk_00500000.c:7553` | First thing the per-frame sky render does. | +| `FUN_004ff420()` (trampoline) calls `FUN_00501990()` | `chunk_004F0000.c:10684` | Region-nullcheck guard, then delegate. | + +The trampoline also implicitly passes the SkyDesc pointer via ECX (`__fastcall`, elided in Ghidra's C): + +```c +// chunk_004F0000.c:10680 +void FUN_004ff420(void) { + if ((DAT_0084247c != 0) && (*(int *)(DAT_0084247c + 0x50) != 0)) { + FUN_00501990(); // this == Region+0x50 == SkyDesc* + return; + } +} +``` + +Note: `FUN_004ff4b0` (the OTHER mapped trampoline → `FUN_00502a10`) is called **after** `FUN_004ff420` inside `FUN_00508010`. By the time `FUN_00502a10` reads `*this = SkyDesc+0x00` = current DayGroup index, `FUN_00501990` has already written it. + +--- + +## 2. The selection function + +### 2.1 Decompile (verbatim) + +```c +// chunk_00500000.c:1274 FUN_00501990 @ 0x00501990 size=138 +// SkyDesc::PickCurrentDayGroup(this) +void __fastcall FUN_00501990(uint *param_1) +{ + float fVar1; + float fVar2; + int iVar3; + int iVar4; + uint uVar5; + int iVar6; + + if (DAT_008ee9c8 == 0) { // global TimeOfDay not yet bound + iVar4 = 0; + iVar6 = 0; + iVar3 = 0; + } + else { + iVar4 = *(int *)(DAT_008ee9c8 + 0x68); // DAY_OF_YEAR + iVar6 = *(int *)(DAT_008ee9c8 + 0x10); // SECONDS_PER_DAY (int, dat-declared) + iVar3 = *(int *)(DAT_008ee9c8 + 100); // YEAR (100 decimal = 0x64) + } + iVar4 = (iVar3 * iVar6 + iVar4) * 0x6a42fdb2 + -0x7541e9ae; + fVar1 = (float)iVar4; + if (iVar4 < 0) { + fVar1 = fVar1 + _DAT_0079920c; // +2^32 signed→unsigned float fixup + } + fVar2 = (float)(int)param_1[8]; // dayGroupCount (SkyDesc +0x20) + if ((int)param_1[8] < 0) { + fVar2 = fVar2 + _DAT_0079920c; + } + floor((double)(fVar2 * fVar1 * _DAT_007c6f10)); // dayCount * hash_u32 * (1/2^32) + uVar5 = FUN_005df4c4(); // __ftol2: top-of-FPU → int + *param_1 = uVar5; // SkyDesc+0x00 = chosen index + if (param_1[8] <= uVar5) { // safety clamp for float rounding + *param_1 = 0; + } + return; +} +``` + +### 2.2 Key constants + +| Symbol | Bytes | Role | +|---|---|---| +| `0x6a42fdb2` | 1,782,399,410 | LCG multiplier (AC-specific, not a named-PRNG constant). | +| `-0x7541e9ae` | `0x8ABE1652` unsigned | LCG increment. | +| `_DAT_0079920c` | ~4.29497e9f (2^32) | Standard signed-int→unsigned-float fixup constant. | +| `_DAT_007c6f10` | (see §2.4) | Scale factor; interpreted as `1.0 / 2^32 ≈ 2.32830644e-10f`. Same constant is reused by all deterministic AC LCG picks (e.g. `FUN_00504060` at `chunk_00500000.c:4042` for hash-chain lookup). | +| `FUN_005df4c4` | — | MSVC `__ftol2` helper (float→long32 truncation). | + +### 2.3 Why this is NOT a weighted CDF + +No loop. No cumulative sum. No compare-and-walk. The decompile is straight-line: one integer mix, one divide-by-range, one clamp. The `ChanceOfOccur` field on each DayGroup is read during `FUN_005025c0` (DayGroup unpack) and stored but **never consulted by the picker**. Retail data happens to make all 20 DayGroups equiprobable (5.0 each, summing to 100.0), so uniform selection produces the same statistical distribution as a weighted walk would have. **If the user's Dereth server data differs, retail-observed weather will NOT match a weighted simulator — it will match this uniform picker over whatever DayGroupCount the dat contains.** + +### 2.4 `_DAT_007c6f10` sanity check + +The decompile always uses `_DAT_007c6f10` in the pattern `int-cast-to-float × count × _DAT_007c6f10 → floor → ftol`. Two examples confirm it is `1.0/2^32`: + +- `chunk_00500000.c:1305` (this function): dayCount × hash × `_DAT_007c6f10` → floor → index in [0, dayCount). +- `chunk_00500000.c:4042` (`FUN_00504060`, physics-script variant roll): variantCount × hash × `_DAT_007c6f10` → index in [0, variantCount). + +For the clamp at line 1308 (`if (count <= index) index = 0;`) to be a **safety net only** rather than the common path, the scale must strictly map `[0, 2^32)` to `[0, 1.0)` — i.e., `1.0/2^32`. Values `>= count` only ever occur from floating-point rounding at very high hash values. This is a textbook uniform-int-from-LCG idiom. + +--- + +## 3. Where is the current DayGroup index stored? + +**`SkyDesc + 0x00`** (byte offset 0). Written by line 1307 or 1309 of `FUN_00501990`, above. Consumed by: + +- `FUN_00502a10` (`SkyDesc::UpdateSkyObjectTable`) at `chunk_00500000.c:2429`: + `iVar2 = *(int *)(param_1[6] + *param_1 * 4);` — indexes into the DayGroup array (+0x18) using `*this`. +- `FUN_00501600` (`Region::LerpSunAndAmbient`, sun+ambient interp) — same pattern (walks `DayGroup[*thisIndex].SkyTime` keyframes). +- `FUN_00501860` (`Region::LerpFogAmbient`, fog interp) — same. + +There is **no separate global** for the selected DayGroup; all consumers go through the SkyDesc pointer at `Region+0x50` and read `SkyDesc.CurrentIndex = *SkyDesc`. This matches `FUN_005015d0` (SkyDesc::Init, `chunk_00500000.c:1135`) which zero-inits `*param_1 = 0;` so the first frame before `FUN_00501990` runs picks DayGroup[0]. + +--- + +## 4. TimeOfDay struct layout (relevant fields) + +Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay::Unpack) during Region load, ticked by `FUN_005a7800` every frame. Verified field map: + +| Byte offset | Type | Field | Written where | +|---|---|---|---| +| 0x00 | double | `EpochBase` (dat-declared) | `FUN_005a7fd0:6045` | +| 0x08 | int | `BaseYear` (dat-declared) | `FUN_005a7fd0:6051` | +| 0x0C | float | `SecondsPerDay` | `FUN_005a7fd0:6056` | +| **0x10** | int | **`SecondsPerDay` (int copy — source of `iVar6`)** | `FUN_005a7fd0:6061` | +| 0x40 | double | `SecondsPerYear = DaysPerYear × SecondsPerDay` | `FUN_005a7fd0:6068` | +| 0x48 | float | `DayFraction` (0..1) | `FUN_005a7800:5497` | +| 0x50 | double | CurrentDay startTick | `FUN_005a7510` (via `FUN_005a75b0`) | +| 0x58 | double | CurrentDay endTick | `FUN_005a75b0:5345` | +| **0x64** | int | **`Year` (absolute)** | `FUN_005a7510:5300`: `= floor((worldTime+base)/secsPerYear) + baseYear` | +| **0x68** | int | **`DayOfYear`** | `FUN_005a7510:5304`: `= floor(withinYearSec / secsPerDay)` | +| 0x6c | int | `SeasonIndex` | `FUN_005a7510:5313` | + +The `iVar6 = *(int *)(DAT_008ee9c8 + 0x10)` read in `FUN_00501990` picks up `SecondsPerDay` as a multiplier — acting as a spread-factor to guarantee different days in different years yield different hash inputs. (If both sides of the multiplication were small — Year in [0, ~200], DayOfYear in [0, 365] — and `SecondsPerDay` were 1, the seed range would be tiny. Using the full `SecondsPerDay` integer makes the seed uniform over a ~31-bit range before the LCG step.) + +Retail Dereth values (from dat — confirm when we next parse a Region): `SecondsPerDay = 1800` (30 real-minutes = one Dereth-day per r12 §11, times some scale factor baked into the dat). Any value works — the algorithm is agnostic. + +--- + +## 5. Pseudocode (ready for C# port) + +```csharp +// SkyDesc.PickCurrentDayGroup — ports acclient.exe FUN_00501990 @ 0x00501990 +// Decompile: docs/research/decompiled/chunk_00500000.c:1276 +// Must be called every frame before SkyDesc.UpdateSkyObjectTable; value is +// stable across frames within one Dereth-day so repeated calls are cheap. +public static int PickCurrentDayGroup(int year, int secondsPerDay, int dayOfYear, int dayGroupCount) +{ + // Step 1: deterministic per-day seed. + // (This mirrors retail's 3-int read from TimeOfDay+0x64/0x10/0x68.) + int seed = year * secondsPerDay + dayOfYear; + + // Step 2: 32-bit signed LCG (x86 wraps silently; force it in C#). + int mixed = unchecked(seed * 0x6A42FDB2 + (int)0x8ABE1652); + // equivalent to: seed * 0x6A42FDB2 - 0x7541E9AE + + // Step 3: signed-int → float with sign fixup (retail does this in x87). + float hashF = (float)mixed; + if (mixed < 0) hashF += 4294967296.0f; // +2^32 + + // Step 4: scale to [0, dayGroupCount). + // _DAT_007c6f10 == 1.0f / 2^32 in retail. + const float kInv2Pow32 = 1.0f / 4294967296.0f; + float countF = (float)dayGroupCount; // dayGroupCount is always positive (it's a uint) + int index = (int)MathF.Floor(countF * hashF * kInv2Pow32); // __ftol2 truncates toward 0 + + // Step 5: safety clamp for float rounding edge case. + if (index >= dayGroupCount) index = 0; + + return index; +} +``` + +Call from the per-frame sky render hook, **before** the keyframe-bracket interpolation pass (mirrors `FUN_00508010` which invokes `FUN_004ff420()` before `FUN_004ff4b0()`): + +```csharp +// once per frame, before sky object/light interp +skyDesc.CurrentDayGroupIndex = SkyDesc.PickCurrentDayGroup( + year: world.TimeOfDay.Year, + secondsPerDay: world.TimeOfDay.SecondsPerDayInt, // the int copy at TimeOfDay+0x10 + dayOfYear: world.TimeOfDay.DayOfYear, + dayGroupCount: skyDesc.DayGroups.Count); +``` + +If ACE and acdream agree on `(Year, SecondsPerDay, DayOfYear)` (trivially true — ACE computes these server-side from the same epoch), both clients will converge to the same index every Dereth-day. This replaces the current SplitMix64 path. + +--- + +## 6. Cross-check notes + +- **ACE server does not re-implement this.** DayGroup/ChanceOfOccur are parsed (`ACE.DatLoader/Entity/DayGroup.cs`) but ACE never picks a DayGroup — the server leaves sky-state to each client, relying on every client running the same deterministic function on the same `(Year, DayOfYear)`. Confirmed by grep: no match for `0x6a42fdb2` or `SelectDayGroup`/`RollDayGroup`/`PickDayGroup` anywhere in `references/ACE/`. +- **ACViewer:** same — its `DayGroup.cs` is display-only. +- **WorldBuilder:** no sky/weather simulation at all. +- **Chorizite.ACProtocol:** the protocol has no "current DayGroup" message — confirming it's a pure client computation. + +So retail's decompile IS our only source, and we have it. + +--- + +## 7. Gaps + +None critical. Two minor open items: + +1. **`_DAT_007c6f10` exact bit value.** I inferred `1.0/2^32` from usage pattern but did not find a data-section dump that prints the literal. If the live client ever shows off-by-one drift on a boundary day, re-verify by dumping the .rdata section at 0x007c6f10. (ACDREAM_DUMP_SKY already logs picked index + inputs; we can correlate against a retail client session to confirm.) + +2. **`TimeOfDay+0x10` value for live Dereth.** Retail `SecondsPerDay` has been quoted as both 1800 and 3600 in various reverse-engineering docs depending on whether "seconds" means realtime seconds or Dereth-clock seconds. The algorithm itself is insensitive — whatever integer the dat provides is what both retail and acdream must use. If our Region parser stores it in a differently-named field, make sure the call site passes the dat-raw value (the int at TimeOfDay+0x10), not a re-derived one. + +--- + +## 8. Summary table + +| Question | Answer | Evidence | +|---|---|---| +| Caller of `FUN_00502a10` | `FUN_004ff4b0` → called from `FUN_00508010:7560` | `chunk_004F0000.c:10732`, `chunk_00500000.c:7560` | +| DayGroup selection function | `FUN_00501990` | `chunk_00500000.c:1276` | +| Hash formula | `(Year × SecondsPerDay + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `chunk_00500000.c:1296` | +| Weighted by ChanceOfOccur? | **No.** Uniform over DayGroupCount. | No CDF loop in FUN_00501990 | +| Selected index stored at | `SkyDesc + 0x00` (via `*param_1` write) | `chunk_00500000.c:1307,1309` | +| Read back by | `FUN_00502a10:2429`, `FUN_00501600`, `FUN_00501860` | `chunk_00500000.c:2429` | +| Time source | global `DAT_008ee9c8` (TimeOfDay), fields +0x64/+0x10/+0x68 | `chunk_00500000.c:1292-1294` | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c0014d4..2fb25f6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4316,10 +4316,21 @@ public sealed class GameWindow : IDisposable if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0) return; - long dayIndex = (long)System.Math.Floor( - WorldTime.NowTicks / AcDream.Core.World.DerethDateTime.DayTicks); + // Retail FUN_00501990 uses the full (Year, SecondsPerDay, DayOfYear) + // triple — NOT a flat dayIndex. The SecondsPerDay multiplier is + // load-bearing: without it, adjacent years map to adjacent LCG + // seeds and convergence to acdream != retail would recur every + // year. We use the retail calendar constants from DerethDateTime. + double ticks = WorldTime.NowTicks; + int year = AcDream.Core.World.DerethDateTime.Year(ticks); + int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks); + int secondsPerDay = (int)AcDream.Core.World.DerethDateTime.DayTicks; // 7620 - int idx = _loadedSkyDesc.SelectDayGroupIndex(dayIndex); + // Compute a composite "dayIndex" for our own change-detection + // and logging (doesn't feed into the roll itself). + long dayIndex = (long)year * 360 + dayOfYear; + + int idx = _loadedSkyDesc.SelectDayGroupIndex(year, secondsPerDay, dayOfYear); var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count ? _loadedSkyDesc.DayGroups[idx] : null; @@ -4339,7 +4350,7 @@ public sealed class GameWindow : IDisposable grp.SkyTimes.Select(s => s.Keyframe).ToList())); Console.WriteLine( - $"sky: day {dayIndex} → DayGroup[{idx}] \"{grp.Name}\" " + + $"sky: PY{year} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " + $"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " + $"{grp.SkyTimes.Count} keyframes)"); } diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs index 2d5ecf4..5168e04 100644 --- a/src/AcDream.Core/World/DerethDateTime.cs +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -176,4 +176,35 @@ public static class DerethDateTime return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks)); } + + /// + /// Absolute year since tick-0 (PY 0, Snowreap 1, Morntide-and-Half). + /// Matches retail's TimeOfDay + 0x64 field which is + /// floor((worldTime + base) / secsPerYear) + baseYear. For + /// acdream's purposes we treat ZeroYear=0 since only the RELATIVE + /// year is needed for deterministic day-group rolling (retail and + /// acdream agree as long as both apply the same derivation to the + /// server's PortalYearTicks). + /// + public static int Year(double ticks) + { + if (ticks < 0) ticks = 0; + return (int)(ticks / YearTicks); + } + + /// + /// Day index within the current Derethian year in [0, 359] (360 days + /// per year = 12 months × 30). Matches retail's TimeOfDay + 0x68 + /// field which is floor(withinYearSec / secsPerDay). Consumed + /// by SkyDesc.PickCurrentDayGroup as part of the per-day seed. + /// + public static int DayOfYear(double ticks) + { + if (ticks < 0) ticks = 0; + double tYear = ticks - Year(ticks) * YearTicks; + int d = (int)(tYear / DayTicks); + if (d < 0) d = 0; + if (d > 359) d = 359; + return d; + } } diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index 4aecffe..e8084b9 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -143,22 +143,45 @@ public sealed class LoadedSkyDesc public IReadOnlyList DayGroups = Array.Empty(); /// - /// Pick a deterministically for a given - /// in-game day. Matches r12 §11: retail rolls one DayGroup per Derethian - /// day using as a PDF weight, - /// seeded by the day index so all clients in the same server-day see - /// the same weather. Use -floored - /// ticks as to stay in sync with the - /// server's PortalYearTicks. + /// Pick a deterministically for the given + /// Derethian (year, dayOfYear) pair. Retail-verbatim port of + /// SkyDesc::PickCurrentDayGroup (FUN_00501990 at + /// chunk_00500000.c:1276) — the per-frame weather roller. /// /// - /// Honors the ACDREAM_DAY_GROUP environment variable as a - /// force-override (useful for visual verification: set it to 12 = - /// "Cloudy" or 10 = "Clear" to confirm the weather-selection - /// hypothesis without running through the roller). + /// Algorithm (from the retail decompile): + /// + /// seed = year * secondsPerDay + dayOfYear + /// hash = seed * 0x6A42FDB2 + 0x8ABE1652 (signed 32-bit LCG) + /// index = floor(dayGroupCount * (uint)hash / 2^32) + /// if (index >= dayGroupCount) index = 0 // float-rounding safety + /// + /// It is uniform over all DayGroups — retail does NOT weight + /// by . Dereth's 20 groups all + /// carry ChanceOfOccur=5.0 so the intent is equiprobable anyway. See + /// docs/research/2026-04-23-daygroup-selection.md §2-5 for the + /// full citation trail and the disproof of the weighted-CDF + /// hypothesis. + /// + /// + /// + /// should be the dat-declared + /// "seconds per Derethian day" integer (retail reads it from + /// TimeOfDay + 0x10). acdream's callers pass + /// as an int (7620); ACE + /// computes the same value server-side so retail and acdream + /// converge on identical picks whenever their (Year, DayOfYear) + /// agree — which they do, because both derive from the server's + /// PortalYearTicks. + /// + /// + /// + /// The ACDREAM_DAY_GROUP environment variable overrides the + /// pick (useful for visually A/B-testing each weather preset against + /// retail). /// /// - public int SelectDayGroupIndex(long dayIndex) + public int SelectDayGroupIndex(int year, int secondsPerDay, int dayOfYear) { if (DayGroups.Count == 0) return 0; @@ -173,61 +196,65 @@ public sealed class LoadedSkyDesc if (DayGroups.Count == 1) return 0; - // SplitMix64-style deterministic hash of the day index so all - // clients roll the same weather for the same Dereth-day without - // any network synchronization (r12 §11, "client-local weather - // model"). The constants are the canonical SplitMix64 mixer from - // the JDK's Splittable Random — high avalanche quality. - ulong h = (ulong)dayIndex; - h = (h ^ (h >> 30)) * 0xbf58476d1ce4e5b9UL; - h = (h ^ (h >> 27)) * 0x94d049bb133111ebUL; - h ^= (h >> 31); + // --- Retail FUN_00501990 line-by-line port --- - // Sum ChanceOfOccur to get the weighted total. - double total = 0; - for (int i = 0; i < DayGroups.Count; i++) - if (DayGroups[i].ChanceOfOccur > 0) - total += DayGroups[i].ChanceOfOccur; + // Step 1: deterministic per-day seed. + int seed = unchecked(year * secondsPerDay + dayOfYear); - // Fall back to uniform if weights are missing / zero. - if (total <= 0) - return (int)(h % (ulong)DayGroups.Count); + // Step 2: 32-bit signed LCG (retail uses x86 silent wrap; force + // unchecked in C#). `0x8ABE1652` is stored as `-0x7541E9AE` in the + // decompile — same bit pattern. + int mixed = unchecked(seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652)); - // Pick = h normalized to [0, total), then cumulative-distribution walk. - double pick = (h / (double)ulong.MaxValue) * total; - double cum = 0; - for (int i = 0; i < DayGroups.Count; i++) - { - if (DayGroups[i].ChanceOfOccur > 0) - cum += DayGroups[i].ChanceOfOccur; - if (pick < cum) return i; - } - return DayGroups.Count - 1; + // Step 3: signed-int → float with +2^32 fixup for negative values + // (retail x87 converts `int` to `float` and then adds + // `_DAT_0079920c` ≈ 4294967296.0f when `iVar4 < 0`). + float hashF = (float)mixed; + if (mixed < 0) hashF += 4294967296.0f; + + // Step 4: scale to [0, dayGroupCount). `_DAT_007c6f10` is + // `1.0f / 2^32` per reuse pattern (see research doc §2.4). + const float kInv2Pow32 = 1.0f / 4294967296.0f; + float countF = (float)DayGroups.Count; + int index = (int)System.MathF.Floor(countF * hashF * kInv2Pow32); + + // Step 5: safety clamp for float rounding at the upper edge. + // Retail does `if (count <= index) index = 0;`. Using the same + // "snap to 0 on overflow" instead of clamping to count-1. + if (index >= DayGroups.Count) index = 0; + if (index < 0) index = 0; + + return index; } /// - /// Convenience: compute the day index from current server ticks and - /// look up today's rolled DayGroup. Safe to call off the render thread. + /// Convenience: derive (Year, DayOfYear) from the current + /// and call . + /// Used by the per-frame render tick so callers don't need to + /// de-structure the calendar themselves. /// public DayGroupData? ActiveDayGroup(double serverTicks) { - long dayIndex = (long)System.Math.Floor(serverTicks / DerethDateTime.DayTicks); - int idx = SelectDayGroupIndex(dayIndex); + int year = DerethDateTime.Year(serverTicks); + int dayOfYear = DerethDateTime.DayOfYear(serverTicks); + int secondsPerDay = (int)DerethDateTime.DayTicks; // 7620 + int idx = SelectDayGroupIndex(year, secondsPerDay, dayOfYear); return idx < DayGroups.Count ? DayGroups[idx] : null; } /// /// Legacy accessor — kept for callers that don't yet know the server - /// tick count. Rolls against dayIndex=0 (so env-var override - /// works but unforced selection always returns the same group). - /// Prefer which syncs to the - /// server clock. + /// tick count. Rolls against (year=0, dayOfYear=0) so env-var + /// override works but unforced selection always returns the same + /// group pre-sync. Prefer which + /// syncs to the server clock. /// public DayGroupData? DefaultDayGroup { get { - int idx = SelectDayGroupIndex(0); + int idx = SelectDayGroupIndex( + year: 0, secondsPerDay: (int)DerethDateTime.DayTicks, dayOfYear: 0); return DayGroups.Count > 0 ? DayGroups[idx] : null; } }