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) <noreply@anthropic.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).
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):
// 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
// 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) 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 | 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)
// 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()):
// 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 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. Two minor open items:
-
_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.) -
TimeOfDay+0x10value for live Dereth. RetailSecondsPerDayhas 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 |