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"),