# 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` |