acdream/docs/research/2026-04-23-daygroup-selection.md
Erik d09e246d3a refactor(world): own live environment state
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>
2026-07-22 10:54:33 +02:00

12 KiB
Raw Blame History

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 to FUN_00501990 @ 0x00501990 describe the older fallback binary/chunk only; that address is inside SkyDesc::Pack in 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) 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 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 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. One minor open item:

  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.)

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