From 1e1d3875f7bff2e171f0016f1750b871a59c8f39 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 10:17:38 +0200 Subject: [PATCH] =?UTF-8?q?sky(phase-3g):=20fix=20LCG=20multiplier=20?= =?UTF-8?q?=E2=80=94=20360=20(DaysPerYear),=20not=207620?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AcDream.slnx | 3 + .../2026-04-23-retail-memory-probe.md | 117 ++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 27 ++- src/AcDream.Core/World/SkyDescLoader.cs | 10 +- tools/RetailTimeProbe/Program.cs | 211 ++++++++++++++++++ tools/RetailTimeProbe/RetailTimeProbe.csproj | 18 ++ 6 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 docs/research/2026-04-23-retail-memory-probe.md create mode 100644 tools/RetailTimeProbe/Program.cs create mode 100644 tools/RetailTimeProbe/RetailTimeProbe.csproj 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 + + +