acdream/docs/research/2026-04-23-retail-memory-probe.md
Erik 1e1d3875f7 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>
2026-04-24 10:17:38 +02:00

4.5 KiB
Raw Blame History

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:

// 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.