Move the DAT sky, selected day group, world clock, weather, AdminEnvirons bridge, and debug cycles into a one-shot WorldEnvironmentController while preserving GameWindow's public aliases and accepted startup/session/render order. Correct the named retail citations and register the remaining environment audio and fog/radar gaps. Co-authored-by: Codex <codex@openai.com>
12 KiB
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).
Named-oracle correction (2026-07-22): the Sept 2013 PDB identifies this body as
SkyDesc::CalcPresentDayGroup @ 0x00500E10. References below toFUN_00501990 @ 0x00501990describe the older fallback binary/chunk only; that address is insideSkyDesc::Packin the named 2013 build.
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 named algorithm is SkyDesc::CalcPresentDayGroup @ 0x00500E10; FUN_00501990 is its older-build fallback identity.
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):
// 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)
// chunk_00500000.c:1274 FUN_00501990 @ 0x00501990 size=138
// Older build of SkyDesc::CalcPresentDayGroup(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) atchunk_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 (walksDayGroup[*thisIndex].SkyTimekeyframes).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 | DaysPerYear (source of iVar6; live-probed as 360) |
FUN_005a7fd0:6061; 2026-04-23-retail-memory-probe.md |
| 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 days_per_year = GameTime::current_game_time->days_per_year read in
SkyDesc::CalcPresentDayGroup is the year-to-day multiplier. The live memory
probe measured 360 at the older build's corresponding TimeOfDay+0x10 field,
so Year × DaysPerYear + DayOfYear is a flat total-day index.
Retail Dereth's DAT-backed DaysPerYear value is 360.
5. Pseudocode (ready for C# port)
// SkyDesc.CalcPresentDayGroup — named retail @ 0x00500E10
// Older-build cross-reference: 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 CalcPresentDayGroup(int year, int daysPerYear, 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 * daysPerYear + 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()):
// once per frame, before sky object/light interp
skyDesc.CurrentDayGroupIndex = SkyDesc.CalcPresentDayGroup(
year: world.TimeOfDay.Year,
daysPerYear: world.TimeOfDay.DaysPerYear,
dayOfYear: world.TimeOfDay.DayOfYear,
dayGroupCount: skyDesc.DayGroups.Count);
Retail and acdream converge whenever their (Year, DaysPerYear, DayOfYear)
inputs agree. ACE does not select a day group; each client computes it locally.
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 for0x6a42fdb2orSelectDayGroup/RollDayGroup/PickDayGroupanywhere inreferences/ACE/. - ACViewer: same — its
DayGroup.csis 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. One minor open item:
_DAT_007c6f10exact bit value. I inferred1.0/2^32from 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.)
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 | SkyDesc::CalcPresentDayGroup @ 0x00500E10 |
named Sept 2013 PDB/pseudo-C; older fallback FUN_00501990, chunk_00500000.c:1276 |
| Hash formula | (Year × DaysPerYear + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652 |
SkyDesc::CalcPresentDayGroup @ 0x00500E10; older 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 |