From 991fb9a222440d08a2074a2845135298fc2edaa5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 22:19:28 +0200 Subject: [PATCH 01/18] tools(probe): add StarsProbe to dump every SkyObject's geometry + UVs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling of WeatherEnumerator/PesChainAudit. Walks every DayGroup in the Dereth Region (0x13000000), prints each SkyObject (Properties bits, TexVelocity, BeginTime/EndTime, gfx/pes ids), then dumps the underlying GfxObj's vertices, UV ranges, and surfaces. The crucial diagnostic is the per-GfxObj "UV range outside [0,1]" flag. Built for Bug B (sky-investigation-handoff §"Bug B"): stars rendering as a square in one corner of the sky. Smoking gun on first run: GfxObj 0x010015EF (OI-1 in every DayGroup, TexVelocity = 0) has UVs in [0.398, 4.602] — meaning the texture tiles ~4× across each face, but SkyRenderer's "CLAMP_TO_EDGE unless TexVelocity != 0" heuristic forces clamp on it, so the whole inner dome samples edge texels except the tiny region where UVs happen to fall in [0,1]. That tiny region is the "square in one corner" the user observed. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/StarsProbe/Program.cs | 153 +++++++++++++++++++++++++++++ tools/StarsProbe/StarsProbe.csproj | 15 +++ 2 files changed, 168 insertions(+) create mode 100644 tools/StarsProbe/Program.cs create mode 100644 tools/StarsProbe/StarsProbe.csproj diff --git a/tools/StarsProbe/Program.cs b/tools/StarsProbe/Program.cs new file mode 100644 index 0000000..2902a0a --- /dev/null +++ b/tools/StarsProbe/Program.cs @@ -0,0 +1,153 @@ +// StarsProbe — Bug B (sky-investigation-handoff §"Bug B"): dump every +// SkyObject's geometry + UVs to identify the star object and verify +// whether its UV range matches what GL_CLAMP_TO_EDGE supports. +// +// Sibling of WeatherEnumerator/SetupProbe/etc under tools/. Walks all +// DayGroups in the Dereth Region (0x13000000), prints every SkyObject +// (Properties bits, TexVelocity, BeginTime/EndTime), then dumps the +// underlying GfxObj's vertices, UV ranges, and surfaces. The crucial +// diagnostic is the per-GfxObj "UV range outside [0,1]" flag — when +// that's set on a static (non-scrolling) sky object, our SkyRenderer's +// CLAMP_TO_EDGE heuristic mis-samples and the texture appears as a +// "square in one corner" of the geometry. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + +Console.WriteLine($"datDir = {datDir}"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +if (!dats.TryGet(0x13000000u, out var region) || region is null) +{ + Console.Error.WriteLine("ERROR: Cannot read Region 0x13000000"); + return 1; +} +var dayGroups = region.SkyInfo?.DayGroups; +if (dayGroups is null) { Console.Error.WriteLine("No DayGroups"); return 1; } + +Console.WriteLine($"Region loaded. {dayGroups.Count} DayGroups."); +Console.WriteLine(); + +var seenGfx = new HashSet(); + +for (int dg = 0; dg < dayGroups.Count; dg++) +{ + var group = dayGroups[dg]; + string name = group.DayName?.Value ?? "(null)"; + Console.WriteLine($"=== DayGroup[{dg}] \"{name}\" Chance={group.ChanceOfOccur:F3} SkyObjects={group.SkyObjects.Count} ==="); + + for (int oi = 0; oi < group.SkyObjects.Count; oi++) + { + var so = group.SkyObjects[oi]; + uint gfx = (uint)so.DefaultGfxObjectId; + uint pes = (uint)so.DefaultPesObjectId; + bool wrapsMidnight = so.BeginTime > so.EndTime; + Console.WriteLine( + $" OI={oi,2} Begin={so.BeginTime:F3} End={so.EndTime:F3} {(wrapsMidnight ? "(wraps midnight — night candidate)" : "")}"); + Console.WriteLine( + $" BeginAng={so.BeginAngle:F1} EndAng={so.EndAngle:F1} TexVel=({so.TexVelocityX:F4},{so.TexVelocityY:F4})"); + Console.WriteLine( + $" Gfx=0x{gfx:X8} Pes=0x{pes:X8} Props=0x{so.Properties:X8} (bin={Convert.ToString(so.Properties, 2).PadLeft(8, '0')})"); + if (gfx != 0) seenGfx.Add(gfx); + } + + // SkyTime replaces (some sky objects swap GfxObj at specific times). + foreach (var st in group.SkyTime) + foreach (var r in st.SkyObjReplace) + { + uint gfx = (uint)r.GfxObjId; + if (gfx != 0 && seenGfx.Add(gfx)) + Console.WriteLine($" REPLACE SkyTime.Begin={st.Begin:F3} OI={r.ObjectIndex} Gfx=0x{gfx:X8}"); + } + + Console.WriteLine(); +} + +Console.WriteLine($"Unique GfxObjIds across all DayGroups: {seenGfx.Count}"); +Console.WriteLine(); +Console.WriteLine("=== Per-GfxObj geometry + UV summary ==="); + +foreach (uint gid in seenGfx.OrderBy(x => x)) + DumpGeoAndUVs(dats, gid); + +return 0; + +static void DumpGeoAndUVs(DatCollection dats, uint gid) +{ + if (gid >= 0x02000000u) + { + if (!dats.TryGet(gid, out var setup) || setup is null) + { Console.WriteLine($"0x{gid:X8} | (Setup not found)"); return; } + Console.WriteLine($"0x{gid:X8} | Setup with {setup.Parts.Count} part(s):"); + foreach (var p in setup.Parts) DumpGfx(dats, (uint)p, indent: " "); + return; + } + DumpGfx(dats, gid, indent: ""); +} + +static void DumpGfx(DatCollection dats, uint gid, string indent) +{ + if (!dats.TryGet(gid, out var go) || go is null) + { Console.WriteLine($"{indent}0x{gid:X8} | (GfxObj not found)"); return; } + var verts = go.VertexArray?.Vertices; + if (verts is null || verts.Count == 0) + { Console.WriteLine($"{indent}0x{gid:X8} | 0 verts"); return; } + + Vector3 mn = new(float.MaxValue), mx = new(float.MinValue); + float uMin = float.MaxValue, uMax = float.MinValue; + float vMin = float.MaxValue, vMax = float.MinValue; + int uvLayerMax = 0; + foreach (var kv in verts) + { + var v = kv.Value; + var p = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); + mn = Vector3.Min(mn, p); mx = Vector3.Max(mx, p); + if (v.UVs is { Count: > 0 } uvs) + { + uvLayerMax = Math.Max(uvLayerMax, uvs.Count); + foreach (var uv in uvs) + { + uMin = Math.Min(uMin, uv.U); uMax = Math.Max(uMax, uv.U); + vMin = Math.Min(vMin, uv.V); vMax = Math.Max(vMax, uv.V); + } + } + } + var size = mx - mn; + int polyCount = go.Polygons?.Count ?? 0; + int surfCount = go.Surfaces?.Count ?? 0; + bool uvOutsideUnit = uvLayerMax > 0 + && (uMin < 0f || uMax > 1f || vMin < 0f || vMax > 1f); + + Console.WriteLine($"{indent}0x{gid:X8} | verts={verts.Count} polys={polyCount} surfaces={surfCount} uvLayers={uvLayerMax}"); + Console.WriteLine($"{indent} bbox min=({mn.X:F2},{mn.Y:F2},{mn.Z:F2}) max=({mx.X:F2},{mx.Y:F2},{mx.Z:F2}) size=({size.X:F2},{size.Y:F2},{size.Z:F2})"); + if (uvLayerMax > 0) + Console.WriteLine($"{indent} UV range U=[{uMin:F3}, {uMax:F3}] V=[{vMin:F3}, {vMax:F3}] {(uvOutsideUnit ? "*** OUTSIDE [0,1] — needs REPEAT wrap ***" : "in [0,1]")}"); + else + Console.WriteLine($"{indent} UV range (no UVs on any vertex)"); + + if (go.Surfaces is { Count: > 0 }) + for (int i = 0; i < go.Surfaces.Count; i++) + Console.WriteLine($"{indent} Surface[{i}]=0x{(uint)go.Surfaces[i]:X8}"); + + // Verbose per-vertex dump (capped at 64 verts to keep output bounded). + int dumpN = Math.Min(verts.Count, 64); + int shown = 0; + foreach (var kv in verts) + { + if (shown++ >= dumpN) { Console.WriteLine($"{indent} ...({verts.Count - dumpN} more verts)"); break; } + var v = kv.Value; + string uvStr = v.UVs is null || v.UVs.Count == 0 ? "(none)" : string.Join(" ", v.UVs.Select(u => $"({u.U:F3},{u.V:F3})")); + Console.WriteLine($"{indent} v[{kv.Key,3}] pos=({v.Origin.X,7:F2},{v.Origin.Y,7:F2},{v.Origin.Z,7:F2}) uv={uvStr}"); + } +} diff --git a/tools/StarsProbe/StarsProbe.csproj b/tools/StarsProbe/StarsProbe.csproj new file mode 100644 index 0000000..a70fd01 --- /dev/null +++ b/tools/StarsProbe/StarsProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + StarsProbe + + + + + + + From 7b88fde52d2ba47110b49b42def436fedfd8f7fa Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 22:55:24 +0200 Subject: [PATCH 02/18] =?UTF-8?q?fix(sky):=20drive=20wrap=20mode=20from=20?= =?UTF-8?q?mesh=20UV=20range=20=E2=80=94=20fixes=20Bug=20B=20(stars-as-squ?= =?UTF-8?q?are)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug B in docs/research/2026-04-26-sky-investigation-handoff.md: stars rendered as a small square in one corner of the sky instead of stretching across the dome. Root cause: the wrap-mode heuristic at SkyRenderer.cs:234-237 was "GL_CLAMP_TO_EDGE unless TexVelocity != 0". That heuristic was tuned to fix a separate symptom (the outer dome 0x010015EE/F0/F1/F2 shows wall-seam bleed under GL_REPEAT because of bilinear-filter sampling at texel boundaries). But it misclassified any *static* sky object whose mesh UVs are deliberately authored outside [0,1] to tile the texture across the geometry. The smoking gun: GfxObj 0x010015EF is OI-1 in EVERY DayGroup (always loaded), has TexVelocity = 0 (no scrolling), and authors UVs in [0.398, 4.602] (texture tiles ~4× across each face). Under CLAMP_TO_EDGE the bulk of the inner dome sampled the texture's edge texels; only the small region where UVs happened to fall in [0,1] showed actual texture content. Hence "a square in one corner". Fix: * GfxObjMesh.Build() now scans the resulting per-vertex UVs and sets GfxObjSubMesh.NeedsUvRepeat true when any component lies outside [0,1]. Mesh-time scan, not draw-time guess. * SubMeshGpu carries the flag through to draw time. * SkyRenderer uses `sub.NeedsUvRepeat || obj.TexVelocity != 0` to decide REPEAT vs CLAMP_TO_EDGE. The dome (UVs in [0,1]) keeps CLAMP — no seam regression. The inner star/sky layer 0x010015EF (UVs outside [0,1]) gets REPEAT — texture tiles across the dome. Cloud meshes (UVs outside [0,1] AND non-zero TexVelocity) keep REPEAT via either branch. Probe-driven: tools/StarsProbe (committed in 991fb9a) dumps every SkyObject's geometry + UVs and flags meshes whose UV range exceeds [0,1]. Run `dotnet run --project tools/StarsProbe -c Release` to re-derive. Verified visually by user against the live ACE server in Holtburg — stars now stretch across the night sky instead of appearing as a square in one corner. Build green, dotnet test 1222 pass. Note: this is functionally retail-equivalent for the reported bug but not the exact retail mechanism. Retail's GameSky::Draw at 0x00506ff0 relies on D3D's global default D3DTADDRESS_WRAP (i.e. REPEAT everywhere). True retail-faithfulness would require investigating why our pipeline shows seams on the dome under REPEAT (likely a bilinear filter / non-seamless texture detail). The data-driven approach taken here preserves working dome behavior while fixing the broken star behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 47 +++++++++++++++----- src/AcDream.Core/Meshing/GfxObjMesh.cs | 18 ++++++++ src/AcDream.Core/Meshing/GfxObjSubMesh.cs | 14 ++++++ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 48ac917..6fefea3 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -222,17 +222,31 @@ public sealed unsafe class SkyRenderer : IDisposable _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); - // Sky meshes need per-object wrap mode. The dome is 5 flat - // walls meeting at edges — under GL_REPEAT any UV drift - // past [0,1] wraps to the opposite edge of the texture, - // drawing a visible line along each wall seam. Static - // sky GfxObjs (dome, sun, moon, stars) should use - // CLAMP_TO_EDGE to avoid that bleed. Scrolling cloud - // layers (TexVelocity != 0) still need REPEAT so the - // animated UV offset wraps correctly. Detection heuristic: - // non-zero TexVelocity on either axis ⇒ scrolling layer. - bool scrolling = obj.TexVelocityX != 0f || obj.TexVelocityY != 0f; - int wrapMode = scrolling + // Sky meshes need per-object wrap mode driven by the + // mesh's authored UV range, not by TexVelocity: + // * The outer dome (0x010015EE/F0/F1/F2) authors UVs + // strictly in [0,1]. Under GL_REPEAT the bilinear + // filter at wall-seam edges would average a texel + // near the right edge with one near the left edge of + // the texture, drawing a visible "bleed line" along + // every dome seam. CLAMP_TO_EDGE avoids that. + // * The inner sky/star layer (0x010015EF) and the + // cloud meshes (0x010015B6, 0x01004C36 etc) author + // UVs that deliberately exceed [0,1] (~0.4..4.6) so + // the texture tiles across the geometry. CLAMP_TO_EDGE + // would clamp ~99% of the surface to a single edge + // texel, leaving only a small "square" where UVs + // happen to fall in [0,1] (Bug B in + // docs/research/2026-04-26-sky-investigation-handoff.md). + // The mesh builder pre-computes NeedsUvRepeat from the + // actual UV range so the right answer is data-driven. + // Scrolling clouds are also forced to REPEAT (the running + // UV offset can drift outside [0,1] regardless of authored + // range, and they'd show their own seam bleed otherwise). + bool needsRepeat = sub.NeedsUvRepeat + || obj.TexVelocityX != 0f + || obj.TexVelocityY != 0f; + int wrapMode = needsRepeat ? (int)TextureWrapMode.Repeat : (int)TextureWrapMode.ClampToEdge; _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode); @@ -423,6 +437,7 @@ public sealed unsafe class SkyRenderer : IDisposable SurfaceId = sm.SurfaceId, IsAdditive = isAdditive, SurfLuminosity = sm.Luminosity, + NeedsUvRepeat = sm.NeedsUvRepeat, }; } @@ -462,5 +477,15 @@ public sealed unsafe class SkyRenderer : IDisposable /// docs/research/2026-04-23-sky-retail-verbatim.md §6. /// public float SurfLuminosity; + /// + /// True when the source mesh's authored UVs exceed [0,1] (e.g. + /// the inner sky/star layer 0x010015EF and the cloud meshes — + /// they tile their texture across the geometry). The renderer + /// must use GL_REPEAT for these or only the small region + /// where UVs fall in [0,1] samples the actual texture; the rest + /// clamps to the edge texel ("square in one corner" symptom). + /// Computed once at mesh build from the actual UV range. + /// + public bool NeedsUvRepeat; } } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index f468dae..240e4db 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -210,6 +210,23 @@ public static class GfxObjMesh } } + // Authored UV range determines the wrap-mode choice in the + // sky pass. A mesh whose UVs are strictly in [0,1] (e.g. the + // outer dome 0x010015EE) wants CLAMP_TO_EDGE to avoid + // bilinear-filter bleed at the wall-seam edges; a mesh whose + // UVs deliberately tile (e.g. 0x010015EF, ~0.4..4.6) wants + // REPEAT so the texture tiles across the geometry. We make + // the call data-driven here rather than guessing from + // TexVelocity at draw time. See + // docs/research/2026-04-26-sky-investigation-handoff.md (Bug B). + bool needsUvRepeat = false; + foreach (var v in kvp.Value.Vertices) + { + if (v.TexCoord.X < 0f || v.TexCoord.X > 1f + || v.TexCoord.Y < 0f || v.TexCoord.Y > 1f) + { needsUvRepeat = true; break; } + } + result.Add(new GfxObjSubMesh( SurfaceId: surfaceId, Vertices: kvp.Value.Vertices.ToArray(), @@ -217,6 +234,7 @@ public static class GfxObjMesh { Translucency = translucency, Luminosity = luminosity, + NeedsUvRepeat = needsUvRepeat, }); } return result; diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index d6a9cd0..488b0dd 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -39,4 +39,18 @@ public sealed record GfxObjSubMesh( /// normal lighting path without change. /// public float Luminosity { get; init; } = 0f; + + /// + /// True when at least one vertex's UV component lies outside the + /// [0, 1] range, meaning the mesh was authored to have its + /// texture tile across the geometry (i.e. it expects + /// GL_REPEAT/D3DTADDRESS_WRAP). The sky renderer reads + /// this to decide between GL_REPEAT (this flag set, or any + /// scrolling layer) and GL_CLAMP_TO_EDGE (all UVs strictly + /// in [0,1]), which avoids wall-seam bleed on the dome + /// (UVs in [0,1]) while still tiling the inner star/cloud + /// layers (UVs in [~0.4, ~4.6]) correctly. + /// Defaults to false so non-sky consumers get the previous behavior. + /// + public bool NeedsUvRepeat { get; init; } = false; } From 95675978142dc0c991c5362ff3cc56ed44709e17 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 22:57:25 +0200 Subject: [PATCH 03/18] docs(issues): close #26 (stars-as-square) + open #27 (clouds), #28 (aurora) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug B from the sky-investigation handoff is fixed in 7b88fde — file the Recently closed entry. Two new observations from the visual-verify session that the user flagged when they could finally see the sky clearly: cloud coverage looks faint vs retail, aurora ("northern lights") not rendered at all. Both LOW severity (aesthetic feature parity, not gameplay-breaking) and out of scope for the current worktree, which is heading to Bug A (foreground rain, #1) next per docs/research/2026-04-26-sky-investigation-handoff.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 79ece7b..1dad84c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -292,10 +292,60 @@ missing is the plugin-API surface. --- +## #27 — Cloud meshes appear missing or faint compared to retail + +**Status:** OPEN +**Severity:** LOW (aesthetic feature-parity — doesn't break gameplay) +**Filed:** 2026-04-26 +**Component:** sky / clouds + +**Description:** After fixing Bug B (#26 — stars-as-square), the user observed during visual verification that cloud coverage in the sky doesn't match retail. Cloud meshes are authored in the dat (e.g. `0x010015B6`, `0x01004C35`-`0x01004C38`, `0x01004C36` etc) and `tools/StarsProbe` confirms they're loaded into the SkyObject lists with non-zero TexVelocity (so they get GL_REPEAT correctly under the post-#26 code path). They're not strictly missing — they're rendered — but their visual presence falls short of retail. + +**Root cause / status:** Unknown. Hypotheses: (a) cloud surfaces' alpha/blend mode is too subtle (cloud surface flags or shader path under-emphasise the texture); (b) cloud meshes positioned/scaled wrong relative to the dome so they're inside the dome and occluded; (c) DayGroup keyframe interpolation suppresses cloud transparency at certain times of day; (d) some cloud SkyObjects we should be rendering are filtered out by a Properties bit we mis-handle (Props=0x02 might mean something more than "cloud — render it"); (e) retail uses an additive cloud blend that our Translucency classifier doesn't apply. + +**Files:** +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky pass; check per-cloud blend / luminosity / transparency. +- `src/AcDream.Core/World/SkyDescLoader.cs` — Properties bit decoding. +- `src/AcDream.App/Rendering/Shaders/sky.frag` — cloud transparency math. +- `tools/StarsProbe/` — already dumps cloud GfxObj UVs + bounds; extend to dump per-DayGroup cloud surface flags. + +**Research:** None yet. `tools/StarsProbe` output already enumerates which DayGroups reference which cloud meshes — start there. + +**Acceptance:** Side-by-side launch of acdream and a retail client at the same `ACDREAM_DAY_GROUP` shows visually-comparable cloud coverage in the sky. + +--- + +## #28 — Aurora ("northern lights") effect not rendered + +**Status:** OPEN +**Severity:** LOW (aesthetic feature-parity) +**Filed:** 2026-04-26 +**Component:** sky / vfx + +**Description:** Retail occasionally renders an aurora-borealis-style "northern lights" effect in the sky during certain weather/time conditions. acdream renders no aurora at all. + +**Root cause / status:** Unknown — the mechanism hasn't been investigated. Aurora is NOT in the visible SkyObject lists (`tools/StarsProbe` shows the standard 7-object Sunny/Clear/Cloudy DayGroup composition, with extra weather objects in Rainy groups). Hypotheses: (a) it's a special PES on a low-probability DayGroup not yet enumerated; (b) it's a separate shader path not driven by `Region.SkyInfo`; (c) it requires a specific weather/time combo we haven't triggered; (d) it's an entirely separate `EnvironChangeType` system we don't decode. + +**Files:** Unknown. + +**Research:** None yet. Probably needs a retail-decomp grep for "aurora", "northern", a 360° survey of DayGroup PES contents, and possibly a deepdive into the `LScape::weather_enabled` and `EnvironChange*` paths. + +**Acceptance:** When retail shows an aurora at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time. + +--- + --- # Recently closed +## #26 — [DONE 2026-04-26] Stars rendered as a square in one corner of the sky + +**Closed:** 2026-04-26 +**Commit:** `7b88fde fix(sky): drive wrap mode from mesh UV range — fixes Bug B (stars-as-square)` +**Resolution:** SkyRenderer's wrap-mode heuristic was `GL_CLAMP_TO_EDGE unless TexVelocity != 0`, which mis-classified the inner sky/star layer `0x010015EF` (UVs in `[0.398, 4.602]`, TexVel=0). Most of the dome sampled the texture's edge texels; only the small region where UVs fell in `[0,1]` showed actual texture content. Fixed by computing `NeedsUvRepeat` per submesh from the actual UV range during `GfxObjMesh.Build()` and driving the wrap-mode choice from that flag plus the existing scrolling check. Outer dome `0x010015EE/F0/F1/F2` (UVs strictly in `[0,1]`) keeps `CLAMP_TO_EDGE` so no seam regression. Probe `tools/StarsProbe/` (commit `991fb9a`) committed alongside as the diagnostic that found this. + +--- + ## #25 — [DONE 2026-04-26] Phase K.3 — Settings panel + click-to-rebind UI **Closed:** 2026-04-26 From 3e0da496e01a0f47ff9cd9756c0ddad9a83c5ee0 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 08:49:42 +0200 Subject: [PATCH 04/18] feat(sky): split SkyRenderer into pre-/post-scene passes + retail -120m weather Z offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug A (foreground rain) from docs/research/2026-04-26-sky-investigation-handoff.md: rain mesh was only visible at horizon, not in the air between camera and character. Two retail mechanisms ported here: 1. **Render order split.** Retail's `LScape::draw` at 0x00506330 calls `GameSky::Draw(0)` BEFORE the landblock DrawBlock loop and `GameSky::Draw(1)` AFTER — i.e. weather meshes render after scene geometry so additive rain streaks paint on top of terrain and entities. Acdream was rendering both passes pre-scene, so terrain immediately painted over the rain. Refactored `SkyRenderer.Render` into `RenderSky` (filter !IsWeather) and `RenderWeather` (filter IsWeather) sharing a private `RenderPass` core that takes a `weatherPass` bool. Partition is per-SkyObject by `Properties & 0x04` (the WEATHER_BIT, mirroring tools/WeatherEnumerator). Added `SkyObjectData.IsWeather` getter for the partition. `GameWindow.OnRender` now calls `RenderSky` before terrain/static-mesh/ particles (line ~4322) and `RenderWeather` after particles (line ~4368). 2. **Weather Z offset.** Retail `GameSky::UpdatePosition` at 0x00506dd0, lines 0x506e96..0x506e98: if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0)) int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f Weather objects (property bit 0x04 set, bit 0x08 unset) get their frame origin set to player_pos + (0, 0, -120m). The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local Z range 0.11..814.90 (815m tall, 113m radius). Without the offset the cylinder bottom sat just above the camera; with -120m the cylinder spans (camera-119.89)..(camera+694.90) so the camera is inside. `SkyRenderer.RenderPass` applies the -120m model translation when `weatherPass` is true (line ~253-254). 3. **Legacy camera-attached emitter gated.** `UpdateWeatherParticles` — the pre-research workaround that emitted camera-attached rain particles (broken alpha fade, fixed disk around camera) — is now gated behind `ACDREAM_FAKE_RAIN_PARTICLES=1`. Default off; the retail-faithful world-space mesh is the default path. User-verified: rain is now visible in foreground from many perspectives, but the cylinder's open-top rim is still visible when looking straight up. That rim issue is a separate brightness-excess bug filed for follow-up (Translucency float not plumbed to shader; surface.Translucency=0.5 ignored so streaks render at 2× retail intensity). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 36 ++++++++- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 82 +++++++++++++++++++- src/AcDream.Core/World/SkyDescLoader.cs | 17 ++++ 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b304d37..17ed773 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4232,7 +4232,17 @@ public sealed class GameWindow : IDisposable // Update the rain/snow particle emitters when the weather kind // changes. Keep the emitters fed by the ParticleSystem tick so // visuals stay alive frame-over-frame. - UpdateWeatherParticles(atmo); + // + // Bug A note (2026-04-26): retail rain is the world-space mesh + // 0x01004C42/0x01004C44 rendered in SkyRenderer.RenderWeather + // AFTER the scene — see docs/research/2026-04-26-sky-investigation-handoff.md. + // The camera-attached emitter path here is acdream's old + // pre-research workaround (broken alpha fade, fixed disk around + // camera). It's kept gated behind ACDREAM_FAKE_RAIN_PARTICLES=1 + // so the retail-faithful path can be A/B-tested against it + // without uninstalling the legacy code outright. + if (System.Environment.GetEnvironmentVariable("ACDREAM_FAKE_RAIN_PARTICLES") == "1") + UpdateWeatherParticles(atmo); // Phase E.3: advance live particle emitters AFTER animation tick // so emitters spawned by hooks fired this frame get integrated. @@ -4299,9 +4309,17 @@ public sealed class GameWindow : IDisposable // celestial meshes FIRST so the rest of the scene z-tests // on top of them (depth mask off, no depth writes). Skipped // when indoors; dungeons fully block sky visibility. + // + // Mirrors retail's LScape::draw at 0x00506330 which calls + // GameSky::Draw(0) (sky pass) BEFORE the landblock DrawBlock + // loop and GameSky::Draw(1) (weather pass) AFTER. The split + // matters because weather meshes (the 815m-tall rain + // cylinder 0x01004C42/0x01004C44) need to overlay terrain + // and entities to look volumetric — see the post-scene + // RenderWeather call further below. if (!cameraInsideCell) { - _skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction, + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf); } @@ -4337,6 +4355,20 @@ public sealed class GameWindow : IDisposable if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(_particleSystem, camera, camPos); + // Bug A fix (post-#26 worktree, 2026-04-26): weather sky + // meshes (Properties & 0x04, e.g. the 815m-tall rain + // cylinder 0x01004C42/0x01004C44) render AFTER the scene so + // the additive rain streaks overlay terrain and entities + // instead of being painted over by them. This is the second + // half of retail's LScape::draw split — GameSky::Draw(1) + // fires after the DrawBlock loop. Same indoor gate as the + // sky pass: weather is suppressed inside cells. + if (!cameraInsideCell) + { + _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf); + } + // Debug: draw collision shapes as wireframe cylinders around the // player so we can visually verify alignment with scenery meshes. if (_debugCollisionVisible && _debugLines is not null) diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 6fefea3..e6238cb 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -70,8 +70,18 @@ public sealed unsafe class SkyRenderer : IDisposable } /// - /// Draw the sky for this frame. Called FIRST in the render loop — - /// terrain / meshes / debug lines / overlay land on top. + /// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds — + /// every SkyObject with Properties & 0x04 == 0). + /// Called BEFORE the scene; terrain / meshes / debug lines / overlay + /// land on top via depth-test. + /// + /// + /// Mirrors the first half of retail's LScape::draw at + /// 0x00506330: that function calls GameSky::Draw(0) + /// (sky pass) before the landblock loop, then GameSky::Draw(1) + /// (weather pass) after. acdream splits the same way — see + /// for the post-scene companion. + /// /// /// /// Each submesh renders with retail's per-vertex lighting formula: @@ -91,12 +101,50 @@ public sealed unsafe class SkyRenderer : IDisposable /// field. /// /// - public void Render( + public void RenderSky( ICamera camera, Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, SkyKeyframe keyframe) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: false); + + /// + /// Draw the WEATHER sky objects (the foreground rain mesh + /// 0x01004C42/0x01004C44 on Rainy DayGroups, plus the + /// per-storm 5cm flash dummies — every SkyObject with + /// Properties & 0x04 != 0). Called AFTER the scene so the + /// rain meshes paint on top of terrain and entities — that's the + /// retail-faithful order from LScape::draw at + /// 0x00506330, where GameSky::Draw(1) fires after the + /// DrawBlock loop. With depth-test disabled and additive blend + /// (the rain Surface flag 0x080000C5 includes Additive), the + /// 815m-tall rain cylinder's bright streak texels add over the scene + /// — making rain appear in the air between camera and character + /// instead of only at the horizon. + /// + public void RenderWeather( + ICamera camera, + Vector3 cameraWorldPos, + float dayFraction, + DayGroupData? group, + SkyKeyframe keyframe) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: true); + + /// + /// Shared pass for and . + /// Sets up the same GL state for both (depth-test off, additive + + /// alpha-blend per submesh, camera-anchored translation) and iterates + /// only the SkyObjects matching the requested partition by + /// . + /// + private void RenderPass( + ICamera camera, + Vector3 cameraWorldPos, + float dayFraction, + DayGroupData? group, + SkyKeyframe keyframe, + bool weatherPass) { if (group is null || group.SkyObjects.Count == 0) return; @@ -149,6 +197,14 @@ public sealed unsafe class SkyRenderer : IDisposable for (int i = 0; i < group.SkyObjects.Count; i++) { var obj = group.SkyObjects[i]; + // Partition by weather flag — the caller chose either the + // pre-scene sky pass (non-weather) or the post-scene weather + // pass (weather only). Mirrors retail GameSky::Draw at + // 0x00506ff0 where arg2==0 iterates non-weather sky_obj + // entries (filtered by property bit 0x04 == 0 inside the + // loop) and arg2==1 draws after_sky_cell which only contains + // weather objects. + if (obj.IsWeather != weatherPass) continue; if (!obj.IsVisible(dayFraction)) continue; // Apply per-keyframe replace overrides. @@ -177,6 +233,26 @@ public sealed unsafe class SkyRenderer : IDisposable * Matrix4x4.CreateRotationZ(-headingRad) * Matrix4x4.CreateRotationY(-rotationRad); + // Retail weather Z-offset (GameSky::UpdatePosition at + // 0x00506dd0, decomp lines 0x506e96..0x506e98): + // + // if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0)) + // int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f + // + // Weather objects (property bit 0x04 set, bit 0x08 unset) + // have their frame origin set to player_pos + (0, 0, -120m). + // The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local + // Z range 0.11..814.90 (815m tall, 113m radius). Without the + // offset the cylinder bottom sits at z=0.11 ABOVE the camera + // (skyView translation is zeroed so model-origin == camera); + // looking horizontally shows nothing, looking up shows a + // distant cylinder. With -120m the cylinder spans z = + // (camera-119.89)..(camera+694.90) in view space — camera + // is inside, looking in any direction shows surrounding + // walls — the volumetric foreground-rain look retail has. + if (weatherPass) + model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f); + _shader.SetMatrix4("uModel", model); // UV scroll accumulates real-time × velocity. Wrap to [0, 1] diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index dda09ca..409d51e 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -36,6 +36,23 @@ public sealed class SkyObjectData public uint GfxObjId; public uint Properties; + /// + /// True when this SkyObject is flagged as weather (Properties bit + /// 0x04). Per the named retail decomp, + /// GameSky::CreateDeletePhysicsObjects at 0x005073c0 + /// passes Properties & 0x04 as arg5 of + /// GameSky::MakeObject (0x00506ee0) — when set, the + /// CPhysicsObj is added to after_sky_cell instead of + /// before_sky_cell, and GameSky::Draw(arg2=1) at + /// 0x00506ff0 draws that cell after the scene. acdream + /// uses this flag to split the sky pass: non-weather objects render + /// pre-scene (so terrain and entities z-test on top), weather meshes + /// (e.g. the 815m-tall rain cylinders 0x01004C42/0x01004C44) + /// render post-scene with depth-test off so they overlay foreground + /// geometry — matching retail's volumetric foreground-rain look. + /// + public bool IsWeather => (Properties & 0x04u) != 0u; + /// Object is visible at day-fraction /// by retail's begin/end semantics (r12 §2). Three cases: /// From b8e0857b87c0f3974908ff4ddaa5301222bebcf5 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 08:50:02 +0200 Subject: [PATCH 05/18] =?UTF-8?q?tools(probe):=20add=20RainMeshProbe=20?= =?UTF-8?q?=E2=80=94=20dumps=20rain=20mesh=20surface=20+=20polygons=20+=20?= =?UTF-8?q?build=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling of StarsProbe/WeatherEnumerator. Targets GfxObjs 0x01004C42 and 0x01004C44 (the two rain cylinders). For each: dumps the Surface raw record (Type bits, Translucency, Luminosity, Diffuse, ColorValue, OrigTextureId), every polygon's SidesType + Stippling + hasPos/hasNeg emission flags (mirroring GfxObjMesh.Build's neg-side rule), and the final GfxObjMesh.Build() submesh+index counts. Built per independent code-review §5: "Run one targeted probe... if one cylinder has more than 48 indices per side-equivalent, fix the duplicate-side/cull behavior together with the surface-opacity uniform." Probe results (rain_mesh_probe.log, not committed): Surface 0x080000C5: Type=0x10112 (Base1Image|Translucent|Alpha|Additive), Translucency=0.5000, Luminosity=0.1484, OrigTextureId=0x050016A6. Polygons: all 8 are Stippling=Positive, SidesType=None, hasNeg=False. Build output: 1 submesh, 24 verts, 48 indices = 8 walls × 2 tris × 3. → SINGLE-SIDED (the duplicate-side hypothesis is disconfirmed). Confirmed: the rim brightness excess is purely from Translucency not being plumbed (acdream draws rain at full alpha=1.0 instead of retail's 0.5). Bonus finding: surface.Luminosity=0.1484 is also ignored by the renderer's `effEmissive = (luminosity > 0) ? luminosity : sub.SurfLuminosity` fallback (the local `luminosity` defaults to 1.0 so the fallback never fires) — but that's keyed on the LUMINOUS flag bit (0x40), which the rain surface does NOT have. Filed as follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/RainMeshProbe/Program.cs | 157 +++++++++++++++++++++++ tools/RainMeshProbe/RainMeshProbe.csproj | 15 +++ 2 files changed, 172 insertions(+) create mode 100644 tools/RainMeshProbe/Program.cs create mode 100644 tools/RainMeshProbe/RainMeshProbe.csproj diff --git a/tools/RainMeshProbe/Program.cs b/tools/RainMeshProbe/Program.cs new file mode 100644 index 0000000..37475b1 --- /dev/null +++ b/tools/RainMeshProbe/Program.cs @@ -0,0 +1,157 @@ +// RainMeshProbe — independent code-review recommended probe (Bug A, post-#26). +// +// Per Report 1's §5: "Run one targeted probe for 0x01004C42/0x01004C44: print +// surface raw type/translucency, each polygon's SidesType/Stippling, and +// GfxObjMesh.Build() submesh/index counts. If one cylinder has more than 48 +// indices per side-equivalent, fix the duplicate-side/cull behavior together +// with the surface-opacity uniform." +// +// The cylinder has 8 wall quads. With fan-triangulation each quad → 2 tris → +// 6 indices, total 48 indices per side. If pos-only emission: 48. If pos+neg: +// 96. The threshold tells us whether double-sided drawing is happening. +using System; +using System.IO; +using System.Linq; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using AcDream.Core.Meshing; +using SysEnv = System.Environment; + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); +Console.WriteLine($"datDir = {datDir}"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +uint[] gfxIds = { 0x01004C42u, 0x01004C44u }; +foreach (uint gid in gfxIds) ProbeRain(dats, gid); +return 0; + +static void ProbeRain(DatCollection dats, uint gid) +{ + Console.WriteLine(); + Console.WriteLine($"================ GfxObj 0x{gid:X8} ================"); + if (!dats.TryGet(gid, out var go) || go is null) + { + Console.WriteLine(" (NOT FOUND)"); + return; + } + + Console.WriteLine($" Flags={go.Flags}"); + Console.WriteLine($" VertexArray.Vertices.Count={go.VertexArray?.Vertices.Count ?? 0}"); + Console.WriteLine($" Polygons.Count={go.Polygons?.Count ?? 0}"); + Console.WriteLine($" Surfaces.Count={go.Surfaces?.Count ?? 0}"); + Console.WriteLine($" PhysicsPolygons.Count={go.PhysicsPolygons?.Count ?? 0}"); + Console.WriteLine($" SortCenter=({go.SortCenter.X:F2},{go.SortCenter.Y:F2},{go.SortCenter.Z:F2})"); + + // ----- Per-Surface dump ----- + Console.WriteLine(); + Console.WriteLine(" --- Surfaces (raw dat record) ---"); + if (go.Surfaces is { Count: > 0 }) + { + for (int i = 0; i < go.Surfaces.Count; i++) + { + uint sid = (uint)go.Surfaces[i]; + Console.WriteLine($" Surface[{i}] = 0x{sid:X8}"); + if (!dats.TryGet(sid, out var surf) || surf is null) + { + Console.WriteLine(" (Surface NOT FOUND)"); + continue; + } + uint typeRaw = (uint)surf.Type; + Console.WriteLine($" Type=0x{typeRaw:X8} ({surf.Type})"); + Console.WriteLine($" decoded bits:"); + DumpFlagBits(typeRaw); + Console.WriteLine($" Translucency={surf.Translucency:F4} (1.0 - x = opacity = {1f - surf.Translucency:F4})"); + Console.WriteLine($" Luminosity={surf.Luminosity:F4}"); + Console.WriteLine($" Diffuse={surf.Diffuse:F4}"); + Console.WriteLine($" ColorValue=" + (surf.ColorValue is null ? "null" : + $"A:{surf.ColorValue.Alpha} R:{surf.ColorValue.Red} G:{surf.ColorValue.Green} B:{surf.ColorValue.Blue}")); + Console.WriteLine($" OrigTextureId=0x{(uint)surf.OrigTextureId:X8}"); + Console.WriteLine($" OrigPaletteId=0x{(uint)surf.OrigPaletteId:X8}"); + } + } + + // ----- Per-Polygon dump ----- + Console.WriteLine(); + Console.WriteLine(" --- Polygons (sides + stippling — checks Report 1 hypothesis) ---"); + if (go.Polygons is { Count: > 0 }) + { + int posCount = 0, negCount = 0; + foreach (var kv in go.Polygons) + { + var p = kv.Value; + // Mirror the GfxObjMesh.Build() emission rule (lines 71-91): + bool hasPos = !p.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = + p.Stippling.HasFlag(StipplingType.Negative) || + p.Stippling.HasFlag(StipplingType.Both) || + (!p.Stippling.HasFlag(StipplingType.NoNeg) && p.SidesType == CullMode.Clockwise); + if (hasPos) posCount++; + if (hasNeg) negCount++; + + Console.WriteLine( + $" Poly[{kv.Key,3}] VertexIds={p.VertexIds.Count} " + + $"PosSurface={p.PosSurface} NegSurface={p.NegSurface} " + + $"Stippling={p.Stippling} SidesType={p.SidesType} " + + $"hasPos={hasPos} hasNeg={hasNeg} " + + $"PosUVIdx={p.PosUVIndices.Count} NegUVIdx={p.NegUVIndices.Count}"); + } + Console.WriteLine($" Build emission summary: pos-side polys={posCount} neg-side polys={negCount}"); + } + + // ----- GfxObjMesh.Build() output ----- + Console.WriteLine(); + Console.WriteLine(" --- GfxObjMesh.Build() output ---"); + var subs = GfxObjMesh.Build(go, dats); + Console.WriteLine($" Submesh count: {subs.Count}"); + int totalVerts = 0, totalIndices = 0; + for (int i = 0; i < subs.Count; i++) + { + var s = subs[i]; + totalVerts += s.Vertices.Length; + totalIndices += s.Indices.Length; + Console.WriteLine( + $" Submesh[{i}] SurfaceId=0x{s.SurfaceId:X8} " + + $"Vertices={s.Vertices.Length} Indices={s.Indices.Length} " + + $"Translucency={s.Translucency} Luminosity={s.Luminosity:F2} " + + $"NeedsUvRepeat={s.NeedsUvRepeat}"); + } + Console.WriteLine($" TOTAL: verts={totalVerts} indices={totalIndices}"); + Console.WriteLine(); + Console.WriteLine($" Report 1 threshold check: with 8 wall quads × 2 tris × 3 indices = 48 indices per side."); + Console.WriteLine($" pos-only emission expects ~48 indices total."); + Console.WriteLine($" pos+neg emission expects ~96 indices total."); + Console.WriteLine($" OBSERVED: {totalIndices} indices → " + + (totalIndices > 60 ? "*** DOUBLE-SIDED — duplicate-side rendering active ***" : "single-sided")); +} + +static void DumpFlagBits(uint type) +{ + // From docs/research/named-retail/acclient.h:5820-5836. + // Print every named SurfaceType bit that's set. + var bits = new (uint mask, string name)[] + { + (0x00000001u, "Base1Solid"), + (0x00000002u, "Base1Image"), + (0x00000004u, "Base1ClipMap"), + (0x00000010u, "Translucent"), + (0x00000020u, "Diffuse"), + (0x00000040u, "Luminous"), + (0x00000100u, "Alpha"), + (0x00000200u, "InvAlpha"), + (0x00010000u, "Additive"), + (0x00020000u, "Detail"), + (0x10000000u, "Gouraud"), + (0x40000000u, "Stippled"), + (0x80000000u, "Perspective"), + }; + foreach (var (mask, name) in bits) + { + if ((type & mask) != 0) + Console.WriteLine($" {name} (0x{mask:X8})"); + } +} diff --git a/tools/RainMeshProbe/RainMeshProbe.csproj b/tools/RainMeshProbe/RainMeshProbe.csproj new file mode 100644 index 0000000..7e499da --- /dev/null +++ b/tools/RainMeshProbe/RainMeshProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + RainMeshProbe + + + + + + + From a6e71081220f5fc5624be3cb665c98678816c3ac Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 12:04:23 +0200 Subject: [PATCH 06/18] tools(probe): extend RainMeshProbe with sky-surface LUMINOUS audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added per-Surface dump that decodes Type bits and prints whether the LUMINOUS (0x40) flag is set on each. Targets all 27 sky surface IDs referenced by Holtburg's Region — every dome variant (0x010015EE/F0/F1/F2), the inner sky/star sheet (0x010015EF), sun (0x01001F67/0x01001348), moon (0x01001F6A), every cloud variant (0x01004C35..0x01004C3A, 0x010015B6), and rain (0x01004C42/0x01004C44 — control row). Result: zero of the 27 surfaces have the LUMINOUS bit set. The previous SkyRenderer comment that claimed dome+clouds carried the bit was wrong; the differentiator between "self-lit texture passthrough" and "ambient+diffuse-tinted" sky meshes is purely the Surface.Luminosity FLOAT (1.0 dome/sun/moon, 0.0 stars/clouds, 0.1484 rain). This fed directly into the emissive-default fix in the next commit. Bonus finding: cloud surface 0x08000023 has Translucency=0.25 (not 0) which the Translucency plumbing fix in the next commit will also pick up — clouds will render at 75% opacity, matching retail's curr_alpha derivation (D3DPolyRender::SetSurface at 0x59c767). Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/RainMeshProbe/Program.cs | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tools/RainMeshProbe/Program.cs b/tools/RainMeshProbe/Program.cs index 37475b1..1eaff70 100644 --- a/tools/RainMeshProbe/Program.cs +++ b/tools/RainMeshProbe/Program.cs @@ -28,8 +28,47 @@ using var dats = new DatCollection(datDir, DatAccessType.Read); uint[] gfxIds = { 0x01004C42u, 0x01004C44u }; foreach (uint gid in gfxIds) ProbeRain(dats, gid); + +// Phase 7c: also dump every sky surface we know to test the LUMINOUS flag. +// Two existing code comments contradict each other about whether Dereth's +// dome/sun/moon meshes carry the LUMINOUS bit. Resolve empirically. +Console.WriteLine(); +Console.WriteLine("================ Sky Surface LUMINOUS audit ================"); +uint[] skySurfaceIds = { + 0x08000048u, 0x08000049u, 0x0800004Au, 0x0800004Bu, // dome 0x010015EE + 0x0800004Du, // star sheet 0x010015EF + 0x0800004Eu, 0x0800004Fu, 0x08000050u, 0x08000051u, // dome 0x010015F0 + 0x08000053u, 0x08000054u, 0x08000055u, 0x08000056u, // dome 0x010015F1 + 0x08000057u, 0x08000058u, 0x08000059u, 0x0800005Au, // dome 0x010015F2 + 0x080000D1u, // celestial 0x01001348 + 0x080000D2u, // sun-like 0x01001F67 + 0x080000D6u, 0x080000D7u, // moon 0x01001F6A + 0x080000D4u, // cloud 0x01004C36/37 + 0x08000023u, // cloud 0x01004C35 + 0x08000024u, 0x08000025u, // cloud 0x01004C39/3A + 0x080000D5u, // dome variant 0x010015B6 + 0x080000C5u, // RAIN — control row, expected NO Luminous +}; +foreach (uint sid in skySurfaceIds) ProbeSkySurface(dats, sid); + return 0; +static void ProbeSkySurface(DatCollection dats, uint sid) +{ + if (!dats.TryGet(sid, out var s) || s is null) + { Console.WriteLine($" Surface 0x{sid:X8} (NOT FOUND)"); return; } + uint t = (uint)s.Type; + bool luminous = (t & 0x40u) != 0u; + Console.Write($" Surface 0x{sid:X8} Type=0x{t:X8} Luminous={(luminous ? "YES" : "no ")} Lum={s.Luminosity:F4} Trans={s.Translucency:F4} "); + // Decode bits inline. + var bits = new (uint mask, string n)[] { + (0x01u,"B1Solid"),(0x02u,"B1Image"),(0x04u,"B1ClipMap"),(0x10u,"Translucent"), + (0x20u,"Diffuse"),(0x40u,"Luminous"),(0x100u,"Alpha"),(0x200u,"InvAlpha"), + (0x10000u,"Additive"),(0x20000u,"Detail"), + }; + Console.WriteLine(string.Join("|", bits.Where(b => (t & b.mask) != 0).Select(b => b.n))); +} + static void ProbeRain(DatCollection dats, uint gid) { Console.WriteLine(); From 4678b3ee6b233ca9430e83d69121dade09a1f9ef Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 12:04:55 +0200 Subject: [PATCH 07/18] fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent brightness bugs were compounding to make rain ~6.7× too bright at the cylinder rim, and clouds full-bright instead of time-of-day-tinted: **Fix 1 — Surface.Translucency was never plumbed to the shader.** Retail's D3DPolyRender::SetSurface at 0x59c767: when the Surface's Translucent (0x10) bit is set, its translucency float drives per-vertex alpha (curr_alpha = ftol(0.5 × 255) = 127). ACViewer (TextureCache.cs:142) and WorldBuilder (ObjectMeshManager.cs:1115) both encode the same as `opacity = (1 - x)`. acdream read only Surface.Type and Surface.Luminosity in GfxObjMesh.Build() — Surface.Translucency (the float) was never read, never stored, never reached the shader. For the rain Surface 0x080000C5 (Translucency=0.5) this meant rain streaks were at full alpha=1.0 instead of 0.5 — 2× brighter than retail under the (SrcAlpha, One) blend. Plumbed end-to-end: GfxObjSubMesh.SurfTranslucency (init float, default 0) GfxObjMesh.Build() reads surface.Translucency next to .Luminosity SubMeshGpu.SurfTranslucency carries it to draw time SkyRenderer.RenderPass writes uniform `uSurfTranslucency` sky.frag final alpha: a = sampled.a × (1 - uTransparency) × (1 - uSurfTranslucency) Bonus reach: cloud surface 0x08000023 has Translucency=0.25 → clouds also dimmed by 25%, more retail-faithful overall. **Fix 2 — Emissive default was 1.0 instead of the surface's actual Luminosity.** The sky shader's `effEmissive = (luminosity > 0) ? luminosity : sub.SurfLuminosity` fallback never fired because the local `luminosity` defaulted to 1f (always > 0). Every sky mesh got effEmissive=1.0, saturating vTint to white before the alpha blend. The comment claimed the fallback was active; the code disagreed. Empirical sky-surface LUMINOUS audit (RainMeshProbe a6e7108) found that NO Dereth sky surface carries the SurfaceType.Luminous flag (0x40) — the previous code comment that did was wrong. The differentiator is purely the Surface.Luminosity FLOAT: dome/sun/moon: Lum=1.0 → vTint saturates → texture passthrough stars/clouds: Lum=0.0 → vTint = ambient + sun·N·L → time-of-day tint rain: Lum=0.1484 → faint emissive baseline + lit additions Refactored: replaceLuminosity = NaN sentinel for "no replace override" rep.Luminosity > 0 → set replaceLuminosity to override value rep.MaxBright > 0 → cap replaceLuminosity at MaxBright effEmissive = NaN ? sub.SurfLuminosity : replaceLuminosity Dead uniform `uLuminosity` removed from sky.frag and SkyRenderer SetFloat call — the redundant multiply was already commented-out earlier this year (would have double-dimmed clouds), and the uniform value was unused in the fragment. Visual verification (Holtburg, live ACE, Rainy DG forced and natural LCG-picked): rain rim is no longer visible; cloud direction matches retail when the same DayGroup is active; sky lighting transitions through day cycle with appropriate time-of-day tint on stars/clouds. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Shaders/sky.frag | 50 +++++++----- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 81 ++++++++++++++++---- src/AcDream.Core/Meshing/GfxObjMesh.cs | 11 +++ src/AcDream.Core/Meshing/GfxObjSubMesh.cs | 18 +++++ 4 files changed, 127 insertions(+), 33 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 4ddfbde..3a25b3a 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -2,17 +2,16 @@ // Sky mesh fragment shader — final composite matching retail's // D3D fixed-function: // -// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash -// fragment.a = texture.a × (1 - uTransparency) +// fragment.rgb = texture.rgb × vTint + lightning_flash +// fragment.a = texture.a × (1 - uTransparency) × (1 - uSurfTranslucency) // // vTint arrives from the vertex shader with retail's per-vertex // lighting formula baked in (Emissive + lightAmbient + lightDiffuse × -// max(N·L, 0)) — see sky.vert for the decompile citation. -// -// uLuminosity is the per-keyframe SkyObjectReplace.Luminosity override -// (0..1, /100 in SkyDescLoader). It's a SEPARATE field from the -// Surface.Luminosity that feeds uEmissive in the vertex shader — they -// compose multiplicatively in retail too. +// max(N·L, 0)) — see sky.vert for the decompile citation. The keyframe +// SkyObjectReplace.Luminosity override is folded into uEmissive on the +// CPU side (SkyRenderer.cs) so vTint already saturates properly for +// bright keyframes; the previous shader had a redundant uLuminosity +// multiply that was double-dimming clouds, removed 2026-04-26. // // See `docs/research/2026-04-23-sky-material-state.md`. @@ -22,8 +21,15 @@ in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far) out vec4 fragColor; uniform sampler2D uDiffuse; -uniform float uTransparency; // 0 = fully visible, 1 = fully transparent -uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1) +uniform float uTransparency; // 0 = fully visible, 1 = fully transparent +// Surface.Translucency float (0..1) — distinct from uTransparency +// (which is the per-keyframe Replace override). Retail +// D3DPolyRender::SetSurface at 0x59c767 reads this when the Surface's +// Translucent (0x10) bit is set and converts to per-vertex alpha; +// ACViewer + WorldBuilder both apply opacity = (1 - x). Both factors +// compose multiplicatively into final fragment alpha. For non-Translucent +// surfaces uSurfTranslucency = 0 ⇒ no effect. +uniform float uSurfTranslucency; // Shared SceneLighting UBO — fog params drive the mix, flash channel // bumps sky brightness during lightning strikes. Matches sky.vert's @@ -45,14 +51,13 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // Composite: texture × per-vertex lit. - // `rep.Luminosity` is now pushed into `uEmissive` on the CPU side - // (SkyRenderer.cs) so `vTint` already saturates properly for bright - // keyframes. Multiplying by uLuminosity again here would dim the - // result — a BUG that was making clouds render as grey instead of - // white. Retail's fragment formula (FUN_0059da60 non-luminous - // branch) is texture × litColor × vertex.color(=white), so just - // `texture × vTint` is the retail-faithful composite. + // Composite: texture × per-vertex lit. Replace.Luminosity (per + // keyframe) and Surface.Luminosity are both folded into uEmissive + // on the CPU side (SkyRenderer.cs) so vTint already carries the + // right tint for the time-of-day. Retail's fragment formula + // (FUN_0059da60 non-luminous branch) is texture × litColor × + // vertex.color(=white), so `texture × vTint` is the retail-faithful + // composite. vec3 rgb = sampled.rgb * vTint; // Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED @@ -79,7 +84,14 @@ void main() { float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); - float a = sampled.a * (1.0 - uTransparency); + // Final fragment alpha = texture-alpha × keyframe-replace-opacity + // × surface-translucency-opacity. Both opacity factors are + // (1 - x) form per ACViewer (TextureCache.cs:142) and WorldBuilder + // (ObjectMeshManager.cs:1115). For the rain mesh 0x01004C42/0x01004C44: + // sampled.a = 1.0 (R8G8B8 texture), uTransparency = 0, + // uSurfTranslucency = 0.5 → a = 0.5 → with the (SrcAlpha, One) blend + // the streak contribution is halved, matching retail's curr_alpha = 127. + float a = sampled.a * (1.0 - uTransparency) * (1.0 - uSurfTranslucency); if (a < 0.01) discard; fragColor = vec4(rgb, a); } diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index e6238cb..b3df2b8 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -211,14 +211,31 @@ public sealed unsafe class SkyRenderer : IDisposable uint gfxObjId = obj.GfxObjId; float headingDeg = 0f; float transparent = 0f; - float luminosity = 1f; + // Replace-override luminosity. Stays NaN when there is no + // replace entry or none of the keyframe's overrides are set, + // and that NaN is the signal to fall back to the surface's + // authored Luminosity at draw time. This replaces the previous + // `luminosity = 1f` default which masked the surface value + // because the `(luminosity > 0) ? luminosity : sub.SurfLuminosity` + // fallback at the inner loop never fired (1f is always > 0). + // RainMeshProbe (committed b8e0857) confirmed empirically that + // NO Dereth sky surface carries the SurfaceType.Luminous flag + // bit (0x40) — the differentiator is purely the float field. + float replaceLuminosity = float.NaN; if (replaces.TryGetValue((uint)i, out var rep)) { if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId; if (rep.Rotate != 0f) headingDeg = rep.Rotate; transparent = Math.Clamp(rep.Transparent, 0f, 1f); - if (rep.Luminosity > 0f) luminosity = rep.Luminosity; - if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright); + if (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity; + // MaxBright is a CAP: even if the surface authored Lum=1.0, + // a per-keyframe MaxBright trims it. When no explicit + // Luminosity replace exists, MaxBright still acts as the + // ceiling (applied against sub.SurfLuminosity at draw time). + if (rep.MaxBright > 0f) + replaceLuminosity = float.IsNaN(replaceLuminosity) + ? rep.MaxBright + : MathF.Min(replaceLuminosity, rep.MaxBright); } if (gfxObjId == 0) continue; @@ -262,7 +279,6 @@ public sealed unsafe class SkyRenderer : IDisposable float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f; _shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset)); _shader.SetFloat("uTransparency", transparent); - _shader.SetFloat("uLuminosity", luminosity); EnsureMeshUploaded(gfxObjId); if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue; @@ -281,19 +297,44 @@ public sealed unsafe class SkyRenderer : IDisposable else _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - // Emissive source: retail's FUN_0059da60 for non-luminous - // surfaces writes rep.Luminosity into D3DMATERIAL9.Emissive - // (via material cache +0x3c). This PROMOTES bright-keyframe - // clouds into the self-lit term so the litColor saturates - // and the texture renders at full brightness rather than - // being dimmed by a per-fragment multiply. + // Emissive source picks the surface's authored Luminosity by + // default; the per-keyframe replace data can OVERRIDE + // (rep.Luminosity > 0) or CAP (rep.MaxBright). This matches + // retail's FUN_0059da60: surface.Luminosity → D3DMATERIAL.Emissive + // (via material cache +0x3c), with the keyframe replace + // promoting bright-keyframe clouds when the keyframe asks. // - // If no rep.Luminosity override: fall back to the Surface's - // static Luminosity (1.0 for dome/sun/moon → saturates; - // 0.0 for stars → stays ambient-lit, correct retail look). - float effEmissive = (luminosity > 0f) ? luminosity : sub.SurfLuminosity; + // Empirical Dereth sky surfaces (RainMeshProbe, b8e0857): + // dome/sun/moon → Lum=1.0 → vTint saturates → texture + // passthrough (correct retail look); + // stars/clouds → Lum=0.0 → vTint = ambient + diffuse → + // picks up the time-of-day tint; + // rain → Lum=0.1484 → faint emissive baseline, + // ambient+diffuse adds atmospheric tint. + // + // Pre-fix: the replace-override variable defaulted to 1f and + // the fallback `(luminosity > 0) ? luminosity : sub.SurfLuminosity` + // never fired — every sky mesh got effEmissive=1.0, + // saturating vTint. That made stars/clouds look full-bright + // instead of time-of-day-tinted, and made rain streaks + // 6.7× too bright (one of two factors compounding the + // foreground-rim visibility bug). + float effEmissive = float.IsNaN(replaceLuminosity) + ? sub.SurfLuminosity + : replaceLuminosity; _shader.SetFloat("uEmissive", effEmissive); + // Retail per-Surface translucency override (D3DPolyRender::SetSurface + // at 0x59c767): when the Surface's Translucent (0x10) bit is set, + // its translucency float drives per-vertex alpha. Both ACViewer + // and WorldBuilder render this as opacity = (1 - x). The shader + // multiplies output alpha by (1 - uSurfTranslucency); for surfaces + // without the bit, SurfTranslucency=0 ⇒ no effect. Critical for + // the rain mesh 0x01004C42/0x01004C44 (Translucency=0.5) so its + // streaks contribute at half intensity instead of full under the + // additive (SrcAlpha, One) blend. + _shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency); + uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); @@ -514,6 +555,7 @@ public sealed unsafe class SkyRenderer : IDisposable IsAdditive = isAdditive, SurfLuminosity = sm.Luminosity, NeedsUvRepeat = sm.NeedsUvRepeat, + SurfTranslucency = sm.SurfTranslucency, }; } @@ -563,5 +605,16 @@ public sealed unsafe class SkyRenderer : IDisposable /// Computed once at mesh build from the actual UV range. /// public bool NeedsUvRepeat; + /// + /// Surface.Translucency float (0..1) carried through from + /// . Passed to the + /// sky fragment shader as uSurfTranslucency; the shader + /// multiplies output alpha by (1 - x). For the rain + /// surface 0x080000C5 this is 0.5 → opacity 0.5 → rain streaks + /// contribute at half intensity under the additive blend, matching + /// retail's curr_alpha derivation in + /// D3DPolyRender::SetSurface at 0x59c767. + /// + public float SurfTranslucency; } } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 240e4db..6ddb68c 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -200,6 +200,7 @@ public static class GfxObjMesh // docs/research/2026-04-23-sky-retail-verbatim.md §6). var translucency = TranslucencyKind.Opaque; var luminosity = 0f; + var surfTranslucency = 0f; if (dats is not null) { var surface = dats.Get(surfaceId); @@ -207,6 +208,15 @@ public static class GfxObjMesh { translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); luminosity = surface.Luminosity; + // Retail D3DPolyRender::SetSurface at 0x59c767: when the + // Translucent (0x10) flag is set, the surface's + // Translucency float drives per-vertex alpha. Both + // ACViewer and WorldBuilder apply opacity = (1 - x). + // For the rain Surface 0x080000C5 this is 0.5. Carrying + // the float verbatim and converting to opacity in the + // shader keeps non-Translucent surfaces (Translucency=0) + // identical to the previous behavior. + surfTranslucency = surface.Translucency; } } @@ -235,6 +245,7 @@ public static class GfxObjMesh Translucency = translucency, Luminosity = luminosity, NeedsUvRepeat = needsUvRepeat, + SurfTranslucency = surfTranslucency, }); } return result; diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index 488b0dd..f85d5aa 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -53,4 +53,22 @@ public sealed record GfxObjSubMesh( /// Defaults to false so non-sky consumers get the previous behavior. /// public bool NeedsUvRepeat { get; init; } = false; + + /// + /// Surface.Translucency float (0..1 — distinct from the + /// classifier above, which buckets the + /// flag bits). Retail's D3DPolyRender::SetSurface at + /// 0x59c767 reads this when the Translucent (0x10) bit + /// is set on the surface and feeds it into the per-vertex alpha + /// (curr_alpha); the rasterizer then multiplies fragment alpha + /// by (1 - translucency) so the resulting opacity is + /// 1 - x. ACViewer (TextureCache.cs:142) and WorldBuilder + /// (ObjectMeshManager.cs:1115) both use the same convention. + /// For the rain Surface 0x080000C5, Translucency = 0.5 ⇒ + /// opacity = 0.5; with the (SrcAlpha, One) additive blend the + /// rain streaks contribute at half intensity instead of full. + /// Defaults to 0.0 (fully opaque) so non-translucent surfaces render + /// through the normal lighting path without change. + /// + public float SurfTranslucency { get; init; } = 0f; } From d95a8d2a558841a69fa159cf661ed0f8b65ab4cf Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 12:05:12 +0200 Subject: [PATCH 08/18] refactor(weather): delete legacy camera-attached rain/snow particle emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-research workaround at GameWindow.UpdateWeatherParticles + BuildRainDesc + BuildSnowDesc was acdream's stand-in for retail's weather rendering. It emitted billboarded particles inside a 15m disk attached to the camera ('AttachLocal'), with a broken alpha fade (0.3 → 0 caused rain to vanish at exact ground level — Issue #1) and a fixed disk that visibly framed the player even at speed. Retail rain is the world-space mesh path (SkyRenderer.RenderWeather): GfxObj 0x01004C42 / 0x01004C44 — hollow octagonal cylinder, 113m radius, 815m tall, anchored at player_pos + (0, 0, -120m) per GameSky::UpdatePosition at 0x00506dd0 — drawn AFTER the landblock pass per LScape::draw at 0x00506330. Snow renders identically when a Snowy DayGroup is active: the partition by Properties&0x04 picks up snow weather meshes for free. The legacy emitter was gated behind ACDREAM_FAKE_RAIN_PARTICLES=1 in the previous commit (3e0da49) so the world-space path could be A/B-compared. Visual verification this session confirmed the world- space path is correct; deleting the legacy code removes ~120 LOC plus the env var, the gate, the _rainEmitterHandle / _snowEmitterHandle fields, and the _lastWeatherKind state machine. Files affected: GameWindow.cs: drop UpdateWeatherParticles, BuildRainDesc, BuildSnowDesc, emitter-handle fields, last-weather-kind state, and the gated call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 141 +++--------------------- 1 file changed, 13 insertions(+), 128 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 17ed773..66888b6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -373,12 +373,6 @@ public sealed class GameWindow : IDisposable private long _loadedSkyDayIndex = long.MinValue; private AcDream.Core.World.DayGroupData? _activeDayGroup; - // Current rain/snow emitter handles — spawned on weather-kind change - // and stopped when the kind leaves Rain/Snow. Non-zero == active. - private int _rainEmitterHandle; - private int _snowEmitterHandle; - private AcDream.Core.World.WeatherKind _lastWeatherKind = - AcDream.Core.World.WeatherKind.Clear; private double _weatherAccum; // F7 / F10 debug-cycle steps for time + weather. Initialized out of @@ -4229,20 +4223,19 @@ public sealed class GameWindow : IDisposable Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds); _weatherAccum += deltaSeconds; - // Update the rain/snow particle emitters when the weather kind - // changes. Keep the emitters fed by the ParticleSystem tick so - // visuals stay alive frame-over-frame. - // - // Bug A note (2026-04-26): retail rain is the world-space mesh - // 0x01004C42/0x01004C44 rendered in SkyRenderer.RenderWeather - // AFTER the scene — see docs/research/2026-04-26-sky-investigation-handoff.md. - // The camera-attached emitter path here is acdream's old - // pre-research workaround (broken alpha fade, fixed disk around - // camera). It's kept gated behind ACDREAM_FAKE_RAIN_PARTICLES=1 - // so the retail-faithful path can be A/B-tested against it - // without uninstalling the legacy code outright. - if (System.Environment.GetEnvironmentVariable("ACDREAM_FAKE_RAIN_PARTICLES") == "1") - UpdateWeatherParticles(atmo); + // (Pre-Bug-A code spawned camera-attached rain/snow particle + // emitters here as a workaround for missing weather-mesh + // rendering. Deleted 2026-04-26 once the retail-faithful world- + // space mesh path landed in SkyRenderer.RenderWeather. Retail + // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal + // cylinder anchored at player_pos + (0, 0, -120m) per + // GameSky::UpdatePosition at 0x00506dd0 — drawn after the + // landblock pass per LScape::draw at 0x00506330. There is no + // server-driven weather event and no camera-attached emitter + // in retail. Snow renders identically when a Snowy DayGroup is + // active in some other Region; the partition by Properties&0x04 + // and the SkyRenderer.RenderWeather pass both pick up snow + // weather meshes for free.) // Phase E.3: advance live particle emitters AFTER animation tick // so emitters spawned by hooks fired this frame get integrated. @@ -5241,114 +5234,6 @@ public sealed class GameWindow : IDisposable } } - /// - /// Keep the rain/snow camera-anchored emitters aligned with the - /// current weather state. Spawns on entry, stops on exit, with no - /// per-frame churn while the state is stable. Emitters are camera- - /// local () - /// so walking never leaves the rain volume (r12 §7). - /// - private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo) - { - if (_particleSystem is null) return; - - if (atmo.Kind == _lastWeatherKind) return; // no change - - // Stop any existing emitters first. - if (_rainEmitterHandle != 0) - { - _particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true); - _rainEmitterHandle = 0; - } - if (_snowEmitterHandle != 0) - { - _particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true); - _snowEmitterHandle = 0; - } - - // Anchor at camera world position; AttachLocal keeps it moving. - var anchor = System.Numerics.Vector3.Zero; - if (_cameraController is not null) - { - System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv); - anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43); - } - - switch (atmo.Kind) - { - case AcDream.Core.World.WeatherKind.Rain: - case AcDream.Core.World.WeatherKind.Storm: - _rainEmitterHandle = _particleSystem.SpawnEmitter( - BuildRainDesc(), anchor); - break; - case AcDream.Core.World.WeatherKind.Snow: - _snowEmitterHandle = _particleSystem.SpawnEmitter( - BuildSnowDesc(), anchor); - break; - } - - _lastWeatherKind = atmo.Kind; - } - - /// - /// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with - /// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so - /// drops cover the ~60m fall at terminal velocity. - /// - private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new() - { - DatId = 0xFFFF_0001u, // synthetic id - Type = AcDream.Core.Vfx.ParticleType.LocalVelocity, - Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal | - AcDream.Core.Vfx.EmitterFlags.Billboard, - EmitRate = 500f, - MaxParticles = 2000, - LifetimeMin = 1.0f, - LifetimeMax = 1.4f, - OffsetDir = new System.Numerics.Vector3(0, 0, 1), - MinOffset = 0f, - MaxOffset = 50f, - SpawnDiskRadius = 15f, - InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f), - VelocityJitter = 2f, - Gravity = System.Numerics.Vector3.Zero, - StartColorArgb = 0x40B0C0E0u, - EndColorArgb = 0x20B0C0E0u, - StartAlpha = 0.3f, - EndAlpha = 0f, - StartSize = 0.05f, - EndSize = 0.05f, - }; - - /// - /// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling - /// sideways drift, small billboards, 100 flakes/sec, long lifespan. - /// - private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new() - { - DatId = 0xFFFF_0002u, - Type = AcDream.Core.Vfx.ParticleType.LocalVelocity, - Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal | - AcDream.Core.Vfx.EmitterFlags.Billboard, - EmitRate = 100f, - MaxParticles = 1000, - LifetimeMin = 4f, - LifetimeMax = 8f, - OffsetDir = new System.Numerics.Vector3(0, 0, 1), - MinOffset = 0f, - MaxOffset = 30f, - SpawnDiskRadius = 15f, - InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f), - VelocityJitter = 0.8f, - Gravity = System.Numerics.Vector3.Zero, - StartColorArgb = 0xE0FFFFFFu, - EndColorArgb = 0x80FFFFFFu, - StartAlpha = 0.85f, - EndAlpha = 0.3f, - StartSize = 0.08f, - EndSize = 0.06f, - }; - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask From 47e2c151f44777820a3ba5ac8f5d2598a13310ae Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 12:06:09 +0200 Subject: [PATCH 09/18] =?UTF-8?q?docs(issues):=20close=20#1=20(foreground?= =?UTF-8?q?=20rain)=20=E2=80=94=20commits=20d95a8d2=20+=204678b3e=20+=203e?= =?UTF-8?q?0da49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rain bug from `docs/research/2026-04-26-sky-investigation-handoff.md` fully resolved this session. Three commits sequentially landed the retail-faithful path: 3e0da49 — sky pass split + -120m weather Z offset 4678b3e — Surface.Translucency + Luminosity plumbing d95a8d2 — delete legacy camera-attached particle emitter Visual verification by user: rain renders as volumetric foreground, direction matches retail when LCG-picked DayGroup matches retail's, no cylinder rim visible looking up. Two follow-up issues remain open from the visual-verify session: #27 — cloud rendering parity (Translucency=0.25 partial fix landed but cloud coverage still differs from retail, possibly keyframe-tint related) #28 — aurora/northern lights — research found NO evidence in retail decomp, references, or DG composition; either misremembered or emergent from cloud system at specific keyframes Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1dad84c..bc39ced 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -177,26 +177,6 @@ missing is the plugin-API surface. --- -## #1 — Rain falls only to horizon, not to the player's feet - -**Status:** OPEN -**Severity:** MEDIUM -**Filed:** 2026-04-25 -**Component:** weather / particles - -**Description:** During Rainy DayGroups, rain particles are visible in the upper sky band but fade out before reaching the camera / ground level. Retail's rain falls all the way past the camera to the terrain. - -**Root cause / status:** Unknown. Likely one of: (a) particle emitter volume too short in Z, (b) particle lifetime shorter than the time it takes to traverse emitter-top → ground, (c) emitter anchored in world-space so particles escape the player's reference frame as they fall, (d) camera-relative spawn origin is offset too high above the player. - -**Files:** -- `src/AcDream.App/Rendering/GameWindow.cs` — `UpdateWeatherParticles` (~line 4591) -- `src/AcDream.Core/Vfx/ParticleSystem.cs` — emitter spawn config + lifetime integration - -**Research:** `docs/research/deepdives/r12-weather-daynight.md` (rain mechanism — but does not pin volume / lifetime values). - -**Acceptance:** Standing at 9,115 in Holtburg during a Rainy DayGroup, rain drops visibly fall all the way from the sky band past the camera to the ground level. - ---- ## #2 — Lightning visual not wired (dat-baked PES triggers) @@ -338,6 +318,14 @@ missing is the plugin-API surface. # Recently closed +## #1 — [DONE 2026-04-26] Rain falls only to horizon, not to the player's feet + +**Closed:** 2026-04-26 +**Commits:** `3e0da49` (sky pass split + retail -120m Z offset), `4678b3e` (Surface.Translucency + Luminosity correctness), `d95a8d2` (legacy emitter delete) +**Resolution:** Two-part fix. First, rain rendering was completely re-architected to match retail's `LScape::draw` pattern at `0x00506330` — sky pass before the landblock loop (`RenderSky`), weather pass after (`RenderWeather`). Weather meshes now overlay terrain instead of being painted over. Camera anchored inside the rain cylinder via the retail-correct -120m Z offset (constant `0xc2f00000` in `GameSky::UpdatePosition` at `0x00506dd0`). Second, the per-Surface `Translucency` float (rain = 0.5) and `Luminosity` float (rain = 0.1484) were both being ignored by the renderer; plumbed end-to-end so streaks contribute at retail-correct intensity instead of 6.7× too bright. Legacy camera-attached particle emitter (`UpdateWeatherParticles` + `BuildRainDesc` + `BuildSnowDesc`) deleted; world-space mesh is the only path now. Snow rides the same fix automatically. Filed alongside two follow-up issues from the visual-verify session: `#27` (cloud rendering parity), `#28` (aurora/northern lights). + +--- + ## #26 — [DONE 2026-04-26] Stars rendered as a square in one corner of the sky **Closed:** 2026-04-26 From 449e9c35401a9650855e0dfefc70341de54907d9 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 13:18:02 +0200 Subject: [PATCH 10/18] =?UTF-8?q?docs(issues):=20close=20#27=20(cloud=20pa?= =?UTF-8?q?rity)=20=E2=80=94=20DONE-via-Fix-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud rendering parity with retail confirmed visually under Phase 0 of the #27 fix plan: launched acdream with no DG override (LCG-picked matches retail's pick), compared cloud coverage / color / edges / movement at the same in-game time. User verdict: "Cloud and colors look correct." The original #27 observation from earlier in this session was a side-effect of the broken `effEmissive=1.0` default that saturated every sky mesh's vTint to white. That bug, plus the orthogonal `surface.Translucency` plumbing gap, were both repaired in commit 4678b3e: - Fix 1 (Translucency): cloud surface 0x08000023 has Translucency=0.25, now plumbed end-to-end → clouds at 75% opacity instead of 100%. - Fix 2 (Luminosity): cloud surfaces have Luminosity=0.0, so post-fix they run through `vTint = ambient + sun·N·L` instead of saturating to white — clouds pick up the keyframe time-of-day tint. User also flagged that acdream's clock is "a few minutes ahead" of retail (sun higher on the horizon at the same wall-clock moment). That is the existing #3 (`Client clock drifts from retail after ~10 minutes — periodic TimeSync missing`), reproducing exactly as documented. Out of scope for the sky-fixes branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bc39ced..b7eee93 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -272,29 +272,6 @@ missing is the plugin-API surface. --- -## #27 — Cloud meshes appear missing or faint compared to retail - -**Status:** OPEN -**Severity:** LOW (aesthetic feature-parity — doesn't break gameplay) -**Filed:** 2026-04-26 -**Component:** sky / clouds - -**Description:** After fixing Bug B (#26 — stars-as-square), the user observed during visual verification that cloud coverage in the sky doesn't match retail. Cloud meshes are authored in the dat (e.g. `0x010015B6`, `0x01004C35`-`0x01004C38`, `0x01004C36` etc) and `tools/StarsProbe` confirms they're loaded into the SkyObject lists with non-zero TexVelocity (so they get GL_REPEAT correctly under the post-#26 code path). They're not strictly missing — they're rendered — but their visual presence falls short of retail. - -**Root cause / status:** Unknown. Hypotheses: (a) cloud surfaces' alpha/blend mode is too subtle (cloud surface flags or shader path under-emphasise the texture); (b) cloud meshes positioned/scaled wrong relative to the dome so they're inside the dome and occluded; (c) DayGroup keyframe interpolation suppresses cloud transparency at certain times of day; (d) some cloud SkyObjects we should be rendering are filtered out by a Properties bit we mis-handle (Props=0x02 might mean something more than "cloud — render it"); (e) retail uses an additive cloud blend that our Translucency classifier doesn't apply. - -**Files:** -- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky pass; check per-cloud blend / luminosity / transparency. -- `src/AcDream.Core/World/SkyDescLoader.cs` — Properties bit decoding. -- `src/AcDream.App/Rendering/Shaders/sky.frag` — cloud transparency math. -- `tools/StarsProbe/` — already dumps cloud GfxObj UVs + bounds; extend to dump per-DayGroup cloud surface flags. - -**Research:** None yet. `tools/StarsProbe` output already enumerates which DayGroups reference which cloud meshes — start there. - -**Acceptance:** Side-by-side launch of acdream and a retail client at the same `ACDREAM_DAY_GROUP` shows visually-comparable cloud coverage in the sky. - ---- - ## #28 — Aurora ("northern lights") effect not rendered **Status:** OPEN @@ -318,6 +295,14 @@ missing is the plugin-API surface. # Recently closed +## #27 — [DONE 2026-04-26] Cloud meshes appeared missing or faint vs retail + +**Closed:** 2026-04-26 +**Commit:** `4678b3e fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather` +**Resolution:** Resolved as a side-effect of the Bug A fix. The original observation came from a session where every sky mesh got `effEmissive = 1.0` (saturated `vTint` to white), which made stars/clouds look full-bright instead of time-of-day-tinted. Fix 2 corrected the emissive default to `sub.SurfLuminosity` so cloud surfaces (Lum=0.0) now run through the ambient+diffuse vertex-lit path and pick up keyframe tint. Fix 1 separately plumbed `surface.Translucency` to the shader, picking up the 0.25 translucency on cloud surface `0x08000023` (75% opacity). Visual verification under Phase 0 of the followup plan: clouds and colors now match retail at LCG-picked DayGroups across the day cycle. + +--- + ## #1 — [DONE 2026-04-26] Rain falls only to horizon, not to the player's feet **Closed:** 2026-04-26 From dbe6690a4e9f7615d2f10b9369e53d0eab158dfd Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 14:43:49 +0200 Subject: [PATCH 11/18] fix(time): retail-canonical month enum + absolute Portal Year + title-bar calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in calendar display (the CLOCK ITSELF was already correct): 1. **Month enum had wrong order + non-retail names.** Old enum: Snowreap=0, ColdMeet, Leafdawning, Seedsow, Rosetide, Solclaim, ... At day-of-year 83 this gave month index 2 = Leafdawning. Retail's @timestamp at the same moment shows "Seedsow 24". Fixed enum to chronological order starting at year-anchor month Morningthaw, with retail-canonical names: Morningthaw=0, Solclaim, Seedsow, Leafdawning, Verdantine, Thistledown, Harvestgain, Leafcull, Frostfell, Snowreap, Coldeve, Wintersebb. At day-of-year 83 → month 2 = Seedsow ✓ 2. **ToCalendar returned relative year, not absolute Portal Year.** We had AbsoluteYear() = relative_year + ZeroYear (=10) but ToCalendar's Calendar.Year was the relative one. So acdream's title bar showed "PY 106" while retail's @timestamp at the same tick showed "PY 116". Fixed ToCalendar to add ZeroYear so the exposed Calendar.Year matches retail's display. 3. **GameWindow title bar now shows the calendar.** Format mirrors retail's @timestamp output: "PY (df=)" Lets the user read the same fields off both clients and confirm clock parity directly. Drift > 1 hour = real bug. Tests: - Updated ToCalendar_PY10Day1_Morningthaw (renamed from PY0Day1_Snowreap) - Updated ToCalendar_AdvancesCorrectly (Snowreap→Morningthaw etc.) - Added regression: ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat pinning a retail-known tick → retail-known calendar string. The dayFraction formula (CalcDayBegin's `arg2 + zero_time_of_year`, decomp 0x005a6400 line 434549) was already correct; an earlier-this- session attempt to flip the sign was reverted in this same commit's parent. The "few minutes drift" observed in dual-client comparisons this session was a combination of: - calendar label mismatch (this fix addresses) - slot-boundary rounding (fixes itself) - 1-minute wall-clock interpolation drift (within tolerance) NOT a clock-formula bug. ISSUE #3 in docs/ISSUES.md is now misnamed ("Client clock drifts from retail"); plan to re-title or close in a follow-up commit after the visual-divergence investigation lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 15 ++++- src/AcDream.Core/World/DerethDateTime.cs | 57 ++++++++++++------- .../World/DerethDateTimeTests.cs | 48 +++++++++++++--- 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 66888b6..d0d1028 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4565,9 +4565,18 @@ public sealed class GameWindow : IDisposable int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; - _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + - $"lb {visibleLandblocks}/{totalLandblocks} visible | " + - $"ent {entityCount} | anim {animatedCount}"; + // Calendar display matches retail's @timestamp output: + // "Date: , PY Time: ". + // Use NowTicks (server-synced + wall-clock interpolation) so the + // user can read the same fields off both acdream and retail and + // confirm clock parity directly. Drift > 1 hour = real bug. + double tNow = WorldTime.NowTicks; + var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow); + double df = WorldTime.DayFraction; + _window!.Title = + $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " + + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs index 592b868..a2e843d 100644 --- a/src/AcDream.Core/World/DerethDateTime.cs +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -90,21 +90,30 @@ public static class DerethDateTime GloamingAndHalf, } - /// Derethian months (Snowreap..Frostfell, 12 total). + /// + /// Derethian months in chronological order. Year-0 begins at month 0 + /// () and progresses through the 12-month + /// cycle. Names + order match retail's calendar display + /// (GameTime::CalcDayBegin + GetDateTimeString at + /// 0x005a6530) and ACE's DerethDateTime.cs. Verified + /// against retail's @timestamp output in 2026-04-27 dual- + /// client comparison: at day-of-year 83, retail shows + /// "Seedsow 24" — that fixes month index 2 = Seedsow. + /// public enum MonthName { - Snowreap = 0, - ColdMeet, - Leafdawning, - Seedsow, - Rosetide, + Morningthaw = 0, Solclaim, + Seedsow, + Leafdawning, + Verdantine, Thistledown, Harvestgain, - Leaftrue, - Reaptide, - Morningthaw, + Leafcull, Frostfell, + Snowreap, + Coldeve, + Wintersebb, } /// @@ -127,12 +136,15 @@ public static class DerethDateTime /// for the boot window before the dat parses. /// /// - /// Live Dereth dat value: 3600. The +7/16 default is wrong - /// by 266.25 ticks (~33 Derethian minutes) and was the source of - /// the "acdream time is behind retail" + "wrong DayGroup picked" - /// observations in the 2026-04-23 live verification session — see - /// docs/research/2026-04-23-daygroup-selection.md §4 and - /// the Phase 3f commit. + /// Live Dereth dat value: 3600. Retail's + /// GameTime::CalcDayBegin at 0x005a6400 (decomp line + /// 434549) computes arg2 + zero_time_of_year as the basis for + /// year/day-of-year extraction, then derives time_of_day_begin + /// such that (arg2 - time_of_day_begin) / day_length in + /// CalcTimeOfDay gives (arg2 + zero_time_of_year) mod day_length / day_length. + /// Net: the formula is ADD, not subtract — confirmed via the explicit + /// add at line 434549. (A 2026-04-26 attempt to flip the sign over- + /// corrected and broke DG selection; reverted in the same commit.) /// /// public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks; @@ -186,7 +198,10 @@ public static class DerethDateTime /// /// Derethian calendar breakdown: (year, month, day, hour). - /// Year starts at PY 0. Day is 1-based within the month (1..30). + /// is the absolute Portal Year (= relative-year + + /// ) so the value matches retail's + /// @timestamp output ("Date: <Month> <Day>, + /// <Year> P.Y."). Day is 1-based within the month (1..30). /// public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour); @@ -194,15 +209,19 @@ public static class DerethDateTime { if (ticks < 0) ticks = 0; double shifted = ticks + OriginOffsetTicks; - int year = (int)(shifted / YearTicks); - double tYear = shifted - year * YearTicks; + int relativeYear = (int)(shifted / YearTicks); + double tYear = shifted - relativeYear * YearTicks; int monthIdx = (int)(tYear / MonthTicks); if (monthIdx > 11) monthIdx = 11; double tMonth = tYear - monthIdx * MonthTicks; int day = (int)(tMonth / DayTicks) + 1; if (day > DaysInAMonth) day = DaysInAMonth; - return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks)); + // Absolute Portal Year for display: retail's @timestamp shows + // PY-with-base (10 P.Y. == year 0 of the calendar epoch), so add + // ZeroYear here. Matches AbsoluteYear() and the retail decomp at + // FUN_005a7510:5300. + return new Calendar(relativeYear + ZeroYear, (MonthName)monthIdx, day, CurrentHour(ticks)); } /// diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs index 19d44ef..86fb5a9 100644 --- a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs +++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs @@ -75,26 +75,56 @@ public sealed class DerethDateTimeTests } [Fact] - public void ToCalendar_PY0Day1_Snowreap() + public void ToCalendar_PY10Day1_Morningthaw() { + // Tick 0 maps to PY 10 (= relative year 0 + ZeroYear=10), + // Morningthaw 1 — matches retail's calendar epoch + // (ACE DerethDateTime.cs: dayZeroTicks = 0; // Morningthaw 1, 10 P.Y.). var cal = DerethDateTime.ToCalendar(0); - Assert.Equal(0, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(DerethDateTime.ZeroYear, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); Assert.Equal(1, cal.Day); } [Fact] public void ToCalendar_AdvancesCorrectly() { - // One year from start → PY 1, Snowreap 1. + // One year from start → PY (10 + 1) = 11, Morningthaw 1. var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks); - Assert.Equal(1, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(DerethDateTime.ZeroYear + 1, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); Assert.Equal(1, cal.Day); - // One month into year 1. + // One month into year 11 → Solclaim (next month after Morningthaw). var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks); - Assert.Equal(1, cal2.Year); - Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month); + Assert.Equal(DerethDateTime.ZeroYear + 1, cal2.Year); + Assert.Equal(DerethDateTime.MonthName.Solclaim, cal2.Month); + } + + [Fact] + public void ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat() + { + // Regression guard for the 2026-04-27 dual-client comparison. + // Retail @timestamp output format is + // "Date: , P.Y." + // Pick a tick at the exact start of Seedsow 24 in relative year 106: + // shifted = 106 * YearTicks + 2 * MonthTicks + 23 * DayTicks + // Derived: 290,779,200 + 457,200 + 175,260 = 291,411,660. Subtract + // OriginOffsetTicks (3600 in Dereth dat) to get the input tick: + // 291,411,660 - 3600 = 291,408,060 + // Expected output: PY 116 (= ZeroYear 10 + relative 106), Seedsow, + // day 24 1-indexed. + DerethDateTime.SetOriginOffsetFromDat(3600.0); + try + { + var cal = DerethDateTime.ToCalendar(291_408_060.0); + Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month); + Assert.Equal(24, cal.Day); + } + finally + { + DerethDateTime.SetOriginOffsetFromDat(DerethDateTime.DayFractionOriginOffsetTicks); + } } } From 63b50c5291dfc9fb7814a33928101bfa372ce52d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 15:02:35 +0200 Subject: [PATCH 12/18] =?UTF-8?q?fix(sky):=20retail-faithful=20keyframe=20?= =?UTF-8?q?lerp=20=E2=80=94=20separate-channel=20color/bright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331) lerps each color channel and the brightness scalar SEPARATELY, then multiplies post-lerp: arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u) arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u) arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u) arg3 = lerp(k1.amb_bright, k2.amb_bright, u) final = (arg4.rgb * arg3, ...) acdream pre-multiplied (color × bright) at LOAD time (`SkyDescLoader.cs:558-559`) and then lerped the product. For any keyframe pair where both color and brightness change, the two are mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0) at u=0.5: - retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375) - acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25) For Rainy/Cloudy DayGroups transitioning between dim and bright keyframes, this contributes to subtle brightness divergence vs retail. Refactor: SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright SEPARATELY (raw, not pre-multiplied). Computed properties SunColor and AmbientColor return the post-multiplied product, keeping the shader uniform interface (uSunColor / uAmbientColor) unchanged. SkyStateProvider.Interpolate lerps each raw channel, then constructs a new SkyKeyframe whose computed properties yield the correct post-lerp multiply. SkyDescLoader now stores raw values without pre-multiplying. GameWindow comment updated; no functional change there. Default factory + tests updated to use the new constructor parameters with DirBright=AmbBright=1.0 (preserving exact existing behavior). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 8 +- src/AcDream.Core/World/SkyDescLoader.cs | 15 ++- src/AcDream.Core/World/SkyState.cs | 93 ++++++++++++++----- .../AcDream.Core.Tests/World/SkyStateTests.cs | 6 +- .../World/WorldTimeDebugTests.cs | 6 +- 5 files changed, 97 insertions(+), 31 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d0d1028..3fa1721 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5225,9 +5225,11 @@ public sealed class GameWindow : IDisposable } else { - // Outdoor: full keyframe sun + ambient; colors are already - // pre-multiplied by DirBright / AmbBright inside - // SkyDescLoader so we feed them straight into the UBO. + // Outdoor: full keyframe sun + ambient. The SkyKeyframe stores + // raw DirColor + DirBright (and AmbColor + AmbBright) for + // retail-faithful per-channel keyframe interpolation; the + // computed `kf.SunColor` / `kf.AmbientColor` properties return + // the post-multiplied product the shader expects. Lighting.Sun = new AcDream.Core.Lighting.LightSource { Kind = AcDream.Core.Lighting.LightKind.Directional, diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index 409d51e..5ef8245 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -551,12 +551,23 @@ public static class SkyDescLoader _ => FogMode.Off, }; + // Store DirColor / AmbColor RAW and DirBright / AmbBright SEPARATE + // (NOT pre-multiplied) so the keyframe interpolator can lerp each + // channel independently — matches retail SkyDesc::GetLighting at + // 0x00500ac9 (decomp lines 261317-261331). Multiplying at load + // time and lerping the product produces mathematically different + // results than retail when both color and brightness change + // between adjacent keyframes. The post-multiplied values are + // available via `kf.SunColor` / `kf.AmbientColor` computed + // properties for shader-uniform plumbing. var kf = new SkyKeyframe( Begin: s.Begin, SunHeadingDeg: s.DirHeading, SunPitchDeg: s.DirPitch, - SunColor: ColorToVec3(s.DirColor) * s.DirBright, - AmbientColor: ColorToVec3(s.AmbColor) * s.AmbBright, + DirColor: ColorToVec3(s.DirColor), + DirBright: s.DirBright, + AmbColor: ColorToVec3(s.AmbColor), + AmbBright: s.AmbBright, FogColor: ColorToVec3(s.WorldFogColor), FogDensity: 0f, FogStart: s.MinWorldFog, diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 94e1ab5..31fdf3b 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -34,24 +34,53 @@ public enum FogMode /// /// /// -/// Colors are in LINEAR RGB, already pre-multiplied by their brightness -/// scalar so the shader can plug them straight into the UBO without -/// knowing about DirBright / AmbBright. Range is loosely -/// [0, N] — retail dusk tints have channels above 1.0 and the frag -/// shader clamps after lighting math. +/// Colors are stored RAW (NOT pre-multiplied by brightness) in +/// / with the brightness +/// scalars in / . Retail's +/// SkyDesc::GetLighting at 0x00500ac9 (decomp lines +/// 261317-261331) lerps each channel separately and lerps brightness +/// separately, then multiplies post-lerp. Lerping the pre-multiplied +/// product gives mathematically different results when both color and +/// brightness change between adjacent keyframes — the cause of subtle +/// brightness discrepancies vs retail observed in dual-client +/// comparisons (Issue #3 visual sub-bug, 2026-04-27). +/// +/// +/// The computed properties and +/// return the post-multiplied product, so +/// downstream shader uniform plumbing (sky.vert / mesh.vert / +/// SceneLightingUbo) is unchanged. /// /// public readonly record struct SkyKeyframe( float Begin, // [0, 1] day-fraction this keyframe kicks in float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W) float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith) - Vector3 SunColor, // RGB linear, post-brightness multiply - Vector3 AmbientColor, // RGB linear, post-brightness multiply + Vector3 DirColor, // RGB linear, RAW (NOT × DirBright) + float DirBright, // sun brightness multiplier + Vector3 AmbColor, // RGB linear, RAW (NOT × AmbBright) + float AmbBright, // ambient brightness multiplier Vector3 FogColor, float FogDensity, // retained for tests; derive from FogStart/End float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm) float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm) - FogMode FogMode = FogMode.Linear); + FogMode FogMode = FogMode.Linear) +{ + /// + /// Final directional sun color used by the shader = + /// × . Computed property + /// so the storage stays as separate channels (for retail-faithful + /// keyframe interpolation) while the shader interface stays simple. + /// + public Vector3 SunColor => DirColor * DirBright; + + /// + /// Final ambient color used by the shader = + /// × . See + /// for the rationale. + /// + public Vector3 AmbientColor => AmbColor * AmbBright; +} /// /// Sky keyframe interpolator — given a day fraction in [0, 1), returns @@ -111,12 +140,18 @@ public sealed class SkyStateProvider // Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk. return new SkyStateProvider(new[] { + // Default factory: brightness scalars are 1.0 here — the colors + // ARE the final intended values. Live Dereth keyframes loaded + // from the dat have separate non-1.0 DirBright/AmbBright values + // and the renderer multiplies them post-lerp. new SkyKeyframe( Begin: 0.0f, SunHeadingDeg: 0f, // below horizon (north) SunPitchDeg: -30f, - SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue - AmbientColor: new Vector3(0.05f, 0.05f, 0.12f), + DirColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue + DirBright: 1.0f, + AmbColor: new Vector3(0.05f, 0.05f, 0.12f), + AmbBright: 1.0f, FogColor: new Vector3(0.02f, 0.02f, 0.05f), FogDensity: 0.004f, FogStart: 30f, @@ -126,8 +161,10 @@ public sealed class SkyStateProvider Begin: 0.25f, SunHeadingDeg: 90f, // east at dawn SunPitchDeg: 0f, - SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm - AmbientColor: new Vector3(0.4f, 0.35f, 0.3f), + DirColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm + DirBright: 1.0f, + AmbColor: new Vector3(0.4f, 0.35f, 0.3f), + AmbBright: 1.0f, FogColor: new Vector3(0.8f, 0.55f, 0.4f), FogDensity: 0.002f, FogStart: 60f, @@ -137,8 +174,10 @@ public sealed class SkyStateProvider Begin: 0.5f, SunHeadingDeg: 180f, // south at noon SunPitchDeg: 70f, - SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish - AmbientColor: new Vector3(0.5f, 0.5f, 0.55f), + DirColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish + DirBright: 1.0f, + AmbColor: new Vector3(0.5f, 0.5f, 0.55f), + AmbBright: 1.0f, FogColor: new Vector3(0.7f, 0.75f, 0.85f), FogDensity: 0.0008f, FogStart: 120f, @@ -148,8 +187,10 @@ public sealed class SkyStateProvider Begin: 0.75f, SunHeadingDeg: 270f, // west at dusk SunPitchDeg: 0f, - SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red - AmbientColor: new Vector3(0.35f, 0.25f, 0.25f), + DirColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red + DirBright: 1.0f, + AmbColor: new Vector3(0.35f, 0.25f, 0.25f), + AmbBright: 1.0f, FogColor: new Vector3(0.85f, 0.45f, 0.35f), FogDensity: 0.002f, FogStart: 60f, @@ -194,17 +235,25 @@ public sealed class SkyStateProvider // Angular lerp for sun heading: pick shortest arc. float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u); - // Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere). + // Retail-faithful interpolation: lerp DirColor / DirBright / + // AmbColor / AmbBright as SEPARATE CHANNELS, not as the + // pre-multiplied product. Mirrors SkyDesc::GetLighting at + // 0x00500ac9 (decomp lines 261317-261331). The post-multiplied + // SunColor / AmbientColor are computed properties on the result. + // Fog mode doesn't interpolate — pick k1's mode (retail uses + // Linear everywhere). return new SkyKeyframe( Begin: t, SunHeadingDeg: heading, SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u), - SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u), - AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u), - FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u), + DirColor: Vector3.Lerp(k1.DirColor, k2.DirColor, u), + DirBright: Lerp(k1.DirBright, k2.DirBright, u), + AmbColor: Vector3.Lerp(k1.AmbColor, k2.AmbColor, u), + AmbBright: Lerp(k1.AmbBright, k2.AmbBright, u), + FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u), FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u), - FogStart: Lerp(k1.FogStart, k2.FogStart, u), - FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u), + FogStart: Lerp(k1.FogStart, k2.FogStart, u), + FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u), FogMode: k1.FogMode); } diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index 272bdc5..7eac91a 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -56,8 +56,10 @@ public sealed class SkyStateTests Begin: 0.5f, SunHeadingDeg: 180f, // south SunPitchDeg: 70f, - SunColor: Vector3.One, - AmbientColor: Vector3.One, + DirColor: Vector3.One, + DirBright: 1f, + AmbColor: Vector3.One, + AmbBright: 1f, FogColor: Vector3.One, FogDensity: 0.001f); diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs index b1d6c24..7acf0d1 100644 --- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs +++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs @@ -58,8 +58,10 @@ public sealed class WorldTimeDebugTests Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 90f, - SunColor: System.Numerics.Vector3.One, - AmbientColor: System.Numerics.Vector3.One, + DirColor: System.Numerics.Vector3.One, + DirBright: 1f, + AmbColor: System.Numerics.Vector3.One, + AmbBright: 1f, FogColor: System.Numerics.Vector3.Zero, FogDensity: 0f), }); From 97fc1b51d87dc836c4a187ad7c848694792bbb1e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 19:49:51 +0200 Subject: [PATCH 13/18] fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three retail-faithful sky/weather composite fixes (one cohesive commit because they touch the same per-Surface flag plumbing path). 1. Surface.Translucency is OPACITY, not (1 - opacity). Retail D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) computes `curr_alpha = _ftol2(translucency × 255)` and writes that directly as vertex.color.alpha. ACViewer (TextureCache.cs:142) and WorldBuilder (ObjectMeshManager.cs:1115) both use `1 - translucency` and are wrong by the same misread. Cloud surface 0x08000023 has Translucency=0.25; under the old (1-x) formula opacity was 0.75, making clouds 3× too bright vs retail. Flipped to use translucency directly. Gated on the Translucent flag (0x10) so non-Translucent surfaces (which carry Translucency=0 in the dat) keep opacity 1.0 instead of going invisible. 2. Sky fog re-enabled with a "fog floor" mitigation. Disabled 2026-04-24 because Dereth sky meshes are authored at radii 1050-1820m while storm-keyframe FogEnd is ~400m, which would saturate the entire dome to flat fogColor and destroy stars/moon/dome texture. Retail visibly DOES fog its sky, mechanism still un-pinned. Workaround: clamp `vFogFactor` to a minimum of SKY_FOG_FLOOR=0.2 so the dome shows AT LEAST 20% raw texture even at extreme distances. Tuned via dual- client visual comparison; preserves stars/moon while letting the horizon haze visibly in low-FogEnd keyframes. 3. Additive sky surfaces skip fog entirely. Retail D3DPolyRender::SetSurface at 0x59c882 calls SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) is set — sun, moon, stars, additive cloud sheets render unfogged. Without this gate the sun dimmed to fog color at horizon dusk/dawn instead of staying bright. Plumbed via new `uApplyFog` shader uniform driven by the existing SubMeshGpu.IsAdditive boolean (already set from TranslucencyKind.Additive at upload time). User visually verified all three vs retail screenshots in Holtburg. Tests: 1223 pass. --- src/AcDream.App/Rendering/Shaders/sky.frag | 83 ++++++++++++++------ src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 28 +++++-- src/AcDream.Core/Meshing/GfxObjMesh.cs | 32 +++++--- src/AcDream.Core/Meshing/GfxObjSubMesh.cs | 33 ++++---- 4 files changed, 121 insertions(+), 55 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 3a25b3a..e492f6e 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -22,13 +22,18 @@ out vec4 fragColor; uniform sampler2D uDiffuse; uniform float uTransparency; // 0 = fully visible, 1 = fully transparent -// Surface.Translucency float (0..1) — distinct from uTransparency -// (which is the per-keyframe Replace override). Retail -// D3DPolyRender::SetSurface at 0x59c767 reads this when the Surface's -// Translucent (0x10) bit is set and converts to per-vertex alpha; -// ACViewer + WorldBuilder both apply opacity = (1 - x). Both factors -// compose multiplicatively into final fragment alpha. For non-Translucent -// surfaces uSurfTranslucency = 0 ⇒ no effect. +// 1.0 = apply fog mix to this submesh; 0.0 = skip fog (Additive sky +// surfaces — sun/moon/stars per retail SetFFFogAlphaDisabled(1) at +// D3DPolyRender::SetSurface 0x59c882). Set per-submesh on the CPU side. +uniform float uApplyFog; +// Surface.Translucency float (0..1) used DIRECTLY as opacity (NOT 1-x). +// Distinct from uTransparency (per-keyframe Replace override). Retail +// D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) reads +// Surface.Translucency when the Translucent (0x10) bit is set and feeds +// _ftol2(translucency × 255) directly as vertex alpha. ACViewer +// (TextureCache.cs:142) + WorldBuilder (ObjectMeshManager.cs:1115) both +// invert it (1-x) and are wrong. For non-Translucent surfaces the CPU +// side (GfxObjMesh.Build) sets uSurfTranslucency = 1.0 ⇒ no effect. uniform float uSurfTranslucency; // Shared SceneLighting UBO — fog params drive the mix, flash channel @@ -60,14 +65,36 @@ void main() { // composite. vec3 rgb = sampled.rgb * vTint; - // Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED - // 2026-04-24 — Dereth sky meshes are authored at radii 1050–1820m - // while the midnight keyframe's FogEnd is only 400m. Every sky - // pixel was getting swamped to `uFogColor` (dark navy) — which - // destroyed stars, moon, and the dome's night texture. Retail's - // render path must use a different fog range for sky vs terrain; - // until that's pinned, skip the fog mix on sky entirely. - // rgb = mix(uFogColor.rgb, rgb, vFogFactor); + // Retail-faithful sky fog mix with a "fog floor" mitigation: + // + // Dereth sky meshes are authored at radii 1050–1820m. At midnight + // (storm keyframes FogEnd ~400m) the raw vFogFactor saturates to 0 + // for every dome pixel — `mix(fogColor, rgb, 0)` would render the + // entire dome as flat fogColor, destroying stars / moon / texture. + // That was the reason fog was disabled on sky 2026-04-24 (issue #4). + // + // Retail clearly DOES apply fog to its sky meshes — distant horizon + // mountains and the dome itself fade toward the fog color in retail + // screenshots. Mechanism unknown (sky-specific FogEnd? elevation- + // weighted? different formula?). Until pinned, the workaround is + // a clamp on the minimum fog factor so the dome NEVER mixes more + // than (1 - SKY_FOG_FLOOR) toward fogColor — preserves stars/moon + // while still letting the horizon haze visibly in low-FogEnd + // keyframes. + // + // SKY_FOG_FLOOR=0.2 means dome shows AT LEAST 20% raw texture, AT + // MOST 80% fog color even at extreme distances. Tuned via dual- + // client visual comparison 2026-04-27 — adjust if night sky goes + // back to flat-fog or stays too vivid vs retail. + // Skip fog mix entirely on Additive surfaces (sun, moon, stars, + // additive cloud sheets) — retail's SetFFFogAlphaDisabled(1) at + // D3DPolyRender::SetSurface 0x59c882. Without this gate the sun + // dims to fog color at horizon, which doesn't match retail. + if (uApplyFog > 0.5) { + const float SKY_FOG_FLOOR = 0.2; + float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR); + rgb = mix(uFogColor.rgb, rgb, skyFogFactor); + } // Lightning additive bump — client-driven during storm flashes. // NOTE: the exact retail mechanism for lightning visual is still @@ -84,14 +111,24 @@ void main() { float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); - // Final fragment alpha = texture-alpha × keyframe-replace-opacity - // × surface-translucency-opacity. Both opacity factors are - // (1 - x) form per ACViewer (TextureCache.cs:142) and WorldBuilder - // (ObjectMeshManager.cs:1115). For the rain mesh 0x01004C42/0x01004C44: - // sampled.a = 1.0 (R8G8B8 texture), uTransparency = 0, - // uSurfTranslucency = 0.5 → a = 0.5 → with the (SrcAlpha, One) blend - // the streak contribution is halved, matching retail's curr_alpha = 127. - float a = sampled.a * (1.0 - uTransparency) * (1.0 - uSurfTranslucency); + // Final fragment alpha: + // uTransparency — keyframe-replace transparency override (0..1). + // 0 = fully visible, 1 = fully transparent. + // Applied as (1 - x). + // uSurfTranslucency — the dat's Surface.Translucency value when the + // Translucent flag is set, else 1.0. Despite the + // name, retail uses this as OPACITY directly (per + // D3DPolyRender::SetSurface at 0x59c7a6 which + // writes _ftol2(translucency × 255) into vertex + // alpha). Multiply directly — NOT (1 - x). + // + // For the rain mesh 0x01004C42/4C44 (translucency=0.5): a = 1*1*0.5 = 0.5 + // matches retail curr_alpha=127, halves the additive streak. + // For cloud surface 0x08000023 (translucency=0.25): a = 1*1*0.25 = 0.25 + // matches retail curr_alpha=63, dim cloud (was 3× too bright with + // the previous 1-x formula). + // For non-Translucent surfaces uSurfTranslucency = 1.0, no effect. + float a = sampled.a * (1.0 - uTransparency) * uSurfTranslucency; if (a < 0.01) discard; fragColor = vec4(rgb, a); } diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index b3df2b8..76f5fa6 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -325,16 +325,28 @@ public sealed unsafe class SkyRenderer : IDisposable _shader.SetFloat("uEmissive", effEmissive); // Retail per-Surface translucency override (D3DPolyRender::SetSurface - // at 0x59c767): when the Surface's Translucent (0x10) bit is set, - // its translucency float drives per-vertex alpha. Both ACViewer - // and WorldBuilder render this as opacity = (1 - x). The shader - // multiplies output alpha by (1 - uSurfTranslucency); for surfaces - // without the bit, SurfTranslucency=0 ⇒ no effect. Critical for - // the rain mesh 0x01004C42/0x01004C44 (Translucency=0.5) so its - // streaks contribute at half intensity instead of full under the - // additive (SrcAlpha, One) blend. + // at 0x59c7a6, decomp 425255-425260): when the Surface's + // Translucent (0x10) bit is set, retail computes + // curr_alpha = _ftol2(translucency × 255) and writes it as vertex + // alpha — i.e. the dat's Translucency float is the OPACITY + // directly, NOT inverted. ACViewer and WorldBuilder both invert + // it (1 - x) and are wrong by the same misread. The shader uses + // it directly as an opacity multiplier; for non-Translucent + // surfaces the GfxObjMesh.Build() path keeps SurfTranslucency=1.0 + // (no effect). Critical for rain (Translucency=0.5 → opacity 0.5) + // and clouds (Translucency=0.25 → opacity 0.25, dim like retail). _shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency); + // Retail D3DPolyRender::SetSurface at 0x59c882 calls + // SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) + // is set on the Surface — so the sun, moon, stars, and any + // additive cloud sheet are drawn WITHOUT fog. Skipping fog + // on additive surfaces keeps the sun bright at horizon + // dusk/dawn (where fog would otherwise dim it to fog color). + // Non-additive sky meshes (the dome, opaque cloud layers) + // still mix toward fog with the floor mitigation in sky.frag. + _shader.SetFloat("uApplyFog", sub.IsAdditive ? 0f : 1f); + uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 6ddb68c..47f4368 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -200,7 +200,21 @@ public static class GfxObjMesh // docs/research/2026-04-23-sky-retail-verbatim.md §6). var translucency = TranslucencyKind.Opaque; var luminosity = 0f; - var surfTranslucency = 0f; + // SurfTranslucency = the OPACITY multiplier the shader applies + // to fragment alpha. 1.0 = fully opaque (default, non-Translucent + // surfaces). For Translucent-flag surfaces, retail's + // D3DPolyRender::SetSurface at 0x59c7a6 (decomp lines 425255- + // 425260) computes curr_alpha = _ftol2(translucency × 255) and + // feeds that as vertex.color.alpha — so the dat's Translucency + // float is the OPACITY directly (NOT inverted). For rain + // (translucency=0.5) opacity is 0.5; for cloud surface + // 0x08000023 (translucency=0.25) opacity is 0.25 — that's why + // retail's clouds are dim and acdream's were 3× too bright + // before this fix (we used 1-translucency, inverting the + // semantic). ACViewer's TextureCache.cs:142 and WorldBuilder's + // ObjectMeshManager.cs:1115 also use 1-translucency and are + // both wrong by the same misread. + var surfTranslucency = 1.0f; if (dats is not null) { var surface = dats.Get(surfaceId); @@ -208,15 +222,13 @@ public static class GfxObjMesh { translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); luminosity = surface.Luminosity; - // Retail D3DPolyRender::SetSurface at 0x59c767: when the - // Translucent (0x10) flag is set, the surface's - // Translucency float drives per-vertex alpha. Both - // ACViewer and WorldBuilder apply opacity = (1 - x). - // For the rain Surface 0x080000C5 this is 0.5. Carrying - // the float verbatim and converting to opacity in the - // shader keeps non-Translucent surfaces (Translucency=0) - // identical to the previous behavior. - surfTranslucency = surface.Translucency; + // Apply the dat's Translucency value as opacity ONLY + // when the Translucent flag (0x10) is set on the + // Surface. Without this gate, surfaces with + // Translucency=0 (non-Translucent default) would + // render fully transparent. + if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0) + surfTranslucency = surface.Translucency; } } diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index f85d5aa..31542a6 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -55,20 +55,25 @@ public sealed record GfxObjSubMesh( public bool NeedsUvRepeat { get; init; } = false; /// - /// Surface.Translucency float (0..1 — distinct from the + /// Surface.Translucency float (0..1) treated as an OPACITY + /// multiplier on fragment alpha. 1.0 = fully opaque (default for + /// non-Translucent surfaces). Distinct from the /// classifier above, which buckets the - /// flag bits). Retail's D3DPolyRender::SetSurface at - /// 0x59c767 reads this when the Translucent (0x10) bit - /// is set on the surface and feeds it into the per-vertex alpha - /// (curr_alpha); the rasterizer then multiplies fragment alpha - /// by (1 - translucency) so the resulting opacity is - /// 1 - x. ACViewer (TextureCache.cs:142) and WorldBuilder - /// (ObjectMeshManager.cs:1115) both use the same convention. - /// For the rain Surface 0x080000C5, Translucency = 0.5 ⇒ - /// opacity = 0.5; with the (SrcAlpha, One) additive blend the - /// rain streaks contribute at half intensity instead of full. - /// Defaults to 0.0 (fully opaque) so non-translucent surfaces render - /// through the normal lighting path without change. + /// flag bits. Retail's D3DPolyRender::SetSurface at + /// 0x59c7a6 (decomp lines 425255-425260) reads + /// Surface.Translucency when the Translucent (0x10) bit + /// is set, computes curr_alpha = _ftol2(translucency × 255), + /// and writes that as vertex alpha — i.e. the dat's Translucency float + /// is used DIRECTLY as opacity, NOT inverted. ACViewer + /// (TextureCache.cs:142) and WorldBuilder + /// (ObjectMeshManager.cs:1115) both use 1 - translucency + /// and are wrong by the same misread. + /// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5; + /// with the (SrcAlpha, One) additive blend the rain streaks + /// contribute at half intensity. For cloud surface 0x08000023 + /// (translucency=0.25): opacity = 0.25 (matches retail's dim clouds). + /// Defaults to 1.0 (fully opaque) so non-Translucent surfaces render + /// at full opacity without change. /// - public float SurfTranslucency { get; init; } = 0f; + public float SurfTranslucency { get; init; } = 1f; } From 05a8a7209fdb02413d254ec907b55573ecd73610 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 22:42:53 +0200 Subject: [PATCH 14/18] fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent investigations (in-house decomp re-check + two external agent reports) converged on the same root cause for the "too blue-white sky" symptom: acdream computed SunColor = DirColor × DirBright and AmbientColor = AmbColor × AmbBright. Retail computes them from the magnitude of a specially-shaped sun vector instead. Per the named retail decomp: SkyDesc::GetLighting at 0x00500ac9 (decomp 261343-261353): sunVec.x = sin(H_rad) × DirBright × cos(P_rad) sunVec.y = cos(P_rad) ← NOT scaled by DirBright sunVec.z = DirBright × sin(P_rad) PrimD3DRender::UpdateLightsInternal at 0x0059b57c (decomp 424118): D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²) SmartBox::SetWorldAmbientLight callsite at 0x0050560b (decomp 267117): SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ...) Y stays unscaled by DirBright on purpose, so |sunVec| ≠ DirBright in general — the magnitude varies with sun pitch/heading. That's what gives retail's "sun feels stronger when it's overhead, ambient warms up at midday" behavior we were missing. Added SkyStateProvider.RetailSunVector(kf) that builds the vector verbatim. SkyKeyframe.SunColor / AmbientColor now compose via |sunVec|. SunDirectionFromKeyframe normalizes the same vector (replaces our geometrically-clean spherical convention which didn't match retail's deliberate Y-decoupled-from-heading shape). Tests: - Replaced the linear-interp assumption in Interpolate_BetweenKeyframes_LerpsColors with a test on the RAW inputs (DirColor, AmbBright, etc.) — those still lerp linearly; the composite SunColor doesn't, intentionally. - Added 4 golden-value tests for the new formulas (RetailSunVector_AtZenith, _AtHorizonNorth, SunColor_UsesRetailMagnitudeNotDirBrightDirectly, AmbientColor_BoostsByTwentyPercentOfSunVectorLength). - Updated stale LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness test to LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude with the new expected magnitude. User visually verified — acdream's sky shifted from blue-white toward the warm tint retail shows at the same keyframe. 1227 tests pass. --- src/AcDream.Core/World/SkyState.cs | 99 +++++++++++++++---- .../World/SkyDescLoaderTests.cs | 17 +++- .../AcDream.Core.Tests/World/SkyStateTests.cs | 98 +++++++++++++++++- 3 files changed, 186 insertions(+), 28 deletions(-) diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 31fdf3b..5acf2d3 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -67,19 +67,48 @@ public readonly record struct SkyKeyframe( FogMode FogMode = FogMode.Linear) { /// - /// Final directional sun color used by the shader = - /// × . Computed property - /// so the storage stays as separate channels (for retail-faithful - /// keyframe interpolation) while the shader interface stays simple. + /// Final directional sun color the shader feeds into N·L lighting. + /// Retail-faithful magnitude formula: + /// SunColor = DirColor × |sunVec| + /// where sunVec is retail's heading+pitch+brightness vector + /// (see ). + /// + /// + /// Why |sunVec| instead of DirBright directly: retail's + /// PrimD3DRender::UpdateLightsInternal at 0x0059b57c + /// (decomp line 424118-424119) computes + /// D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²) + /// from the sun vector SkyDesc::GetLighting built at + /// 0x00500ac9 (decomp lines 261343-261353): + /// + /// sunVec.x = sin(H) × DirBright × cos(P) + /// sunVec.y = cos(P) // NOT scaled by DirBright + /// sunVec.z = DirBright × sin(P) + /// + /// Because Y is unscaled by DirBright, |sunVec| ≠ + /// DirBright in general — it varies with sun pitch and heading. + /// Using DirBright alone underweighted the warm directional + /// term, letting the cool ambient/fog dominate ⇒ acdream rendered + /// blue-white at keyframes where retail looked warm-gray. + /// /// - public Vector3 SunColor => DirColor * DirBright; + public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length(); /// - /// Final ambient color used by the shader = - /// × . See - /// for the rationale. + /// Final ambient color the shader feeds into the per-vertex tint. + /// Retail-faithful magnitude formula: + /// AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|) + /// matching SmartBox::SetWorldAmbientLight as called at + /// 0x0050560b (decomp line 267117): + /// SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color) + /// Retail boosts the ambient brightness by 20% of the sun-vector + /// magnitude — i.e. ambient feels warmer when the sun is up, cooler + /// at night. acdream previously used AmbBright alone, which + /// is roughly 44% too dim mid-day ⇒ contributed to the blue-white + /// bias because the warm fill was missing. /// - public Vector3 AmbientColor => AmbColor * AmbBright; + public Vector3 AmbientColor => + AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length()); } /// @@ -271,22 +300,52 @@ public sealed class SkyStateProvider return aDeg + delta * u; } + /// + /// Retail's raw sun vector (NOT normalized) — the same vector + /// SkyDesc::GetLighting writes at 0x00500ac9 + /// (decomp lines 261343, 261352, 261353): + /// + /// sunVec.x = sin(H_rad) × DirBright × cos(P_rad) + /// sunVec.y = cos(P_rad) // NOT scaled by DirBright + /// sunVec.z = DirBright × sin(P_rad) + /// + /// Y is unscaled by brightness on purpose — that's what makes + /// |sunVec|DirBright in general (the magnitude varies + /// with pitch/heading, which is the basis for retail's "sun is brighter + /// in some configurations than others" lighting behavior). The shader's + /// uSunDir uniform uses the NORMALIZED vector for N·L; the + /// magnitude feeds intensity and + /// the ambient brightness boost in . + /// + public static Vector3 RetailSunVector(SkyKeyframe kf) + { + float h = kf.SunHeadingDeg * (MathF.PI / 180f); + float p = kf.SunPitchDeg * (MathF.PI / 180f); + float cosP = MathF.Cos(p); + float sinP = MathF.Sin(p); + float B = kf.DirBright; + return new Vector3( + MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P) + cosP, // y = cos(P) ← unscaled by B + B * sinP); // z = B × sin(P) + } + /// /// World-space sun direction unit vector pointing FROM the surface - /// TOWARDS the sun. Derived from heading + pitch in the returned - /// keyframe — shader sunDir uniform should use -this so lighting - /// math (N·L) works correctly for the side facing the sun. + /// TOWARDS the sun, derived from and + /// normalized. The shader sunDir uniform should use this directly + /// (or -this if the lighting math wants the L-vector pointing AT the + /// surface). The previous implementation used standard spherical + /// coordinates (sin(H)cos(P), cos(H)cos(P), sin(P)) which didn't match + /// retail's deliberate Y-decoupled-from-heading convention. Switching + /// to the retail vector subtly tilts the lighting on objects but + /// matches retail's screenshots when both clients view the same scene. /// public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf) { - float yaw = kf.SunHeadingDeg * (MathF.PI / 180f); - float pit = kf.SunPitchDeg * (MathF.PI / 180f); - // Heading 0 = +Y (north), +X=east. Pitch up from horizon. - float cosP = MathF.Cos(pit); - return new Vector3( - MathF.Sin(yaw) * cosP, - MathF.Cos(yaw) * cosP, - MathF.Sin(pit)); + var v = RetailSunVector(kf); + float len = v.Length(); + return len > 1e-6f ? v / len : Vector3.UnitZ; } } diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index bbb619d..3331e85 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -73,15 +73,26 @@ public sealed class SkyDescLoaderTests } [Fact] - public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness() + public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude() { + // The loader stores DirColor and DirBright RAW. The SunColor property + // composes them via |sunVec| per retail's UpdateLightsInternal at + // 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²) + // where the sun vector is built from heading/pitch/brightness with + // Y unscaled by brightness (decomp 261352). + // + // For this region: H=180°, P=70°, B=1.5 + // sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70)) + // = (0, 0.342, 1.410) + // |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509 + // DirColor.X = 200/255 = 0.7843 + // SunColor.X = 0.7843 × 1.4509 = 1.138 var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200); var loaded = SkyDescLoader.LoadFromRegion(region); Assert.NotNull(loaded); var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe; - // R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176 - Assert.InRange(kf.SunColor.X, 1.17f, 1.19f); + Assert.InRange(kf.SunColor.X, 1.13f, 1.15f); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index 7eac91a..bd3bc73 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -25,17 +25,105 @@ public sealed class SkyStateTests } [Fact] - public void Interpolate_BetweenKeyframes_LerpsColors() + public void Interpolate_BetweenKeyframes_LerpsRawInputs() { var sky = SkyStateProvider.Default(); var dawn = sky.Interpolate(0.25f); var noon = sky.Interpolate(0.5f); var midPt = sky.Interpolate(0.375f); - // Midpoint should fall between dawn & noon for sun color Y (green channel). - float low = System.Math.Min(dawn.SunColor.Y, noon.SunColor.Y); - float high = System.Math.Max(dawn.SunColor.Y, noon.SunColor.Y); - Assert.InRange(midPt.SunColor.Y, low, high); + // The RAW per-channel inputs (DirColor, AmbColor, brightness scalars) + // lerp linearly between adjacent keyframes — that's the retail-faithful + // separate-channel interpolation. The composite SunColor / AmbientColor + // properties intentionally do NOT lerp linearly (their magnitude + // depends nonlinearly on heading/pitch/brightness via the retail + // sun-vector formula), so we assert on the raw inputs here. + float low = System.Math.Min(dawn.DirColor.Y, noon.DirColor.Y); + float high = System.Math.Max(dawn.DirColor.Y, noon.DirColor.Y); + Assert.InRange(midPt.DirColor.Y, low, high); + } + + [Fact] + public void RetailSunVector_AtZenith_HasMagnitudeEqualToBrightness() + { + // Sun straight up (P=90°): cos(P)=0, sin(P)=1. + // sunVec = (sin(H)×B×0, 0, B×1) = (0, 0, B) + // |sunVec| = B + var kf = new SkyKeyframe( + Begin: 0.5f, + SunHeadingDeg: 0f, + SunPitchDeg: 90f, + DirColor: Vector3.One, + DirBright: 1.5f, + AmbColor: Vector3.One, + AmbBright: 0.3f, + FogColor: Vector3.One, + FogDensity: 0f); + + var v = SkyStateProvider.RetailSunVector(kf); + Assert.InRange(v.Length(), 1.49f, 1.51f); + } + + [Fact] + public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne() + { + // Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0. + // sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0) + // |sunVec| = 1 regardless of B (because Y is unscaled by B) + var kf = new SkyKeyframe( + Begin: 0f, + SunHeadingDeg: 0f, + SunPitchDeg: 0f, + DirColor: Vector3.One, + DirBright: 2.0f, // anything + AmbColor: Vector3.One, + AmbBright: 1f, + FogColor: Vector3.One, + FogDensity: 0f); + + var v = SkyStateProvider.RetailSunVector(kf); + Assert.InRange(v.Length(), 0.99f, 1.01f); + } + + [Fact] + public void SunColor_UsesRetailMagnitudeNotDirBrightDirectly() + { + // At sun pitch 90° (zenith) with H=0, B=2: |sunVec| = 2. + // SunColor = DirColor × |sunVec| = (0.5, 0.5, 0.5) × 2 = (1, 1, 1). + var kf = new SkyKeyframe( + Begin: 0.5f, + SunHeadingDeg: 0f, + SunPitchDeg: 90f, + DirColor: new Vector3(0.5f, 0.5f, 0.5f), + DirBright: 2.0f, + AmbColor: Vector3.One, + AmbBright: 0.3f, + FogColor: Vector3.One, + FogDensity: 0f); + + Assert.InRange(kf.SunColor.X, 0.99f, 1.01f); + Assert.InRange(kf.SunColor.Y, 0.99f, 1.01f); + Assert.InRange(kf.SunColor.Z, 0.99f, 1.01f); + } + + [Fact] + public void AmbientColor_BoostsByTwentyPercentOfSunVectorLength() + { + // |sunVec| = 1 (horizon north), AmbBright = 0.4, AmbColor = (1,1,1). + // AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|) + // = (1,1,1) × (0.4 + 0.2) = (0.6, 0.6, 0.6). + var kf = new SkyKeyframe( + Begin: 0f, + SunHeadingDeg: 0f, + SunPitchDeg: 0f, + DirColor: Vector3.One, + DirBright: 1f, + AmbColor: Vector3.One, + AmbBright: 0.4f, + FogColor: Vector3.One, + FogDensity: 0f); + + Assert.InRange(kf.AmbientColor.X, 0.59f, 0.61f); } [Fact] From 034a684f02d2779bfb21872e2a4457e7dd04168b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 22:43:14 +0200 Subject: [PATCH 15/18] fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre/post-scene sky pass split was using SkyObjectData.IsWeather (bit 0x04) — the wrong bit. Per the named retail decomp: GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp 269036: MakeObject(this, gfx_id, &tex_velocity, (properties & 1), // arg4: post-scene flag (properties & 4)); // arg5: weather gate GameSky::MakeObject at 0x00506ee0 / decomp 268656: if (arg4 != 0) AddObjectToSingleCell(result, after_sky_cell); // post-scene else AddObjectToSingleCell(result, before_sky_cell); // pre-scene So bit 0x01 routes between before_sky_cell (rendered pre-scene by GameSky::Draw(0)) and after_sky_cell (rendered post-scene by GameSky::Draw(1)). Bit 0x04 is independent — it gates whether the object is instantiated at all when LScape::weather_enabled is false. In Dereth's Rainy DayGroup this matters for the rain cylinders: 0x01004C42 Props=0x04 (bit 0x04 only) → pre-scene + weather-gated 0x01004C44 Props=0x05 (bits 0x01+0x04) → post-scene + weather-gated 0x01004C35 Props=0x02 (bit 0x02 only) → pre-scene (cloud, fog-hide) Before this fix acdream put BOTH rain cylinders in the post-scene pass (because both have bit 0x04). That double-rendered foreground rain — explained why acdream's foreground rain looked thicker than retail's. Now only 0x01004C44 is foreground; 0x01004C42 renders with the sky dome. Added SkyObjectData.IsPostScene (bit 0x01) with citations. Renamed the internal RenderPass parameter weatherPass → postScenePass and updated both the partition criterion and the -120m foreground-rain Z offset to gate on it. Public RenderSky / RenderWeather entry points kept their names for API stability; doc comments updated to explain the bit semantics. Independent confirmation from one of the user's external code-review agents — the report's Setup-objects-silently-dropped finding is the remaining defect in the same family (Setup IDs 0x020xxx aren't loaded by EnsureMeshUploaded; deferred to a separate phase). 1227 tests pass. --- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 63 ++++++++++++-------- src/AcDream.Core/World/SkyDescLoader.cs | 46 ++++++++++---- 2 files changed, 72 insertions(+), 37 deletions(-) diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 76f5fa6..6b91c35 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -107,21 +107,27 @@ public sealed unsafe class SkyRenderer : IDisposable float dayFraction, DayGroupData? group, SkyKeyframe keyframe) - => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: false); + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false); /// - /// Draw the WEATHER sky objects (the foreground rain mesh - /// 0x01004C42/0x01004C44 on Rainy DayGroups, plus the - /// per-storm 5cm flash dummies — every SkyObject with - /// Properties & 0x04 != 0). Called AFTER the scene so the - /// rain meshes paint on top of terrain and entities — that's the - /// retail-faithful order from LScape::draw at - /// 0x00506330, where GameSky::Draw(1) fires after the - /// DrawBlock loop. With depth-test disabled and additive blend - /// (the rain Surface flag 0x080000C5 includes Additive), the - /// 815m-tall rain cylinder's bright streak texels add over the scene - /// — making rain appear in the air between camera and character - /// instead of only at the horizon. + /// Draw the POST-SCENE sky objects (the foreground rain mesh + /// 0x01004C44 on Rainy DayGroups, plus any other SkyObject with + /// Properties & 0x01 != 0). Called AFTER the scene so these + /// meshes paint on top of terrain and entities — retail-faithful order + /// from LScape::draw at 0x00506330, where + /// GameSky::Draw(1) fires after the DrawBlock loop and + /// renders the after_sky_cell contents. With depth-test + /// disabled and additive blend (the rain Surface flag includes + /// Additive), the 815m-tall rain cylinder's bright streak texels add + /// over the scene — making rain appear in the air between camera and + /// character instead of only at the horizon. + /// + /// Method name kept as RenderWeather for API stability; the + /// pass actually partitions on + /// (Properties bit 0x01), not + /// (bit 0x04). The two bits are independent in retail per + /// GameSky::CreateDeletePhysicsObjects at 0x005073c0. + /// /// public void RenderWeather( ICamera camera, @@ -129,14 +135,15 @@ public sealed unsafe class SkyRenderer : IDisposable float dayFraction, DayGroupData? group, SkyKeyframe keyframe) - => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: true); + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true); /// /// Shared pass for and . /// Sets up the same GL state for both (depth-test off, additive + /// alpha-blend per submesh, camera-anchored translation) and iterates /// only the SkyObjects matching the requested partition by - /// . + /// — bit 0x01 per the + /// retail decomp at GameSky::MakeObject (0x00506ee0). /// private void RenderPass( ICamera camera, @@ -144,7 +151,7 @@ public sealed unsafe class SkyRenderer : IDisposable float dayFraction, DayGroupData? group, SkyKeyframe keyframe, - bool weatherPass) + bool postScenePass) { if (group is null || group.SkyObjects.Count == 0) return; @@ -197,14 +204,20 @@ public sealed unsafe class SkyRenderer : IDisposable for (int i = 0; i < group.SkyObjects.Count; i++) { var obj = group.SkyObjects[i]; - // Partition by weather flag — the caller chose either the - // pre-scene sky pass (non-weather) or the post-scene weather - // pass (weather only). Mirrors retail GameSky::Draw at - // 0x00506ff0 where arg2==0 iterates non-weather sky_obj - // entries (filtered by property bit 0x04 == 0 inside the - // loop) and arg2==1 draws after_sky_cell which only contains - // weather objects. - if (obj.IsWeather != weatherPass) continue; + // Partition by post-scene flag (Properties bit 0x01) — the + // caller chose either the pre-scene sky pass (bit clear) or + // the post-scene pass (bit set). Mirrors retail + // GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp + // line 269036 which routes (Properties & 1) into + // before_sky_cell vs after_sky_cell, and GameSky::Draw at + // 0x00506ff0 which renders those cells in the two passes. + // NOTE: bit 0x04 (IsWeather) is independent — it gates whether + // the object is instantiated when weather_enabled is false. + // Earlier acdream incorrectly used IsWeather for this + // partition, putting the outer rain cylinder 0x01004C42 + // (Props=0x04, NO bit 0x01) into the post-scene pass with the + // foreground rain — double-thick rain not matching retail. + if (obj.IsPostScene != postScenePass) continue; if (!obj.IsVisible(dayFraction)) continue; // Apply per-keyframe replace overrides. @@ -267,7 +280,7 @@ public sealed unsafe class SkyRenderer : IDisposable // (camera-119.89)..(camera+694.90) in view space — camera // is inside, looking in any direction shows surrounding // walls — the volumetric foreground-rain look retail has. - if (weatherPass) + if (postScenePass) model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f); _shader.SetMatrix4("uModel", model); diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index 5ef8245..ada2753 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -37,22 +37,44 @@ public sealed class SkyObjectData public uint Properties; /// - /// True when this SkyObject is flagged as weather (Properties bit - /// 0x04). Per the named retail decomp, + /// True when this SkyObject is gated on the weather system (Properties + /// bit 0x04). Per the named retail decomp, /// GameSky::CreateDeletePhysicsObjects at 0x005073c0 - /// passes Properties & 0x04 as arg5 of - /// GameSky::MakeObject (0x00506ee0) — when set, the - /// CPhysicsObj is added to after_sky_cell instead of - /// before_sky_cell, and GameSky::Draw(arg2=1) at - /// 0x00506ff0 draws that cell after the scene. acdream - /// uses this flag to split the sky pass: non-weather objects render - /// pre-scene (so terrain and entities z-test on top), weather meshes - /// (e.g. the 815m-tall rain cylinders 0x01004C42/0x01004C44) - /// render post-scene with depth-test off so they overlay foreground - /// geometry — matching retail's volumetric foreground-rain look. + /// passes Properties & 4 as arg5 of + /// GameSky::MakeObject (0x00506ee0); the inner + /// (arg5 == 0 || LScape::weather_enabled != 0) guard at decomp + /// line 268630 means weather-flagged objects only get instantiated when + /// the global weather flag is on. This bit does not control + /// pre/post-scene placement — that's . + /// acdream currently always renders weather-flagged objects (we don't + /// honor a weather_enabled toggle yet); when we add one, this flag is + /// the gate. /// public bool IsWeather => (Properties & 0x04u) != 0u; + /// + /// True when this SkyObject renders after the world scene + /// (Properties bit 0x01) — i.e. as foreground over terrain and + /// entities. Per the named retail decomp, + /// GameSky::CreateDeletePhysicsObjects passes + /// Properties & 1 as arg4 of + /// GameSky::MakeObject (decomp line 269036); MakeObject at + /// decomp 268656 routes arg4 != 0 objects into + /// after_sky_cell instead of before_sky_cell, and + /// GameSky::Draw(arg2=1) at 0x00506ff0 draws + /// after_sky_cell as a separate post-scene pass. + /// + /// In Dereth's Rainy DayGroup this distinguishes the two rain + /// cylinders: 0x01004C44 (Props=0x05) is foreground rain + /// rendered after terrain; 0x01004C42 (Props=0x04 alone) is + /// background rain rendered with the sky dome. Earlier + /// versions of acdream incorrectly split on + /// (bit 0x04) so both rain meshes ended up in the post-scene pass, + /// double-rendering rain in the foreground. + /// + /// + public bool IsPostScene => (Properties & 0x01u) != 0u; + /// Object is visible at day-fraction /// by retail's begin/end semantics (r12 §2). Three cases: /// From 375065ba9441dd5f2c229d6893c8ff362acceeb2 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 23:23:48 +0200 Subject: [PATCH 16/18] fix(meshing): Translucent flag overrides Additive blend per retail SetSurface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acdream's TranslucencyKindExtensions.FromSurfaceType picked Additive first (priority order). Retail's D3DPolyRender::SetSurface at 0x0059c4d0 (decomp 425083+) has a different resolution: when the Translucent flag (0x10) is set AND either Base1ClipMap (0x04) is set OR the surface would otherwise be opaque (no Additive/Alpha/InvAlpha), the blend is *forced* to (SrcAlpha, InvSrcAlpha) — i.e. standard alpha-blend, not additive. Verbatim from decomp lines 425246-425260: if ((curr_surface_type & 0x10) != 0) { if (skipChk != 0 || ebx == 0 || arg3 == 1) { edi_2 = BLEND_SRCALPHA; // src ebp = BLEND_INVSRCALPHA; // dst ← alpha-blend } curr_alpha = _ftol2(translucency * 255); } Where `arg3 == 1` is set after the Base1ClipMap branch and `ebx == 0` is the opaque-base case in Branch 2. Concrete impact: Dereth's inner cloud sheet GfxObj 0x01004C35 uses surface 0x08000023 with Type=0x10114 (B1ClipMap|Translucent|Alpha| Additive). Retail renders it alpha-blend; acdream was rendering it additive. Additive on a dark cloud texture only brightens the background — sun shines through unchanged — which doesn't match retail's denser cloud appearance. Rain surface 0x080000C5 (Type=0x10112 = B1Image|Translucent|Alpha| Additive, NO ClipMap) hits Branch 1 → Additive, ClipMap branch is skipped, the Translucent override doesn't fire (arg3 stays 0) → stays Additive. Visual rain rendering is unchanged. User reported no visible difference at the verification launch; the remaining cloud-density gap likely lives in the PES particle layer (issue #28). Keeping this fix because the classification is now decomp-correct regardless of immediate visual impact — issue #29 documents the residual gap. 1227 tests pass. --- src/AcDream.Core/Meshing/TranslucencyKind.cs | 54 ++++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/src/AcDream.Core/Meshing/TranslucencyKind.cs b/src/AcDream.Core/Meshing/TranslucencyKind.cs index 9d0ab7b..07aaa29 100644 --- a/src/AcDream.Core/Meshing/TranslucencyKind.cs +++ b/src/AcDream.Core/Meshing/TranslucencyKind.cs @@ -40,17 +40,38 @@ public enum TranslucencyKind public static class TranslucencyKindExtensions { - // Priority order (highest wins): - // 1. Additive — SurfaceType.Additive (0x10000) - // 2. InvAlpha — SurfaceType.InvAlpha (0x200) - // 3. AlphaBlend — SurfaceType.Alpha (0x100) OR SurfaceType.Translucent (0x10) - // 4. ClipMap — SurfaceType.Base1ClipMap (0x04) - // 5. Opaque — everything else + // Translucent override comes FIRST, then the existing priority chain: + // 1. Translucent override — Translucent (0x10) AND (ClipMap OR opaque-base) + // → AlphaBlend (matches retail's blend forcing). + // 2. Additive — SurfaceType.Additive (0x10000) + // 3. InvAlpha — SurfaceType.InvAlpha (0x200) + // 4. AlphaBlend — SurfaceType.Alpha (0x100) OR SurfaceType.Translucent (0x10) + // 5. ClipMap — SurfaceType.Base1ClipMap (0x04) + // 6. Opaque — everything else // - // Note: ACViewer groups Base1ClipMap with the alpha-draw bucket (AlphaSurfaceTypes), - // but acdream keeps its existing alpha-discard approach for clip-map surfaces - // (they render opaque with per-fragment discard) and introduces a separate - // translucent pass only for the genuinely blended surface types. + // The Translucent override matches retail's D3DPolyRender::SetSurface + // at 0x0059c4d0 (decomp lines 425083-425260). Verbatim from the + // Translucent branch at 425246: + // + // if ((curr_surface_type & 0x10) != 0) { + // if (skipChk != 0 || ebx == 0 || arg3 == 1) { + // edi_2 = BLEND_SRCALPHA; // src + // ebp = BLEND_INVSRCALPHA; // dst ← alpha-blend + // ebx = 1; arg1 = 1; arg3 = 0; + // } + // curr_alpha = _ftol2(translucency * 255); + // } + // + // Where `arg3 = 1` after the ClipMap branch and `ebx == 0` happens + // in Branch 2 when the surface would otherwise be opaque (no Additive, + // Alpha, or InvAlpha bits). So Translucent + ClipMap (e.g. cloud + // surface 0x08000023, Type=0x10114) renders ALPHA-BLEND in retail + // even though the Additive flag is also set; previously acdream's + // priority-Additive-first classification mis-routed it as additive. + // Empirically: this is the surface for cloud GfxObj 0x01004C35 in + // every Cloudy/Rainy DayGroup. Misclassifying it as additive made + // acdream's clouds barely-visible "brightness adders" rather than + // the dense alpha-blended sheets retail shows. /// /// Maps a flags value to the correct @@ -58,6 +79,19 @@ public static class TranslucencyKindExtensions /// public static TranslucencyKind FromSurfaceType(SurfaceType type) { + // Step 1: Translucent override — matches retail's branch at + // decomp line 425250 where (skipChk || ebx == 0 || arg3 == 1) + // forces (SrcAlpha, InvSrcAlpha) regardless of Additive. + bool isTranslucent = (type & SurfaceType.Translucent) != 0; + bool isClipMap = (type & SurfaceType.Base1ClipMap) != 0; + bool wouldBeOpaque = + (type & (SurfaceType.Additive + | SurfaceType.Alpha + | SurfaceType.InvAlpha)) == 0; + if (isTranslucent && (isClipMap || wouldBeOpaque)) + return TranslucencyKind.AlphaBlend; + + // Step 2..6: existing priority order for non-overridden surfaces. if ((type & SurfaceType.Additive) != 0) return TranslucencyKind.Additive; From 646ccca85eb46746f3f6fa186223c870fe196bc3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 23:24:09 +0200 Subject: [PATCH 17/18] feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent code review by an external agent (2026-04-27) flagged that SkyRenderer.EnsureMeshUploaded only ever called _dats.Get(...) — every 0x020xxx Setup ID returned null and got cached as an empty submesh list, silently dropping every Setup-backed sky object across the Dereth Region. In Rainy DG3 alone that's 6 dropped SkyObjects (0x02000714, 0x02000BA6 ×2, 0x02000588 ×4, 0x02000589 ×3 across various time-of-day windows). Verbatim from retail's CelestialPosition struct at acclient.h:35451: struct CelestialPosition { IDClass<...> gfx_id; IDClass<...> pes_id; // particle scheduler float heading; float rotation; Vector3 tex_velocity; float transparent; float luminosity; float max_bright; unsigned int properties; }; Per the named retail decomp, CPhysicsObj::InitPartArrayObject (decomp ~280484) dispatches gfx_id by type prefix: type 6 → direct GfxObj, type 7 → Setup via CPartArray::CreateSetup (decomp ~287490) which walks Setup.Parts. Mirror that here: detect 0x020xxxxx in EnsureMeshUploaded, route to a new EnsureSetupUploaded helper that flattens via SetupMesh.Flatten (existing Phase-2 utility) and bakes each part's transform into the vertex positions before upload. Sky setups don't animate in any way that affects the static-mesh visual we render here. Probe extension: also added the Diffuse column to RainMeshProbe's sky-surface audit so the (Type, Translucency, Luminosity, Diffuse) quadruple is visible on every flag-bit row. Visual impact at verification launch: not observable. The Setup objects in Rainy DGs appear to be tiny placeholder meshes existing mainly to anchor PES emitters. The dynamic "aurora-like" sheen the user observes in retail comes from the PES particle layer, which remains unimplemented (issue #28). Keeping this fix because the geometry path is now decomp-correct and provides foundation for the eventual PES wiring. Issue #29 filed for the residual cloud-density gap. 1227 tests pass. --- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 110 ++++++++++++++++++- tools/RainMeshProbe/Program.cs | 2 +- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 6b91c35..1aa9550 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -439,14 +439,53 @@ public sealed unsafe class SkyRenderer : IDisposable } /// - /// Lazy GfxObj build — reuses so the - /// pos/neg polygon splitting logic stays consistent with the main - /// static-mesh pipeline. Most sky meshes are single-surface. + /// Lazy mesh build for a sky object. Handles two cases: + /// + /// + /// 0x010xxxxx — direct . Reuses + /// so the pos/neg polygon + /// splitting logic stays consistent with the main static-mesh + /// pipeline. Most sky meshes are single-surface. + /// + /// + /// 0x020xxxxx. The agent at + /// 2026-04-27 found these Setup-backed sky objects (e.g. + /// 0x02000588, 0x02000589, 0x02000714, + /// 0x02000BA6) were silently dropped: every cache miss + /// fell into the GfxObj branch, returned null, and got cached + /// as an empty submesh list. Per the named retail decomp + /// CPhysicsObj::InitPartArrayObject at 0x0050ed40 + /// dispatches type 7 to CPartArray::CreateSetup + /// (decomp 280484) which loads the Setup and walks its parts. + /// We mirror that here: walks + /// Setup.Parts at the default placement frame and + /// produces submeshes for each + /// part. Per-part transforms are baked into vertex positions + /// (sky setups are static — no animation needed for the static + /// mesh half of the visual). + /// + /// + /// + /// Even with this fix the visible aurora-style sheen most retail + /// rainy/cloudy setups produce comes from the pes_id field + /// on each (a Particle + /// Effect Schedule) — that's a separate Phase-level feature. + /// Rendering the Setup's static parts here is the geometry half; + /// the dynamic particle half is deferred. + /// /// private void EnsureMeshUploaded(uint gfxObjId) { if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; + // Setup-backed sky object: walk Setup.Parts and bake per-part + // transforms into the per-vertex positions. See doc comment above. + if ((gfxObjId & 0xFF000000u) == 0x02000000u) + { + EnsureSetupUploaded(gfxObjId); + return; + } + // DatCollection isn't thread-safe and the streaming loader can be // actively reading a shared DatBinReader buffer; sky meshes are // loaded on the render thread but GfxObj.Unpack can race with the @@ -487,6 +526,71 @@ public sealed unsafe class SkyRenderer : IDisposable _gpuByGfxObj[gfxObjId] = gpuList; } + /// + /// Setup-backed sky object loader. Walks at + /// the default placement frame, builds submeshes via + /// , and bakes the per-part transform + /// into the vertex positions before upload. Static-pose only — sky + /// setups don't animate in any meaningful way for the visual we care + /// about (the dynamic look comes from pes_id particles, not + /// the underlying mesh). + /// + /// Mirrors retail's at + /// decomp 280484 dispatching type 7 → CPartArray::CreateSetup + /// → CSetup::SetSetupID, which loads the setup and instantiates + /// each part as a separate CPhysicsObj child. We collapse the + /// children into a flat submesh list because the sky pass renders + /// without per-part transforms anyway. + /// + /// + private void EnsureSetupUploaded(uint setupId) + { + Setup? setup = null; + try { setup = _dats.Get(setupId); } + catch { setup = null; } + + if (setup is null) + { + _gpuByGfxObj[setupId] = new List(); + return; + } + + var parts = SetupMesh.Flatten(setup); + var allSubs = new List(parts.Count); + foreach (var partRef in parts) + { + GfxObj? partGfx = null; + try { partGfx = _dats.Get(partRef.GfxObjId); } + catch { partGfx = null; } + if (partGfx is null) continue; + + System.Collections.Generic.IReadOnlyList? partSubs = null; + try { partSubs = GfxObjMesh.Build(partGfx, _dats); } + catch { partSubs = null; } + if (partSubs is null) continue; + + // Bake the part's local transform into the vertices. For sky + // setups we don't expect non-uniform scale, so transforming + // normals as directions is fine; if a future sky setup ever + // breaks that assumption we'd need an inverse-transpose here. + var partTx = partRef.PartTransform; + foreach (var sub in partSubs) + { + var transformed = new Vertex[sub.Vertices.Length]; + for (int i = 0; i < sub.Vertices.Length; i++) + { + var v = sub.Vertices[i]; + var p = Vector3.Transform(v.Position, partTx); + var n = Vector3.Normalize(Vector3.TransformNormal(v.Normal, partTx)); + transformed[i] = v with { Position = p, Normal = n }; + } + var rebuilt = sub with { Vertices = transformed }; + allSubs.Add(UploadSubMesh(rebuilt)); + } + } + _gpuByGfxObj[setupId] = allSubs; + } + /// /// Log each surface's raw flag bits and the derived /// . Called once per GfxObj when diff --git a/tools/RainMeshProbe/Program.cs b/tools/RainMeshProbe/Program.cs index 1eaff70..0839f3d 100644 --- a/tools/RainMeshProbe/Program.cs +++ b/tools/RainMeshProbe/Program.cs @@ -59,7 +59,7 @@ static void ProbeSkySurface(DatCollection dats, uint sid) { Console.WriteLine($" Surface 0x{sid:X8} (NOT FOUND)"); return; } uint t = (uint)s.Type; bool luminous = (t & 0x40u) != 0u; - Console.Write($" Surface 0x{sid:X8} Type=0x{t:X8} Luminous={(luminous ? "YES" : "no ")} Lum={s.Luminosity:F4} Trans={s.Translucency:F4} "); + Console.Write($" Surface 0x{sid:X8} Type=0x{t:X8} Luminous={(luminous ? "YES" : "no ")} Lum={s.Luminosity:F4} Trans={s.Translucency:F4} Diff={s.Diffuse:F4} "); // Decode bits inline. var bits = new (uint mask, string n)[] { (0x01u,"B1Solid"),(0x02u,"B1Image"),(0x04u,"B1ClipMap"),(0x10u,"Translucent"), From 0c82d2c9e9a5d3907fe32f7110c9f6b88df8c60b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 23:24:17 +0200 Subject: [PATCH 18/18] docs(issues): #28 root-caused (PES particles), #29 filed (residual cloud gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated #28 (aurora effect) from "unknown root cause" to "PES particles attached via CelestialPosition.pes_id". Includes the verbatim retail header struct, the StarsProbe-confirmed list of PES-bearing entries in Dereth Rainy DG3 (notably PES 0x3300042C active 0.27-0.91, which is the user's Warmtide screenshot), the implementation outline, and decomp pointers to CPhysicsObj::InitPartArrayObject + CPartArray::CreateSetup. Filed #29 for the residual cloud-density gap that remained after this session's Translucent-override fix (commit 375065b) and Setup wiring (commit 646ccca). Two follow-up hypotheses captured — likely rolls into #28 once PES rendering lands. --- docs/ISSUES.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b7eee93..6ab3a19 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -279,15 +279,73 @@ missing is the plugin-API surface. **Filed:** 2026-04-26 **Component:** sky / vfx -**Description:** Retail occasionally renders an aurora-borealis-style "northern lights" effect in the sky during certain weather/time conditions. acdream renders no aurora at all. +**Description:** Retail renders a dynamic colored "light play" effect in the sky during certain Rainy/Cloudy DayGroup time windows. The user describes it as aurora-borealis-style. acdream renders no comparable effect. -**Root cause / status:** Unknown — the mechanism hasn't been investigated. Aurora is NOT in the visible SkyObject lists (`tools/StarsProbe` shows the standard 7-object Sunny/Clear/Cloudy DayGroup composition, with extra weather objects in Rainy groups). Hypotheses: (a) it's a special PES on a low-probability DayGroup not yet enumerated; (b) it's a separate shader path not driven by `Region.SkyInfo`; (c) it requires a specific weather/time combo we haven't triggered; (d) it's an entirely separate `EnvironChangeType` system we don't decode. +**Root cause:** PES (Particle Effect Schedule) particles attached to SkyObjects via the `CelestialPosition.pes_id` field. Retail header at `acclient.h` line 35451 (verbatim): -**Files:** Unknown. +```c +struct CelestialPosition { + IDClass<...> gfx_id; + IDClass<...> pes_id; // ← particle scheduler ID + float heading; float rotation; + Vector3 tex_velocity; + float transparent; float luminosity; float max_bright; + unsigned int properties; +}; +``` -**Research:** None yet. Probably needs a retail-decomp grep for "aurora", "northern", a 360° survey of DayGroup PES contents, and possibly a deepdive into the `LScape::weather_enabled` and `EnvironChange*` paths. +`StarsProbe` confirmed Dereth Rainy DayGroup 3 carries multiple PES-bearing entries (verified 2026-04-27). Sample for the user's observed Warmtide-Rainy state: -**Acceptance:** When retail shows an aurora at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time. +| OI | Gfx | **PES** | Active window | Notes | +|----|-----|---------|----|----| +| 5 | 0x02000714 | 0x330007DB | always | low-rate background | +| 7 | 0x02000BA6 | 0x33000453 | 0.03–0.19 | early morning | +| 17 | 0x02000589 | **0x3300042C** | **0.27–0.91** | **active during user's screenshot** | + +acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetupUploaded` walks `Setup.Parts` for `0x020xxx` IDs). The dynamic visual half — emitting and animating the PES particles — is unimplemented and provides the actual aurora look. Phase E.3 already has data-only PES support per memory crib `project_session_2026_04_18.md`; this issue requires the runtime + visual half. + +**Implementation outline:** +1. PES dat decode (already partially in `AcDream.Core.World.PesData` per Phase E.3). +2. PES emitter runtime — schedule, spawn, advect, color-cycle, expire each particle. +3. `SkyRenderer` integration — when `MakeObject` sees `pes_id != 0`, spawn the PES at the SkyObject's celestial position. +4. PES vertex-sprite renderer — billboarded textured quads with additive blending and color cycling. Probably reuses the future general-purpose particle renderer (issue #L? — TBD). + +**Decomp pointers:** +- `CPhysicsObj::InitPartArrayObject` decomp ~280484 — dispatches type 7 to Setup loader. +- `CPartArray::CreateSetup` decomp ~287490 — Setup → Parts → optional PES wiring. + +**Files:** +- `src/AcDream.Core/World/SkyDescLoader.cs` — `SkyObjectData` needs to carry `PesObjectId` (currently dropped on the floor). +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — needs a particle-emission step alongside the per-SkyObject mesh draw. + +**Acceptance:** When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time. + +--- + +## #29 — Cloud surface 0x08000023 still appears thinner than retail despite blend-mode + Setup fixes + +**Status:** OPEN +**Severity:** LOW (aesthetic feature-parity) +**Filed:** 2026-04-27 +**Component:** sky / clouds + +**Description:** User screenshot comparison showed acdream's clouds let too much sun through; retail's are denser and have a purpleish tint. Two follow-up fixes landed without visible improvement: + +1. `TranslucencyKindExtensions.FromSurfaceType` now applies retail's Translucent-override at `D3DPolyRender::SetSurface` (decomp 425246-425260) — surface `0x08000023` (Type=`0x10114` = `B1ClipMap | Translucent | Alpha | Additive`) is now correctly classified as `AlphaBlend` instead of `Additive`. +2. `SkyRenderer.EnsureSetupUploaded` now loads `0x020xxxxx` Setup IDs (e.g. `0x02000588`, `0x02000589`, `0x02000714`, `0x02000BA6`) which were silently dropped. Setup parts are flattened via `SetupMesh.Flatten` and uploaded with their per-part transform baked into vertex positions. + +Despite both being decomp-correct fixes, the user reports no observable visual change in dual-client comparison. Two follow-up hypotheses: + +- The Setup objects are tiny placeholder meshes (one `0x010001EC` part each) that exist mainly to anchor a PES emitter — the cloud "density" / "purple sheen" the user perceives is entirely the PES particle layer, not the static mesh. +- The cloud surface might still be rendering correctly per its dat data, and what looks "thicker" in retail is the additional aurora-like PES sheen overlaid on top. + +If hypothesis (a) is correct, this issue effectively rolls into **#28** — the PES rendering work would resolve both. + +**Files:** +- `src/AcDream.Core/Meshing/TranslucencyKind.cs` — Translucent override +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — `EnsureSetupUploaded` + +**Acceptance:** Cloud sheets look as dense/purple as retail in dual-client side-by-side. May require #28 (PES) to land first. ---