From 646ccca85eb46746f3f6fa186223c870fe196bc3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 23:24:09 +0200 Subject: [PATCH] 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"),