acdream/docs/research/2026-04-23-daygroup-selection.md
Erik 6ea87b7ea8 sky(phase-3c): port retail FUN_00501990 DayGroup picker (uniform LCG)
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>
2026-04-24 08:45:34 +02:00

12 KiB
Raw Permalink 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).


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

// 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 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