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

@ -176,4 +176,35 @@ public static class DerethDateTime
return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks));
}
/// <summary>
/// Absolute year since tick-0 (PY 0, Snowreap 1, Morntide-and-Half).
/// Matches retail's <c>TimeOfDay + 0x64</c> field which is
/// <c>floor((worldTime + base) / secsPerYear) + baseYear</c>. For
/// acdream's purposes we treat ZeroYear=0 since only the RELATIVE
/// year is needed for deterministic day-group rolling (retail and
/// acdream agree as long as both apply the same derivation to the
/// server's PortalYearTicks).
/// </summary>
public static int Year(double ticks)
{
if (ticks < 0) ticks = 0;
return (int)(ticks / YearTicks);
}
/// <summary>
/// Day index within the current Derethian year in [0, 359] (360 days
/// per year = 12 months × 30). Matches retail's <c>TimeOfDay + 0x68</c>
/// field which is <c>floor(withinYearSec / secsPerDay)</c>. Consumed
/// by <c>SkyDesc.PickCurrentDayGroup</c> as part of the per-day seed.
/// </summary>
public static int DayOfYear(double ticks)
{
if (ticks < 0) ticks = 0;
double tYear = ticks - Year(ticks) * YearTicks;
int d = (int)(tYear / DayTicks);
if (d < 0) d = 0;
if (d > 359) d = 359;
return d;
}
}