diff --git a/AcDream.slnx b/AcDream.slnx
index cd884f7..e7fd39a 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -7,6 +7,9 @@
+
+
+
diff --git a/docs/research/2026-04-23-retail-memory-probe.md b/docs/research/2026-04-23-retail-memory-probe.md
new file mode 100644
index 0000000..f1c7135
--- /dev/null
+++ b/docs/research/2026-04-23-retail-memory-probe.md
@@ -0,0 +1,117 @@
+# Retail acclient.exe Live Memory Probe — TimeOfDay Struct
+
+**Date:** 2026-04-23
+**Tool:** `tools/RetailTimeProbe/` — P/Invoke `ReadProcessMemory` against a running retail client
+**Target:** `C:\Turbine\Asheron's Call\acclient.exe`, pid 17980
+
+## Observed values
+
+```
+module base = 0x00400000 (no ASLR delta from preferred)
+DAT_008ee9c8 relocated to 0x008EE9C8
+TimeOfDay* = 0x00A5C1C0
+
+EpochBase (+0x00 double) = 3600.000000 ← matches dat ZeroTimeOfYear ✓
+BaseYear (+0x08 int) = 10 ← matches dat ZeroYear ✓
+SecondsPerDay (+0x0C float) = 7620 ← matches dat DayLength ✓
++0x10 (int) = 360 ← see §1 — NOT seconds-per-day!
+SecondsPerYear (+0x40 double) = 2743200.000000 ← DayLength × DaysPerYear ✓
+DayFraction (+0x48 float) = 0.340492 ← see §2
+CurDayStart (+0x50 double) = 291133740.000000
+CurDayEnd (+0x58 double) = 291136597.500000
+Year (+0x64 int) = 116 ← matches our AbsoluteYear ✓
+DayOfYear (+0x68 int) = 47 ← matches our DayOfYear ✓
+SeasonIndex (+0x6C int) = 1
+```
+
+Acdream at the same wall-clock moment:
+```
+AbsoluteYear = 116 ✓
+DayOfYear = 47 ✓
+```
+
+## 1. The `+0x10` field is DaysPerYear (360), not SecondsPerDay
+
+The decompile agent's C trail
+(`docs/research/2026-04-23-sky-decompile-hunt-C.md` §1 + §4) labeled
+`TimeOfDay+0x10` as *"SecondsPerDay (int copy — source of iVar6)"*
+for the `FUN_00501990` (`SkyDesc::PickCurrentDayGroup`) LCG seed.
+
+The live probe disproves that. The value is **360** — the same as
+`GameTime.DaysPerYear` from the dat.
+
+Implication for the retail LCG seed formula:
+
+```c
+// FUN_00501990 line 1296 of chunk_00500000.c:
+iVar4 = (iVar3 * iVar6 + iVar4) * 0x6a42fdb2 + -0x7541e9ae;
+// ^Year ^x0x10 ^DayOfYear
+```
+
+With `iVar6 = 360` (DaysPerYear), the seed is:
+
+```
+seed = Year × DaysPerYear + DayOfYear
+ = total days since (Year 0, DayOfYear 0)
+```
+
+That's a flat day-index, which makes obvious sense for a per-day
+weather picker. Retail's engineers didn't pass a "seconds per day" at
+all — they passed "days per year" as the year-to-day multiplier so the
+final seed is a total-day count.
+
+**Actionable:** our C# port must pass 360 (`DaysInAMonth * MonthsInAYear`)
+as the LCG's `secondsPerDay` parameter, not the 7620 we previously used.
+With 7620 we compute `seed = 883,967`; retail computes `seed = 41,807`.
+Completely different LCG outputs → different DayGroup picks → weather
+mismatch users observed against their retail client side-by-side.
+
+## 2. `+0x48 DayFraction` is a sub-period fraction, not full-day
+
+Live retail at the probed moment:
+- Calendar: `PY116 ColdMeet 18 Dawnsong` (hour 4 of 16, i.e. morning).
+- DayFraction: 0.340492
+
+Our acdream at the same tick would compute `dayFraction ≈ 0.13` using
+the full-day formula `((tick + ZeroTimeOfYear) mod DayLength) / DayLength`.
+
+The stored `CurDayStart/End` bounds give it away:
+
+```
+CurDayEnd - CurDayStart = 2857.5 ticks = 0.375 × 7620 = 6 Dereth hours
+```
+
+6 hours = night duration (hours 0-3 + 14-15 = 6 hours of the 16-hour day).
+So retail's `+0x48 DayFraction` is **"fraction through current day/night
+period"**, NOT "fraction through full calendar day". Using these fields:
+
+```
+retailDayFraction = (currentTick - CurDayStart) / (CurDayEnd - CurDayStart)
+```
+
+For our use-case (bracketing SkyTime keyframes whose `Begin` is on a
+[0, 1] full-day scale), the full-day formula we have is correct — sky
+keyframes don't care about retail's internal sub-period storage. The
+difference is only visible if we reverse-engineer "what hour does retail
+think it is" by reading its field; we wouldn't use retail's
+`+0x48` value for interpolation even if we could.
+
+So: no fix required for our dayFraction path. The keyframe bracket works
+correctly with our own `(tick + 3600) mod 7620 / 7620` formula.
+
+## 3. Confirmed no-change items
+
+- `EpochBase` / `ZeroTimeOfYear` = 3600 ✓ (Phase 3f commit cd8a37a already
+ adopted this from `Region.GameTime.ZeroTimeOfYear`).
+- `BaseYear` / `ZeroYear` = 10 ✓ (`DerethDateTime.ZeroYear`).
+- `SecondsPerDay` float (+0x0C) = 7620 ✓ (`DayTicks`).
+- `SecondsPerYear` = 2,743,200 ✓ (`YearTicks`).
+- `Year` = 116, `DayOfYear` = 47 — our `AbsoluteYear` and `DayOfYear`
+ helpers already reproduce these from the server tick.
+
+## 4. Follow-ups
+
+None critical after the Phase 3g fix (DaysPerYear in LCG). If the
+DayFraction sub-period storage becomes relevant (e.g. if we ever render
+retail's "Xm until night" UI), we'd replicate the CurDayStart/End
+computation from `FUN_005a7800` in the decompile.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d8230c5..99dd2bd 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -4352,19 +4352,26 @@ public sealed class GameWindow : IDisposable
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
return;
- // Retail FUN_00501990 seeds the LCG with
- // (Year_absolute, SecondsPerDay, DayOfYear)
- // where Year_absolute = TimeOfDay+0x64 = floor(...) + baseYear
- // (baseYear=10 for Dereth per GameTime.ZeroYear). Our port uses
- // AbsoluteYear which includes the +10 offset; without it, our
- // seed would differ from retail's by `10 × SecondsPerDay` and we'd
- // pick a different DayGroup (verified mismatch in the 2026-04-23
- // live session — acdream picked "Rainy"[17] while retail showed
- // "Sunny" at PY 116 ColdMeet 17).
+ // Retail FUN_00501990 seeds the LCG with the triple stored in
+ // TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear)
+ //
+ // The decompile agent labeled +0x10 "SecondsPerDay (int copy)"
+ // but a live memory probe of retail's acclient.exe (2026-04-23,
+ // tools/RetailTimeProbe) shows the value is actually **360** —
+ // semantically DaysPerYear, not seconds. So the LCG seed is
+ // seed = Year × DaysPerYear + DayOfYear
+ // which is literally "total days since epoch" (a flat day index),
+ // confirmed against retail's Year=116, DayOfYear=47, seed=41807.
+ //
+ // Previously we passed 7620 (DayTicks), producing seed 883967 —
+ // a completely different LCG output → wrong DayGroup pick →
+ // user-observed weather mismatch (acdream clear while retail
+ // stormy, 2026-04-23). The live probe nailed the fix.
double ticks = WorldTime.NowTicks;
int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks);
int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks);
- int secondsPerDay = (int)AcDream.Core.World.DerethDateTime.DayTicks; // 7620
+ int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth
+ * AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360
// Composite day key for change-detection and logging only; the
// LCG seed is computed inside SelectDayGroupIndex from (absYear,
diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs
index a75edab..dda09ca 100644
--- a/src/AcDream.Core/World/SkyDescLoader.cs
+++ b/src/AcDream.Core/World/SkyDescLoader.cs
@@ -237,7 +237,11 @@ public sealed class LoadedSkyDesc
{
int absYear = DerethDateTime.AbsoluteYear(serverTicks);
int dayOfYear = DerethDateTime.DayOfYear(serverTicks);
- int secondsPerDay = (int)DerethDateTime.DayTicks; // 7620
+ // Retail's TimeOfDay+0x10 is actually DaysPerYear (= 360 for Dereth,
+ // live probe 2026-04-23), NOT SecondsPerDay as the decompile agent
+ // mis-labeled. See GameWindow.RefreshSkyForCurrentDay for the full
+ // citation.
+ int secondsPerDay = DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear; // 360
int idx = SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear);
return idx < DayGroups.Count ? DayGroups[idx] : null;
}
@@ -254,7 +258,9 @@ public sealed class LoadedSkyDesc
get
{
int idx = SelectDayGroupIndex(
- year: 0, secondsPerDay: (int)DerethDateTime.DayTicks, dayOfYear: 0);
+ year: 0,
+ secondsPerDay: DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear, // 360
+ dayOfYear: 0);
return DayGroups.Count > 0 ? DayGroups[idx] : null;
}
}
diff --git a/tools/RetailTimeProbe/Program.cs b/tools/RetailTimeProbe/Program.cs
new file mode 100644
index 0000000..3259357
--- /dev/null
+++ b/tools/RetailTimeProbe/Program.cs
@@ -0,0 +1,211 @@
+// RetailTimeProbe — read the live retail acclient.exe process memory and
+// dump its TimeOfDay struct so we can compare against acdream's computed
+// calendar values.
+//
+// Decompile provenance (docs/research/2026-04-23-sky-decompile-hunt-C.md
+// §4 and the daygroup-selection research):
+//
+// DAT_008ee9c8 — absolute VA of a pointer to TimeOfDay
+// TimeOfDay +0x00 double — EpochBase
+// TimeOfDay +0x08 int — BaseYear
+// TimeOfDay +0x0C float — SecondsPerDay
+// TimeOfDay +0x10 int — SecondsPerDay (int copy, LCG input)
+// TimeOfDay +0x40 double — SecondsPerYear
+// TimeOfDay +0x48 float — DayFraction (0..1) — live authority!
+// TimeOfDay +0x50 double — CurrentDay startTick
+// TimeOfDay +0x58 double — CurrentDay endTick
+// TimeOfDay +0x64 int — Year (absolute)
+// TimeOfDay +0x68 int — DayOfYear
+// TimeOfDay +0x6C int — SeasonIndex
+//
+// The acclient.exe referenced in the decompile has preferred image base
+// 0x00400000 (standard Win32 default). If ASLR is enabled the actual
+// load address will differ — we compute relative to Process.MainModule
+// .BaseAddress to stay robust.
+
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+namespace RetailTimeProbe;
+
+internal static class Program
+{
+ // Decompile-derived constants.
+ private const uint PreferredImageBase = 0x00400000u;
+ private const uint DatTimeOfDayPtr = 0x008ee9c8u; // DAT_008ee9c8
+
+ // TimeOfDay struct offsets.
+ private const int Off_EpochBase = 0x00; // double
+ private const int Off_BaseYear = 0x08; // int
+ private const int Off_SecondsPerDay = 0x0C; // float
+ private const int Off_SecondsPerDayI = 0x10; // int
+ private const int Off_SecondsPerYear = 0x40; // double
+ private const int Off_DayFraction = 0x48; // float
+ private const int Off_CurDayStart = 0x50; // double
+ private const int Off_CurDayEnd = 0x58; // double
+ private const int Off_Year = 0x64; // int
+ private const int Off_DayOfYear = 0x68; // int
+ private const int Off_SeasonIndex = 0x6C; // int
+
+ // Process access rights needed: read memory + query info.
+ private const uint PROCESS_VM_READ = 0x0010u;
+ private const uint PROCESS_QUERY_INFORMATION = 0x0400u;
+
+ private static int Main(string[] args)
+ {
+ // Retail's process name is "acclient" (.exe stripped by Process API).
+ // Allow override from the command line just in case.
+ string processName = args.Length > 0 ? args[0] : "acclient";
+
+ Console.WriteLine($"RetailTimeProbe — scanning for process \"{processName}\"...");
+ Process[] procs = Process.GetProcessesByName(processName);
+ if (procs.Length == 0)
+ {
+ Console.Error.WriteLine(
+ $"no process named \"{processName}\" is running. Launch the retail AC client " +
+ "and log in to a character first, then re-run this probe.");
+ return 2;
+ }
+ if (procs.Length > 1)
+ Console.WriteLine($"(found {procs.Length} matching processes — probing the first)");
+
+ Process target = procs[0];
+ Console.WriteLine(
+ $"pid={target.Id} name={target.ProcessName} start={target.StartTime:HH:mm:ss} " +
+ $"mainmodule={target.MainModule?.FileName ?? ""}");
+
+ IntPtr moduleBase;
+ try { moduleBase = target.MainModule!.BaseAddress; }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(
+ $"failed to read main module (maybe bitness mismatch or access denied): {ex.Message}\n" +
+ " if you're running this probe from an x64 process against an x86 target,\n" +
+ " make sure RetailTimeProbe is built with x86.");
+ return 3;
+ }
+ Console.WriteLine(
+ $"module base=0x{moduleBase.ToInt64():X8} " +
+ $"(preferred 0x{PreferredImageBase:X8}; ASLR delta={moduleBase.ToInt64() - PreferredImageBase:+0;-0;0})");
+
+ // Open a handle with read + query perms.
+ IntPtr handle = OpenProcess(
+ PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
+ false, (uint)target.Id);
+ if (handle == IntPtr.Zero)
+ {
+ int err = Marshal.GetLastPInvokeError();
+ Console.Error.WriteLine(
+ $"OpenProcess failed (error {err}). If this is \"Access is denied\" (0x5), try running " +
+ "as the same user that owns the acclient.exe process, or as administrator.");
+ return 4;
+ }
+
+ try
+ {
+ // Compute the relocated address of DAT_008ee9c8.
+ long datAddress = moduleBase.ToInt64() + (long)(DatTimeOfDayPtr - PreferredImageBase);
+ Console.WriteLine($"DAT_008ee9c8 relocated to 0x{datAddress:X8}");
+
+ // Read the pointer at DAT_008ee9c8 → TimeOfDay*
+ uint timeOfDayPtr = ReadUInt32(handle, (IntPtr)datAddress);
+ Console.WriteLine($"TimeOfDay* = 0x{timeOfDayPtr:X8}");
+
+ if (timeOfDayPtr == 0)
+ {
+ Console.Error.WriteLine(
+ "TimeOfDay pointer is NULL. The retail client hasn't entered the world yet — " +
+ "log in to a character fully, then re-run the probe.");
+ return 5;
+ }
+
+ // Dump the TimeOfDay struct.
+ double epochBase = ReadDouble(handle, (IntPtr)(timeOfDayPtr + Off_EpochBase));
+ int baseYear = (int)ReadUInt32(handle, (IntPtr)(timeOfDayPtr + Off_BaseYear));
+ float secsPerDayF = ReadSingle(handle, (IntPtr)(timeOfDayPtr + Off_SecondsPerDay));
+ int secsPerDayI = (int)ReadUInt32(handle, (IntPtr)(timeOfDayPtr + Off_SecondsPerDayI));
+ double secsPerYear = ReadDouble(handle, (IntPtr)(timeOfDayPtr + Off_SecondsPerYear));
+ float dayFraction = ReadSingle(handle, (IntPtr)(timeOfDayPtr + Off_DayFraction));
+ double curDayStart = ReadDouble(handle, (IntPtr)(timeOfDayPtr + Off_CurDayStart));
+ double curDayEnd = ReadDouble(handle, (IntPtr)(timeOfDayPtr + Off_CurDayEnd));
+ int year = (int)ReadUInt32(handle, (IntPtr)(timeOfDayPtr + Off_Year));
+ int dayOfYear = (int)ReadUInt32(handle, (IntPtr)(timeOfDayPtr + Off_DayOfYear));
+ int seasonIndex = (int)ReadUInt32(handle, (IntPtr)(timeOfDayPtr + Off_SeasonIndex));
+
+ Console.WriteLine();
+ Console.WriteLine("=========== TimeOfDay (retail acclient.exe, live) ===========");
+ Console.WriteLine($" EpochBase (+0x00 double) = {epochBase:F6}");
+ Console.WriteLine($" BaseYear (+0x08 int) = {baseYear}");
+ Console.WriteLine($" SecondsPerDay (+0x0C float) = {secsPerDayF}");
+ Console.WriteLine($" SecondsPerDay (+0x10 int) = {secsPerDayI} <-- LCG input");
+ Console.WriteLine($" SecondsPerYear (+0x40 double) = {secsPerYear:F6}");
+ Console.WriteLine($" DayFraction (+0x48 float) = {dayFraction:F6} <-- authoritative hour-of-day");
+ Console.WriteLine($" CurDayStart (+0x50 double) = {curDayStart:F6}");
+ Console.WriteLine($" CurDayEnd (+0x58 double) = {curDayEnd:F6}");
+ Console.WriteLine($" Year (+0x64 int) = {year} <-- LCG seed year");
+ Console.WriteLine($" DayOfYear (+0x68 int) = {dayOfYear} <-- LCG seed day");
+ Console.WriteLine($" SeasonIndex (+0x6C int) = {seasonIndex}");
+ Console.WriteLine("=============================================================");
+
+ // Derive "the tick retail is treating as current".
+ // Retail stores CurDayStart/CurDayEnd as absolute tick bounds of the current day.
+ // Given DayFraction in [0, 1), the absolute tick retail thinks we're at =
+ // CurDayStart + DayFraction * (CurDayEnd - CurDayStart).
+ double inferredTick = curDayStart + dayFraction * (curDayEnd - curDayStart);
+ Console.WriteLine($" inferred retail tick = {inferredTick:F3}");
+ Console.WriteLine($" retail LCG seed = year*secsPerDay + dayOfYear = {year}*{secsPerDayI}+{dayOfYear} = {(long)year * secsPerDayI + dayOfYear}");
+ return 0;
+ }
+ finally
+ {
+ CloseHandle(handle);
+ }
+ }
+
+ private static uint ReadUInt32(IntPtr handle, IntPtr address)
+ {
+ byte[] buf = new byte[4];
+ if (!ReadProcessMemory(handle, address, buf, buf.Length, out _))
+ throw new InvalidOperationException(
+ $"ReadProcessMemory(0x{address.ToInt64():X8}, 4) failed " +
+ $"(Win32 error {Marshal.GetLastPInvokeError()})");
+ return BitConverter.ToUInt32(buf, 0);
+ }
+
+ private static float ReadSingle(IntPtr handle, IntPtr address)
+ {
+ byte[] buf = new byte[4];
+ if (!ReadProcessMemory(handle, address, buf, buf.Length, out _))
+ throw new InvalidOperationException(
+ $"ReadProcessMemory(0x{address.ToInt64():X8}, 4) failed " +
+ $"(Win32 error {Marshal.GetLastPInvokeError()})");
+ return BitConverter.ToSingle(buf, 0);
+ }
+
+ private static double ReadDouble(IntPtr handle, IntPtr address)
+ {
+ byte[] buf = new byte[8];
+ if (!ReadProcessMemory(handle, address, buf, buf.Length, out _))
+ throw new InvalidOperationException(
+ $"ReadProcessMemory(0x{address.ToInt64():X8}, 8) failed " +
+ $"(Win32 error {Marshal.GetLastPInvokeError()})");
+ return BitConverter.ToDouble(buf, 0);
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ReadProcessMemory(
+ IntPtr processHandle,
+ IntPtr baseAddress,
+ byte[] buffer,
+ int size,
+ out int bytesRead);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(IntPtr handle);
+}
diff --git a/tools/RetailTimeProbe/RetailTimeProbe.csproj b/tools/RetailTimeProbe/RetailTimeProbe.csproj
new file mode 100644
index 0000000..55666b2
--- /dev/null
+++ b/tools/RetailTimeProbe/RetailTimeProbe.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ RetailTimeProbe
+
+ x86
+
+
+