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>
This commit is contained in:
Erik 2026-04-24 08:45:34 +02:00
parent 027ccb46b9
commit 6ea87b7ea8
4 changed files with 346 additions and 52 deletions

View file

@ -4316,10 +4316,21 @@ public sealed class GameWindow : IDisposable
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
return;
long dayIndex = (long)System.Math.Floor(
WorldTime.NowTicks / AcDream.Core.World.DerethDateTime.DayTicks);
// Retail FUN_00501990 uses the full (Year, SecondsPerDay, DayOfYear)
// triple — NOT a flat dayIndex. The SecondsPerDay multiplier is
// load-bearing: without it, adjacent years map to adjacent LCG
// seeds and convergence to acdream != retail would recur every
// year. We use the retail calendar constants from DerethDateTime.
double ticks = WorldTime.NowTicks;
int year = AcDream.Core.World.DerethDateTime.Year(ticks);
int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks);
int secondsPerDay = (int)AcDream.Core.World.DerethDateTime.DayTicks; // 7620
int idx = _loadedSkyDesc.SelectDayGroupIndex(dayIndex);
// Compute a composite "dayIndex" for our own change-detection
// and logging (doesn't feed into the roll itself).
long dayIndex = (long)year * 360 + dayOfYear;
int idx = _loadedSkyDesc.SelectDayGroupIndex(year, secondsPerDay, dayOfYear);
var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
? _loadedSkyDesc.DayGroups[idx]
: null;
@ -4339,7 +4350,7 @@ public sealed class GameWindow : IDisposable
grp.SkyTimes.Select(s => s.Keyframe).ToList()));
Console.WriteLine(
$"sky: day {dayIndex} → DayGroup[{idx}] \"{grp.Name}\" " +
$"sky: PY{year} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " +
$"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " +
$"{grp.SkyTimes.Count} keyframes)");
}