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

117 lines
4.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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