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:
parent
027ccb46b9
commit
6ea87b7ea8
4 changed files with 346 additions and 52 deletions
|
|
@ -143,22 +143,45 @@ public sealed class LoadedSkyDesc
|
|||
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
|
||||
|
||||
/// <summary>
|
||||
/// Pick a <see cref="DayGroupData"/> deterministically for a given
|
||||
/// in-game day. Matches r12 §11: retail rolls one DayGroup per Derethian
|
||||
/// day using <see cref="DayGroupData.ChanceOfOccur"/> as a PDF weight,
|
||||
/// seeded by the day index so all clients in the same server-day see
|
||||
/// the same weather. Use <see cref="DerethDateTime.DayTicks"/>-floored
|
||||
/// ticks as <paramref name="dayIndex"/> to stay in sync with the
|
||||
/// server's <c>PortalYearTicks</c>.
|
||||
/// Pick a <see cref="DayGroupData"/> deterministically for the given
|
||||
/// Derethian (year, dayOfYear) pair. Retail-verbatim port of
|
||||
/// <c>SkyDesc::PickCurrentDayGroup</c> (<c>FUN_00501990</c> at
|
||||
/// <c>chunk_00500000.c:1276</c>) — the per-frame weather roller.
|
||||
///
|
||||
/// <para>
|
||||
/// Honors the <c>ACDREAM_DAY_GROUP</c> environment variable as a
|
||||
/// force-override (useful for visual verification: set it to 12 =
|
||||
/// "Cloudy" or 10 = "Clear" to confirm the weather-selection
|
||||
/// hypothesis without running through the roller).
|
||||
/// <b>Algorithm</b> (from the retail decompile):
|
||||
/// <code>
|
||||
/// seed = year * secondsPerDay + dayOfYear
|
||||
/// hash = seed * 0x6A42FDB2 + 0x8ABE1652 (signed 32-bit LCG)
|
||||
/// index = floor(dayGroupCount * (uint)hash / 2^32)
|
||||
/// if (index >= dayGroupCount) index = 0 // float-rounding safety
|
||||
/// </code>
|
||||
/// It is <b>uniform</b> over all DayGroups — retail does NOT weight
|
||||
/// by <see cref="DayGroupData.ChanceOfOccur"/>. Dereth's 20 groups all
|
||||
/// carry ChanceOfOccur=5.0 so the intent is equiprobable anyway. See
|
||||
/// <c>docs/research/2026-04-23-daygroup-selection.md</c> §2-5 for the
|
||||
/// full citation trail and the disproof of the weighted-CDF
|
||||
/// hypothesis.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="secondsPerDay"/> should be the dat-declared
|
||||
/// "seconds per Derethian day" integer (retail reads it from
|
||||
/// <c>TimeOfDay + 0x10</c>). acdream's callers pass
|
||||
/// <see cref="DerethDateTime.DayTicks"/> as an int (7620); ACE
|
||||
/// computes the same value server-side so retail and acdream
|
||||
/// converge on identical picks whenever their <c>(Year, DayOfYear)</c>
|
||||
/// agree — which they do, because both derive from the server's
|
||||
/// <c>PortalYearTicks</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <c>ACDREAM_DAY_GROUP</c> environment variable overrides the
|
||||
/// pick (useful for visually A/B-testing each weather preset against
|
||||
/// retail).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int SelectDayGroupIndex(long dayIndex)
|
||||
public int SelectDayGroupIndex(int year, int secondsPerDay, int dayOfYear)
|
||||
{
|
||||
if (DayGroups.Count == 0) return 0;
|
||||
|
||||
|
|
@ -173,61 +196,65 @@ public sealed class LoadedSkyDesc
|
|||
|
||||
if (DayGroups.Count == 1) return 0;
|
||||
|
||||
// SplitMix64-style deterministic hash of the day index so all
|
||||
// clients roll the same weather for the same Dereth-day without
|
||||
// any network synchronization (r12 §11, "client-local weather
|
||||
// model"). The constants are the canonical SplitMix64 mixer from
|
||||
// the JDK's Splittable Random — high avalanche quality.
|
||||
ulong h = (ulong)dayIndex;
|
||||
h = (h ^ (h >> 30)) * 0xbf58476d1ce4e5b9UL;
|
||||
h = (h ^ (h >> 27)) * 0x94d049bb133111ebUL;
|
||||
h ^= (h >> 31);
|
||||
// --- Retail FUN_00501990 line-by-line port ---
|
||||
|
||||
// Sum ChanceOfOccur to get the weighted total.
|
||||
double total = 0;
|
||||
for (int i = 0; i < DayGroups.Count; i++)
|
||||
if (DayGroups[i].ChanceOfOccur > 0)
|
||||
total += DayGroups[i].ChanceOfOccur;
|
||||
// Step 1: deterministic per-day seed.
|
||||
int seed = unchecked(year * secondsPerDay + dayOfYear);
|
||||
|
||||
// Fall back to uniform if weights are missing / zero.
|
||||
if (total <= 0)
|
||||
return (int)(h % (ulong)DayGroups.Count);
|
||||
// Step 2: 32-bit signed LCG (retail uses x86 silent wrap; force
|
||||
// unchecked in C#). `0x8ABE1652` is stored as `-0x7541E9AE` in the
|
||||
// decompile — same bit pattern.
|
||||
int mixed = unchecked(seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652));
|
||||
|
||||
// Pick = h normalized to [0, total), then cumulative-distribution walk.
|
||||
double pick = (h / (double)ulong.MaxValue) * total;
|
||||
double cum = 0;
|
||||
for (int i = 0; i < DayGroups.Count; i++)
|
||||
{
|
||||
if (DayGroups[i].ChanceOfOccur > 0)
|
||||
cum += DayGroups[i].ChanceOfOccur;
|
||||
if (pick < cum) return i;
|
||||
}
|
||||
return DayGroups.Count - 1;
|
||||
// Step 3: signed-int → float with +2^32 fixup for negative values
|
||||
// (retail x87 converts `int` to `float` and then adds
|
||||
// `_DAT_0079920c` ≈ 4294967296.0f when `iVar4 < 0`).
|
||||
float hashF = (float)mixed;
|
||||
if (mixed < 0) hashF += 4294967296.0f;
|
||||
|
||||
// Step 4: scale to [0, dayGroupCount). `_DAT_007c6f10` is
|
||||
// `1.0f / 2^32` per reuse pattern (see research doc §2.4).
|
||||
const float kInv2Pow32 = 1.0f / 4294967296.0f;
|
||||
float countF = (float)DayGroups.Count;
|
||||
int index = (int)System.MathF.Floor(countF * hashF * kInv2Pow32);
|
||||
|
||||
// Step 5: safety clamp for float rounding at the upper edge.
|
||||
// Retail does `if (count <= index) index = 0;`. Using the same
|
||||
// "snap to 0 on overflow" instead of clamping to count-1.
|
||||
if (index >= DayGroups.Count) index = 0;
|
||||
if (index < 0) index = 0;
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: compute the day index from current server ticks and
|
||||
/// look up today's rolled DayGroup. Safe to call off the render thread.
|
||||
/// Convenience: derive (Year, DayOfYear) from the current
|
||||
/// <paramref name="serverTicks"/> and call <see cref="SelectDayGroupIndex"/>.
|
||||
/// Used by the per-frame render tick so callers don't need to
|
||||
/// de-structure the calendar themselves.
|
||||
/// </summary>
|
||||
public DayGroupData? ActiveDayGroup(double serverTicks)
|
||||
{
|
||||
long dayIndex = (long)System.Math.Floor(serverTicks / DerethDateTime.DayTicks);
|
||||
int idx = SelectDayGroupIndex(dayIndex);
|
||||
int year = DerethDateTime.Year(serverTicks);
|
||||
int dayOfYear = DerethDateTime.DayOfYear(serverTicks);
|
||||
int secondsPerDay = (int)DerethDateTime.DayTicks; // 7620
|
||||
int idx = SelectDayGroupIndex(year, secondsPerDay, dayOfYear);
|
||||
return idx < DayGroups.Count ? DayGroups[idx] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy accessor — kept for callers that don't yet know the server
|
||||
/// tick count. Rolls against <c>dayIndex=0</c> (so env-var override
|
||||
/// works but unforced selection always returns the same group).
|
||||
/// Prefer <see cref="ActiveDayGroup(double)"/> which syncs to the
|
||||
/// server clock.
|
||||
/// tick count. Rolls against <c>(year=0, dayOfYear=0)</c> so env-var
|
||||
/// override works but unforced selection always returns the same
|
||||
/// group pre-sync. Prefer <see cref="ActiveDayGroup(double)"/> which
|
||||
/// syncs to the server clock.
|
||||
/// </summary>
|
||||
public DayGroupData? DefaultDayGroup
|
||||
{
|
||||
get
|
||||
{
|
||||
int idx = SelectDayGroupIndex(0);
|
||||
int idx = SelectDayGroupIndex(
|
||||
year: 0, secondsPerDay: (int)DerethDateTime.DayTicks, dayOfYear: 0);
|
||||
return DayGroups.Count > 0 ? DayGroups[idx] : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue