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

225 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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