acdream/tools/RetailTimeProbe/Program.cs
Erik 1d54880213 sky(phase-8): retail-faithful night sky + README refresh
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:

* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
  (0..400m at midnight, up to 2400m during day) is calibrated for
  terrain; sky meshes are authored at radii 1050-14271m which sits
  past FogEnd universally, causing every sky pixel to saturate to
  fogColor (dark navy). Stars, moon, dome texture all got
  obliterated. The horizon-glow trade-off is noted in the shader
  comment; research item to find retail's sky-specific fog range
  later.

* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
  vertex lighting saturates properly for bright keyframes. Retail's
  FUN_0059da60 non-luminous path writes rep.Luminosity into
  material.Emissive via the cache +0x3c slot; we were instead using
  it as a post-fragment multiply which could only dim, never brighten.
  Net effect: daytime clouds now render saturated white, dome dims
  correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
  and moon unchanged.

* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
  (DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
  pure ambient rather than getting an 8% sun floor.

New research / tooling (no runtime impact):

* docs/research/2026-04-24-lambert-brightness-split.md — retail's
  ambient-brightness formula pinned from PE .rdata read + live
  RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
  where scale constant 0x0079a1e8 = 0.2f exactly.

* docs/research/2026-04-23-lightning-real.md — research note on the
  dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
  explicit PES-triggered flash SkyObjects with 5ms time windows).

* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
  DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
  backwards).

* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
  sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
  and the 0x0079a1e8 scale-factor readout.

* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
  deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
  (A8R8G8B8 128x128 texture, 4% bright-pixel ratio).

* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
  histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
  clouds decoded with proper alpha" type questions.

README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.

All 742 tests green.
2026-04-24 20:34:36 +02:00

385 lines
20 KiB
C#
Raw 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.

// RetailTimeProbe — read the live retail acclient.exe process memory and
// dump its TimeOfDay struct + sky-lighting global block so we can compare
// against acdream's computed calendar / SkyKeyframe 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
//
// Sky-lighting globals (hunt-C §1, with 2026-04-24 label correction — the
// DirColor/AmbColor labeling in §1/§2/§5 was backwards; we use the
// corrected mapping):
//
// DAT_00842778 4 ARGB DirColor (directional / sun color)
// DAT_0084277c 4 ARGB AmbColor (ambient color)
// DAT_00842780 4 float AmbBright (ambient brightness scalar, also fog-start offset)
// DAT_00842784 4 ARGB FogSecondary
// DAT_00842788 4 ARGB FogPrimary
// DAT_00842950 12 3×flt sunDir XYZ (|v| = DirBright, NOT a unit vector)
// DAT_0084295c 4 float DirBright floor (MinWorldFog clamp)
// DAT_0079a1e8 4 float fog-distance scale factor (used in
// fogDist = |sunDir| * _DAT_0079a1e8 + AmbBright)
//
// Cached D3D light struct (written by FUN_00505f30:6058-6065 and
// FUN_004530e0:2083-2086 — see chunk_00500000.c / chunk_00450000.c):
//
// DAT_008682b0 12 3×flt light.Ambient pre-mul = fogTint * AmbBright
// (set inside FUN_004530e0 via FUN_00451a60(DirColor))
// DAT_008682bc 12 3×flt sunDir copy (fVar1/2/3 = X/Y/Z)
// DAT_008682c8 12 3×flt sunDir primary
// DAT_008682d4 4 uint reserved (written 0)
// DAT_008682d8 4 uint light type (3 = directional)
//
// 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
// Sky-lighting globals (static VAs in acclient.exe image).
private const uint SkyBlockBase = 0x00842778u; // DirColor / start of sky block
private const uint SkyBlockSize = 72u; // 0x00842778..0x008427c0 = 72 bytes
private const uint DAT_DirColor = 0x00842778u; // ARGB
private const uint DAT_AmbColor = 0x0084277cu; // ARGB
private const uint DAT_AmbBright = 0x00842780u; // float
private const uint DAT_FogSecondary = 0x00842784u; // ARGB
private const uint DAT_FogPrimary = 0x00842788u; // ARGB
private const uint DAT_SunDirX = 0x00842950u; // float
private const uint DAT_SunDirY = 0x00842954u; // float
private const uint DAT_SunDirZ = 0x00842958u; // float
private const uint DAT_DirBrightMin = 0x0084295cu; // float (MinWorldFog / DirBright floor)
private const uint DAT_FogScale = 0x0079a1e8u; // float (|sun|·scale factor)
// Cached D3D light struct.
private const uint DAT_LightAmbient = 0x008682b0u; // 3×float (light.Ambient pre-mul)
private const uint DAT_LightDirCopy = 0x008682bcu; // 3×float (sunDir copy)
private const uint DAT_LightDirMain = 0x008682c8u; // 3×float (sunDir primary)
private const uint DAT_LightReserved = 0x008682d4u; // uint
private const uint DAT_LightType = 0x008682d8u; // uint (3 = directional)
// 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).
// args[0] = process name OR "pid=NNNN" to target a specific pid.
string processName = "acclient";
int? requestedPid = null;
foreach (var a in args)
{
if (a.StartsWith("pid=", StringComparison.OrdinalIgnoreCase) &&
int.TryParse(a.Substring(4), out var pidParsed))
requestedPid = pidParsed;
else
processName = a;
}
Process target;
if (requestedPid is int pid)
{
try { target = Process.GetProcessById(pid); }
catch (Exception ex)
{
Console.Error.WriteLine($"no process with pid={pid}: {ex.Message}");
return 2;
}
Console.WriteLine($"RetailTimeProbe — targeting pid={pid} ({target.ProcessName})");
}
else
{
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 — use `pid=NNNN` to target a specific one)");
foreach (var p in procs)
{
Console.WriteLine($" pid={p.Id} start={p.StartTime:HH:mm:ss} title=\"{p.MainWindowTitle}\"");
}
Console.WriteLine("(probing the first)");
}
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}");
// ---------------- Sky-lighting block dump ----------------
DumpSkyBlock(handle, moduleBase);
return 0;
}
finally
{
CloseHandle(handle);
}
}
private static void DumpSkyBlock(IntPtr handle, IntPtr moduleBase)
{
// Helper to relocate a preferred-image-base VA onto the live module.
IntPtr Reloc(uint va) =>
(IntPtr)(moduleBase.ToInt64() + (long)(va - PreferredImageBase));
Console.WriteLine();
Console.WriteLine("=========== Sky globals (retail acclient.exe, live) ===========");
// Raw block dump for the contiguous 72-byte region at 0x00842778.
byte[] block = ReadBytes(handle, Reloc(SkyBlockBase), (int)SkyBlockSize);
Console.Write($" [raw {SkyBlockBase:X8}..{SkyBlockBase + SkyBlockSize - 1:X8}]");
for (int i = 0; i < block.Length; i++)
{
if ((i % 16) == 0) Console.Write($"\n +{i:X2}:");
Console.Write($" {block[i]:X2}");
}
Console.WriteLine();
Console.WriteLine();
// Primary field-by-field decode.
uint dirColor = ReadUInt32(handle, Reloc(DAT_DirColor));
uint ambColor = ReadUInt32(handle, Reloc(DAT_AmbColor));
float ambBright = ReadSingle(handle, Reloc(DAT_AmbBright));
uint fogSecondary = ReadUInt32(handle, Reloc(DAT_FogSecondary));
uint fogPrimary = ReadUInt32(handle, Reloc(DAT_FogPrimary));
float sunX = ReadSingle(handle, Reloc(DAT_SunDirX));
float sunY = ReadSingle(handle, Reloc(DAT_SunDirY));
float sunZ = ReadSingle(handle, Reloc(DAT_SunDirZ));
float dirBrightMin = ReadSingle(handle, Reloc(DAT_DirBrightMin));
float fogScale = ReadSingle(handle, Reloc(DAT_FogScale));
double dirBright = Math.Sqrt((double)sunX * sunX + (double)sunY * sunY + (double)sunZ * sunZ);
Console.WriteLine($" [retail sky] DirColor = {FormatArgb(dirColor)}");
Console.WriteLine($" [retail sky] AmbColor = {FormatArgb(ambColor)}");
Console.WriteLine($" [retail sky] AmbBright = {ambBright:F4} (@0x{DAT_AmbBright:X8})");
Console.WriteLine($" [retail sky] FogPrimary = {FormatArgb(fogPrimary)} (@0x{DAT_FogPrimary:X8})");
Console.WriteLine($" [retail sky] FogSecondary = {FormatArgb(fogSecondary)} (@0x{DAT_FogSecondary:X8})");
Console.WriteLine($" [retail sky] sunDir = ({sunX,7:F4},{sunY,7:F4},{sunZ,7:F4}) |dir|=DirBright={dirBright:F4}");
Console.WriteLine($" [retail sky] DirBrightMin = {dirBrightMin:F4} (@0x{DAT_DirBrightMin:X8}, MinWorldFog clamp)");
Console.WriteLine($" [retail sky] 0x0079a1e8 = {fogScale:F6} (fog |sun|-scale factor)");
// Derived fog distance (matches FUN_00505f30:6067-6069):
// fogDist = |sunDir| * _DAT_0079a1e8 + AmbBright
double fogDist = dirBright * fogScale + ambBright;
Console.WriteLine($" [retail sky] derived fogDist = |sun|*scale + AmbBright = {fogDist:F4}");
// ---- Cached D3D light struct at 0x008682b0..0x008682d8 (40 bytes) ----
Console.WriteLine();
Console.WriteLine(" -- cached D3D light struct (0x008682b0..0x008682d8) --");
float ambR = ReadSingle(handle, Reloc(DAT_LightAmbient + 0));
float ambG = ReadSingle(handle, Reloc(DAT_LightAmbient + 4));
float ambB = ReadSingle(handle, Reloc(DAT_LightAmbient + 8));
float dcX = ReadSingle(handle, Reloc(DAT_LightDirCopy + 0));
float dcY = ReadSingle(handle, Reloc(DAT_LightDirCopy + 4));
float dcZ = ReadSingle(handle, Reloc(DAT_LightDirCopy + 8));
float dmX = ReadSingle(handle, Reloc(DAT_LightDirMain + 0));
float dmY = ReadSingle(handle, Reloc(DAT_LightDirMain + 4));
float dmZ = ReadSingle(handle, Reloc(DAT_LightDirMain + 8));
uint reservedVal = ReadUInt32(handle, Reloc(DAT_LightReserved));
uint lightType = ReadUInt32(handle, Reloc(DAT_LightType));
Console.WriteLine($" [retail sky] cache.amb = ({ambR,7:F4},{ambG,7:F4},{ambB,7:F4}) (fogTint * AmbBright, effective light.Ambient)");
Console.WriteLine($" [retail sky] cache.dirCpy = ({dcX,7:F4},{dcY,7:F4},{dcZ,7:F4}) (008682bc/c0/c4, sunDir duplicate)");
Console.WriteLine($" [retail sky] cache.dirMain= ({dmX,7:F4},{dmY,7:F4},{dmZ,7:F4}) (008682c8/cc/d0, sunDir primary)");
Console.WriteLine($" [retail sky] cache.reserv = 0x{reservedVal:X8} (008682d4, written 0 by 00505f30:6065)");
Console.WriteLine($" [retail sky] cache.type = 0x{lightType:X8} (008682d8, 3 = directional)");
Console.WriteLine("=================================================================");
}
/// <summary>
/// Format a packed ARGB u32 as "#AARRGGBB (r=.. g=.. b=..)". Retail uses the
/// standard Windows D3DCOLOR layout verified against FUN_00451a60 (chunk
/// _00450000.c:615-622): float R = (u &gt;&gt; 16) &amp; 0xff, G = (u &gt;&gt; 8) &amp; 0xff,
/// B = u &amp; 0xff, each divided by 255.
/// </summary>
private static string FormatArgb(uint argb)
{
byte a = (byte)((argb >> 24) & 0xff);
byte r = (byte)((argb >> 16) & 0xff);
byte g = (byte)((argb >> 8) & 0xff);
byte b = (byte)( argb & 0xff);
return $"#{a:X2}{r:X2}{g:X2}{b:X2} (r={r / 255.0f:F3} g={g / 255.0f:F3} b={b / 255.0f:F3})";
}
private static byte[] ReadBytes(IntPtr handle, IntPtr address, int count)
{
byte[] buf = new byte[count];
if (!ReadProcessMemory(handle, address, buf, buf.Length, out _))
throw new InvalidOperationException(
$"ReadProcessMemory(0x{address.ToInt64():X8}, {count}) failed " +
$"(Win32 error {Marshal.GetLastPInvokeError()})");
return buf;
}
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);
}