sky(phase-3g): fix LCG multiplier — 360 (DaysPerYear), not 7620

Ran a live memory probe against retail acclient.exe (new tool:
tools/RetailTimeProbe/) to read the TimeOfDay struct at
DAT_008ee9c8 and compare against our computed values. The decompile
agent's identification of TimeOfDay+0x10 as "SecondsPerDay (int
copy)" turned out to be WRONG — the live value is **360**, which is
GameTime.DaysPerYear.

The retail FUN_00501990 LCG seed is:
  seed = Year × (*+0x10) + DayOfYear
       = Year × DaysPerYear + DayOfYear
       = flat "total days since epoch" day-index

Our previous Phase 3c port passed 7620 (DayLength in ticks) as the
multiplier, producing seed=883,967 against retail's seed=41,807 —
completely different LCG outputs, completely different DayGroup
picks. That's why the user's retail kept showing stormy/rainy while
acdream showed sunny/clear (or vice versa) even after Phases 3c.1
and 3f aligned Year and DayOfYear.

Also confirmed by the probe:
  - EpochBase / ZeroTimeOfYear = 3600   ✓ Phase 3f already correct
  - BaseYear / ZeroYear = 10            ✓ DerethDateTime.ZeroYear
  - Year=116, DayOfYear=47              ✓ our AbsoluteYear / DayOfYear
  - SecondsPerDay float (+0x0C) = 7620  ✓ DayTicks
  - SecondsPerYear = 2,743,200          ✓ YearTicks

One "finding that's not a fix": retail's +0x48 DayFraction is a
sub-period fraction (fraction through current day/night window)
NOT a full-day fraction. CurDayEnd - CurDayStart = 2857.5 = 0.375
of a day = 6 Dereth hours = night duration. Not relevant for our
keyframe bracket interpolation, which correctly uses a full-day
0..1 scale matching the SkyTime.Begin values. Documented in the
probe research doc so future work doesn't trip on it.

Changes:
- tools/RetailTimeProbe/ — new P/Invoke tool. Forced x86 target to
  match retail's bitness so hardcoded DAT_xxxxxxxx addresses are
  pointer-width-correct. Handles ASLR relocation via
  Process.MainModule.BaseAddress.
- src/AcDream.App/Rendering/GameWindow.cs: RefreshSkyForCurrentDay
  passes 360 (DaysInAMonth × MonthsInAYear) not 7620.
- src/AcDream.Core/World/SkyDescLoader.cs: ActiveDayGroup(ticks)
  and DefaultDayGroup same.
- docs/research/2026-04-23-retail-memory-probe.md — full probe
  results + decompile-agent correction.
- AcDream.slnx — add tools/ folder.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-24 10:17:38 +02:00
parent cd8a37a9c8
commit 1e1d3875f7
6 changed files with 374 additions and 12 deletions

View file

@ -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 ?? "<null>"}");
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 <PlatformTarget>x86</PlatformTarget>.");
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);
}