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:
///