Merge branch 'feature/sky-fixes' — sky/weather rendering retail-faithful pass

Six commits on the branch, three retail-decomp investigations
(in-house + two external code-review agents) converging on the
same root causes:

  97fc1b5 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
  05a8a72 fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
  034a684 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
  375065b fix(meshing): Translucent flag overrides Additive blend per retail SetSurface
  646ccca feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
  0c82d2c docs(issues): #28 root-caused (PES particles), #29 filed

Net effect:

  * Sun + ambient colors now use retail's |sunVec| magnitude formula
    from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes
    blue-white sky tint at most keyframes.
  * Surface.Translucency is used DIRECTLY as opacity (not 1-x) per
    D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright
    cloud + correct rain alpha.
  * Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon
    haze visible without flat-fogging the dome at storm keyframes.
  * Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp
    425295 — sun stays bright at horizon dusk/dawn.
  * Pre/post-scene partition is bit 0x01 (post-scene placement) instead
    of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects
    at decomp 269036. Fixes double-rendered foreground rain.
  * Translucent flag forces alpha-blend over Additive when ClipMap is
    set, matching retail's blend resolution at decomp 425246-425260.
    Cloud surface 0x08000023 now classified correctly.
  * Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten
    instead of being silently dropped by EnsureMeshUploaded.

Tests: 1227 pass.

User-visible improvements: foreground rain matches retail's
volumetric look, sky tint shifted from blue-white toward retail's
warm-gray, additive sun stays bright through horizon haze.

Outstanding:
  * Issue #28 — PES particle rendering ("aurora light play"). Now
    root-caused with implementation outline; defer to its own Phase.
  * Issue #29 — residual cloud-density gap; likely rolls into #28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/AcDream.App/Rendering/GameWindow.cs
This commit is contained in:
Erik 2026-04-27 23:30:50 +02:00
commit f7c9e88b6a
18 changed files with 1439 additions and 290 deletions

View file

@ -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
@ -4371,10 +4365,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.
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.
@ -4476,9 +4479,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);
}
@ -4514,6 +4525,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)
@ -4730,12 +4755,28 @@ public sealed class GameWindow : IDisposable
// title bar. Default is true (matches pre-L.0 behaviour);
// unchecking the toggle in Display tab collapses the title
// to just "acdream" for a cleaner alt-tab experience.
//
// When perf is shown, also include the in-game calendar/time —
// matches retail's @timestamp output ("Date: <Month> <Day>,
// PY <Year> Time: <HourName>"). Uses 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.
bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true;
_window!.Title = showFps
? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
+ $"lb {visibleLandblocks}/{totalLandblocks} visible | "
+ $"ent {entityCount} | anim {animatedCount}"
: "acdream";
if (showFps)
{
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})";
}
else
{
_window!.Title = "acdream";
}
_lastFps = fps;
_lastFrameMs = avgFrameTime;
_perfAccum = 0;
@ -5393,9 +5434,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,
@ -5411,114 +5454,6 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// 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 (<see cref="AcDream.Core.Vfx.EmitterFlags.AttachLocal"/>)
/// so walking never leaves the rain volume (r12 §7).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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,
};
/// <summary>
/// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
/// sideways drift, small billboards, 100 flakes/sec, long lifespan.
/// </summary>
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

View file

@ -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,20 @@ 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
// 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
// bumps sky brightness during lightning strikes. Matches sky.vert's
@ -45,24 +56,45 @@ 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
// 2026-04-24 — Dereth sky meshes are authored at radii 10501820m
// 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 10501820m. 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
@ -79,7 +111,24 @@ 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:
// 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);
}

View file

@ -70,8 +70,18 @@ public sealed unsafe class SkyRenderer : IDisposable
}
/// <summary>
/// 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 <c>SkyObject</c> with <c>Properties &amp; 0x04 == 0</c>).
/// Called BEFORE the scene; terrain / meshes / debug lines / overlay
/// land on top via depth-test.
///
/// <para>
/// Mirrors the first half of retail's <c>LScape::draw</c> at
/// <c>0x00506330</c>: that function calls <c>GameSky::Draw(0)</c>
/// (sky pass) before the landblock loop, then <c>GameSky::Draw(1)</c>
/// (weather pass) after. acdream splits the same way — see
/// <see cref="RenderWeather"/> for the post-scene companion.
/// </para>
///
/// <para>
/// Each submesh renders with retail's per-vertex lighting formula:
@ -91,12 +101,57 @@ public sealed unsafe class SkyRenderer : IDisposable
/// field.
/// </para>
/// </summary>
public void Render(
public void RenderSky(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false);
/// <summary>
/// Draw the POST-SCENE sky objects (the foreground rain mesh
/// <c>0x01004C44</c> on Rainy DayGroups, plus any other SkyObject with
/// <c>Properties &amp; 0x01 != 0</c>). Called AFTER the scene so these
/// meshes paint on top of terrain and entities — retail-faithful order
/// from <c>LScape::draw</c> at <c>0x00506330</c>, where
/// <c>GameSky::Draw(1)</c> fires after the <c>DrawBlock</c> loop and
/// renders the <c>after_sky_cell</c> 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.
/// <para>
/// Method name kept as <c>RenderWeather</c> for API stability; the
/// pass actually partitions on <see cref="SkyObjectData.IsPostScene"/>
/// (Properties bit <c>0x01</c>), not <see cref="SkyObjectData.IsWeather"/>
/// (bit <c>0x04</c>). The two bits are independent in retail per
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>.
/// </para>
/// </summary>
public void RenderWeather(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true);
/// <summary>
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
/// 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
/// <see cref="SkyObjectData.IsPostScene"/> — bit <c>0x01</c> per the
/// retail decomp at <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>).
/// </summary>
private void RenderPass(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe,
bool postScenePass)
{
if (group is null || group.SkyObjects.Count == 0) return;
@ -149,20 +204,51 @@ public sealed unsafe class SkyRenderer : IDisposable
for (int i = 0; i < group.SkyObjects.Count; i++)
{
var obj = group.SkyObjects[i];
// 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.
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;
@ -177,6 +263,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 (postScenePass)
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
_shader.SetMatrix4("uModel", model);
// UV scroll accumulates real-time × velocity. Wrap to [0, 1]
@ -186,7 +292,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;
@ -205,34 +310,85 @@ 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 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);
// 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);
@ -283,14 +439,53 @@ public sealed unsafe class SkyRenderer : IDisposable
}
/// <summary>
/// Lazy GfxObj build — reuses <see cref="GfxObjMesh"/> 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:
/// <list type="bullet">
/// <item><description>
/// <c>0x010xxxxx</c> — direct <see cref="GfxObj"/>. Reuses
/// <see cref="GfxObjMesh.Build"/> so the pos/neg polygon
/// splitting logic stays consistent with the main static-mesh
/// pipeline. Most sky meshes are single-surface.
/// </description></item>
/// <item><description>
/// <c>0x020xxxxx</c> — <see cref="Setup"/>. The agent at
/// 2026-04-27 found these Setup-backed sky objects (e.g.
/// <c>0x02000588</c>, <c>0x02000589</c>, <c>0x02000714</c>,
/// <c>0x02000BA6</c>) 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
/// <c>CPhysicsObj::InitPartArrayObject</c> at <c>0x0050ed40</c>
/// dispatches type 7 to <c>CPartArray::CreateSetup</c>
/// (decomp 280484) which loads the Setup and walks its parts.
/// We mirror that here: <see cref="SetupMesh.Flatten"/> walks
/// <c>Setup.Parts</c> at the default placement frame and
/// <see cref="GfxObjMesh.Build"/> 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).
/// </description></item>
/// </list>
/// <para>
/// Even with this fix the visible aurora-style sheen most retail
/// rainy/cloudy setups produce comes from the <c>pes_id</c> field
/// on each <see cref="DatReaderWriter.Types.SkyObject"/> (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.
/// </para>
/// </summary>
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
@ -331,6 +526,71 @@ public sealed unsafe class SkyRenderer : IDisposable
_gpuByGfxObj[gfxObjId] = gpuList;
}
/// <summary>
/// Setup-backed sky object loader. Walks <see cref="Setup.Parts"/> at
/// the default placement frame, builds submeshes via
/// <see cref="GfxObjMesh.Build"/>, 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 <c>pes_id</c> particles, not
/// the underlying mesh).
/// <para>
/// Mirrors retail's <see cref="CPhysicsObj.InitPartArrayObject"/> at
/// decomp <c>280484</c> dispatching type 7 → <c>CPartArray::CreateSetup</c>
/// → <c>CSetup::SetSetupID</c>, which loads the setup and instantiates
/// each part as a separate <c>CPhysicsObj</c> child. We collapse the
/// children into a flat submesh list because the sky pass renders
/// without per-part transforms anyway.
/// </para>
/// </summary>
private void EnsureSetupUploaded(uint setupId)
{
Setup? setup = null;
try { setup = _dats.Get<Setup>(setupId); }
catch { setup = null; }
if (setup is null)
{
_gpuByGfxObj[setupId] = new List<SubMeshGpu>();
return;
}
var parts = SetupMesh.Flatten(setup);
var allSubs = new List<SubMeshGpu>(parts.Count);
foreach (var partRef in parts)
{
GfxObj? partGfx = null;
try { partGfx = _dats.Get<GfxObj>(partRef.GfxObjId); }
catch { partGfx = null; }
if (partGfx is null) continue;
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh>? 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;
}
/// <summary>
/// Log each surface's raw flag bits and the derived
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
@ -423,6 +683,8 @@ public sealed unsafe class SkyRenderer : IDisposable
SurfaceId = sm.SurfaceId,
IsAdditive = isAdditive,
SurfLuminosity = sm.Luminosity,
NeedsUvRepeat = sm.NeedsUvRepeat,
SurfTranslucency = sm.SurfTranslucency,
};
}
@ -462,5 +724,26 @@ public sealed unsafe class SkyRenderer : IDisposable
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
/// </summary>
public float SurfLuminosity;
/// <summary>
/// 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 <c>GL_REPEAT</c> 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.
/// </summary>
public bool NeedsUvRepeat;
/// <summary>
/// <c>Surface.Translucency</c> float (0..1) carried through from
/// <see cref="GfxObjSubMesh.SurfTranslucency"/>. Passed to the
/// sky fragment shader as <c>uSurfTranslucency</c>; the shader
/// multiplies output alpha by <c>(1 - x)</c>. 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 <c>curr_alpha</c> derivation in
/// <c>D3DPolyRender::SetSurface</c> at <c>0x59c767</c>.
/// </summary>
public float SurfTranslucency;
}
}

View file

@ -200,6 +200,21 @@ public static class GfxObjMesh
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
var translucency = TranslucencyKind.Opaque;
var luminosity = 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<Surface>(surfaceId);
@ -207,9 +222,33 @@ public static class GfxObjMesh
{
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
luminosity = surface.Luminosity;
// 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;
}
}
// 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 +256,8 @@ public static class GfxObjMesh
{
Translucency = translucency,
Luminosity = luminosity,
NeedsUvRepeat = needsUvRepeat,
SurfTranslucency = surfTranslucency,
});
}
return result;

View file

@ -39,4 +39,41 @@ public sealed record GfxObjSubMesh(
/// normal lighting path without change.
/// </summary>
public float Luminosity { get; init; } = 0f;
/// <summary>
/// True when at least one vertex's UV component lies outside the
/// <c>[0, 1]</c> range, meaning the mesh was authored to have its
/// texture tile across the geometry (i.e. it expects
/// <c>GL_REPEAT</c>/<c>D3DTADDRESS_WRAP</c>). The sky renderer reads
/// this to decide between <c>GL_REPEAT</c> (this flag set, or any
/// scrolling layer) and <c>GL_CLAMP_TO_EDGE</c> (all UVs strictly
/// in <c>[0,1]</c>), which avoids wall-seam bleed on the dome
/// (UVs in <c>[0,1]</c>) while still tiling the inner star/cloud
/// layers (UVs in <c>[~0.4, ~4.6]</c>) correctly.
/// Defaults to false so non-sky consumers get the previous behavior.
/// </summary>
public bool NeedsUvRepeat { get; init; } = false;
/// <summary>
/// <c>Surface.Translucency</c> float (0..1) treated as an OPACITY
/// multiplier on fragment alpha. 1.0 = fully opaque (default for
/// non-Translucent surfaces). Distinct from the
/// <see cref="TranslucencyKind"/> classifier above, which buckets the
/// flag bits. Retail's <c>D3DPolyRender::SetSurface</c> at
/// <c>0x59c7a6</c> (decomp lines 425255-425260) reads
/// <c>Surface.Translucency</c> when the <c>Translucent</c> (0x10) bit
/// is set, computes <c>curr_alpha = _ftol2(translucency × 255)</c>,
/// and writes that as vertex alpha — i.e. the dat's Translucency float
/// is used DIRECTLY as opacity, NOT inverted. ACViewer
/// (<c>TextureCache.cs:142</c>) and WorldBuilder
/// (<c>ObjectMeshManager.cs:1115</c>) both use <c>1 - translucency</c>
/// and are wrong by the same misread.
/// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5;
/// with the <c>(SrcAlpha, One)</c> 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.
/// </summary>
public float SurfTranslucency { get; init; } = 1f;
}

View file

@ -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.
/// <summary>
/// Maps a <see cref="SurfaceType"/> flags value to the correct
@ -58,6 +79,19 @@ public static class TranslucencyKindExtensions
/// </summary>
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;

View file

@ -90,21 +90,30 @@ public static class DerethDateTime
GloamingAndHalf,
}
/// <summary>Derethian months (Snowreap..Frostfell, 12 total).</summary>
/// <summary>
/// Derethian months in chronological order. Year-0 begins at month 0
/// (<see cref="Morningthaw"/>) and progresses through the 12-month
/// cycle. Names + order match retail's calendar display
/// (<c>GameTime::CalcDayBegin</c> + <c>GetDateTimeString</c> at
/// <c>0x005a6530</c>) and ACE's <c>DerethDateTime.cs</c>. Verified
/// against retail's <c>@timestamp</c> output in 2026-04-27 dual-
/// client comparison: at day-of-year 83, retail shows
/// "Seedsow 24" — that fixes month index 2 = Seedsow.
/// </summary>
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,
}
/// <summary>
@ -127,12 +136,15 @@ public static class DerethDateTime
/// for the boot window before the dat parses.
///
/// <para>
/// Live Dereth dat value: <c>3600</c>. 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
/// <c>docs/research/2026-04-23-daygroup-selection.md</c> §4 and
/// the Phase 3f commit.
/// Live Dereth dat value: <c>3600</c>. Retail's
/// <c>GameTime::CalcDayBegin</c> at <c>0x005a6400</c> (decomp line
/// 434549) computes <c>arg2 + zero_time_of_year</c> as the basis for
/// year/day-of-year extraction, then derives <c>time_of_day_begin</c>
/// such that <c>(arg2 - time_of_day_begin) / day_length</c> in
/// <c>CalcTimeOfDay</c> gives <c>(arg2 + zero_time_of_year) mod day_length / day_length</c>.
/// 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.)
/// </para>
/// </summary>
public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
@ -186,7 +198,10 @@ public static class DerethDateTime
/// <summary>
/// Derethian calendar breakdown: (year, month, day, hour).
/// Year starts at PY 0. Day is 1-based within the month (1..30).
/// <see cref="Year"/> is the absolute Portal Year (= relative-year +
/// <see cref="ZeroYear"/>) so the value matches retail's
/// <c>@timestamp</c> output ("Date: &lt;Month&gt; &lt;Day&gt;,
/// &lt;Year&gt; P.Y."). Day is 1-based within the month (1..30).
/// </summary>
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));
}
/// <summary>

View file

@ -36,6 +36,45 @@ public sealed class SkyObjectData
public uint GfxObjId;
public uint Properties;
/// <summary>
/// True when this SkyObject is gated on the weather system (Properties
/// bit <c>0x04</c>). Per the named retail decomp,
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>
/// passes <c>Properties &amp; 4</c> as <c>arg5</c> of
/// <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>); the inner
/// <c>(arg5 == 0 || LScape::weather_enabled != 0)</c> guard at decomp
/// line 268630 means weather-flagged objects only get instantiated when
/// the global weather flag is on. This bit does <b>not</b> control
/// pre/post-scene placement — that's <see cref="IsPostScene"/>.
/// 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.
/// </summary>
public bool IsWeather => (Properties & 0x04u) != 0u;
/// <summary>
/// True when this SkyObject renders <i>after</i> the world scene
/// (Properties bit <c>0x01</c>) — i.e. as foreground over terrain and
/// entities. Per the named retail decomp,
/// <c>GameSky::CreateDeletePhysicsObjects</c> passes
/// <c>Properties &amp; 1</c> as <c>arg4</c> of
/// <c>GameSky::MakeObject</c> (decomp line 269036); MakeObject at
/// decomp 268656 routes <c>arg4 != 0</c> objects into
/// <c>after_sky_cell</c> instead of <c>before_sky_cell</c>, and
/// <c>GameSky::Draw(arg2=1)</c> at <c>0x00506ff0</c> draws
/// <c>after_sky_cell</c> as a separate post-scene pass.
/// <para>
/// In Dereth's Rainy DayGroup this distinguishes the two rain
/// cylinders: <c>0x01004C44</c> (Props=0x05) is foreground rain
/// rendered after terrain; <c>0x01004C42</c> (Props=0x04 alone) is
/// background rain rendered <i>with</i> the sky dome. Earlier
/// versions of acdream incorrectly split on <see cref="IsWeather"/>
/// (bit 0x04) so both rain meshes ended up in the post-scene pass,
/// double-rendering rain in the foreground.
/// </para>
/// </summary>
public bool IsPostScene => (Properties & 0x01u) != 0u;
/// <summary>Object is visible at day-fraction <paramref name="t"/>
/// by retail's begin/end semantics (r12 §2). Three cases:
/// <list type="bullet">
@ -534,12 +573,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,

View file

@ -34,24 +34,82 @@ public enum FogMode
/// </para>
///
/// <para>
/// 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 <c>DirBright</c> / <c>AmbBright</c>. 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
/// <see cref="DirColor"/> / <see cref="AmbColor"/> with the brightness
/// scalars in <see cref="DirBright"/> / <see cref="AmbBright"/>. Retail's
/// <c>SkyDesc::GetLighting</c> at <c>0x00500ac9</c> (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).
/// </para>
/// <para>
/// The computed properties <see cref="SunColor"/> and
/// <see cref="AmbientColor"/> return the post-multiplied product, so
/// downstream shader uniform plumbing (sky.vert / mesh.vert /
/// SceneLightingUbo) is unchanged.
/// </para>
/// </summary>
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)
{
/// <summary>
/// Final directional sun color the shader feeds into N·L lighting.
/// Retail-faithful magnitude formula:
/// <code>SunColor = DirColor × |sunVec|</code>
/// where <c>sunVec</c> is retail's heading+pitch+brightness vector
/// (see <see cref="SkyStateProvider.RetailSunVector"/>).
///
/// <para>
/// Why <c>|sunVec|</c> instead of <c>DirBright</c> directly: retail's
/// <c>PrimD3DRender::UpdateLightsInternal</c> at <c>0x0059b57c</c>
/// (decomp line 424118-424119) computes
/// <code>D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)</code>
/// from the sun vector <c>SkyDesc::GetLighting</c> built at
/// <c>0x00500ac9</c> (decomp lines 261343-261353):
/// <code>
/// sunVec.x = sin(H) × DirBright × cos(P)
/// sunVec.y = cos(P) // NOT scaled by DirBright
/// sunVec.z = DirBright × sin(P)
/// </code>
/// Because Y is unscaled by <c>DirBright</c>, <c>|sunVec|</c> ≠
/// <c>DirBright</c> in general — it varies with sun pitch and heading.
/// Using <c>DirBright</c> alone underweighted the warm directional
/// term, letting the cool ambient/fog dominate ⇒ acdream rendered
/// blue-white at keyframes where retail looked warm-gray.
/// </para>
/// </summary>
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
/// <summary>
/// Final ambient color the shader feeds into the per-vertex tint.
/// Retail-faithful magnitude formula:
/// <code>AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)</code>
/// matching <c>SmartBox::SetWorldAmbientLight</c> as called at
/// <c>0x0050560b</c> (decomp line 267117):
/// <code>SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color)</code>
/// 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 <c>AmbBright</c> alone, which
/// is roughly 44% too dim mid-day ⇒ contributed to the blue-white
/// bias because the warm fill was missing.
/// </summary>
public Vector3 AmbientColor =>
AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length());
}
/// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
@ -111,12 +169,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 +190,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 +203,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 +216,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 +264,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);
}
@ -222,22 +300,52 @@ public sealed class SkyStateProvider
return aDeg + delta * u;
}
/// <summary>
/// Retail's raw sun vector (NOT normalized) — the same vector
/// <c>SkyDesc::GetLighting</c> writes at <c>0x00500ac9</c>
/// (decomp lines 261343, 261352, 261353):
/// <code>
/// sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
/// sunVec.y = cos(P_rad) // NOT scaled by DirBright
/// sunVec.z = DirBright × sin(P_rad)
/// </code>
/// Y is unscaled by brightness on purpose — that's what makes
/// <c>|sunVec|</c> ≠ <c>DirBright</c> 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
/// <c>uSunDir</c> uniform uses the NORMALIZED vector for N·L; the
/// magnitude feeds <see cref="SkyKeyframe.SunColor"/> intensity and
/// the ambient brightness boost in <see cref="SkyKeyframe.AmbientColor"/>.
/// </summary>
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)
}
/// <summary>
/// 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 <see cref="RetailSunVector"/> 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.
/// </summary>
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;
}
}