Commit 2 deleted the GL rendering backend's implementations; this step
removes the package references and shader vocabulary they leave behind,
so nothing in the App project still spells Silk.NET.OpenGL.
Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from
AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its
Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are
used directly and extensively across the Wb texture/mesh pipeline,
independent of the deleted GL IUniformBuffer implementers the package
comment used to cite. The stale comment is corrected in place.
IMeshPipelineDevice.Gl is removed along with the GL? gl parameter
threaded through WbMeshAdapter's four constructors, WorldRenderComposition's
CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null
implementation — nothing read any of them once the legacy per-mesh
upload bodies were gone (confirmed by grep: the sole non-doc-comment hit
was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed
a real bug along the way: its teardown still pattern-matched the deleted
GL GpuFrameFlightController to decide whether to wait for submitted work,
which VulkanFrameFlightController replaced at slice V6a without this site
being updated — so the wait had been silently dead on every Vulkan run
since then. Retargeted to VulkanFrameFlightController, which carries the
same WaitForSubmittedWork().
The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that
WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for
upload validation is replaced by AcDream.Content's existing Silk.NET-free
UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake
tool GL-free); two new members (Rgb, Red, Float) extend that enum with
their GL ABI constants to cover the full vocabulary WorldTextureArray
needs, since MP1a's original set only covered what the extractor itself
emits. ObjectMeshManager's App-boundary cast
`(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct
pass-through now that both sides share the type.
GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of
the Vulkan texture table) is deleted and StorageBindingCount drops from
10 to 9; the descriptor-set-layout code that builds from that count
(VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just
allocates one fewer always-dummy-seeded, always-unused binding.
Several fully dead GL-only classes came along for the ride, confirmed by
zero construction sites: SilkFramebufferViewportTarget
(NullFramebufferViewportTarget is the sole production
IFramebufferViewportTarget), SilkRenderGlStateReader
(NullRenderGlStateReader.Instance is the sole IRenderGlStateReader),
RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole
IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a
pass load-op instead), and GpuFrameTimer plus FrameProfiler's
GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame
bracket (RecordGpuSample is the only GPU-timing path any backend uses
now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no
longer applies, since WbDrawDispatcher's own diagnostic GPU sampling
already moved to the device's Vulkan timer pool). GpuFrameFlightController
itself stays (never constructed with a real fence API in production, but
its retirement-ledger/serial-ring logic is backend-neutral and still
covered by its own unit tests) — only its GL-specific parts (the public
GL constructor overload, SilkGpuFenceApi) are deleted, since removing the
whole class would mean restructuring the frozen Slice-8 composition
shape's GpuFrameFlightController? threading, which is out of this
commit's scope. TextureParameters.cs and BufferUsageExtensions.cs
(zero callers each) are deleted outright.
common.glsl is deleted: nothing in the actual Vulkan .spv build reads
it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair
directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own
complete self-contained preamble per file; common.glsl's textual
concatenation was exclusively Shader.cs's GL-only mechanism, deleted at
Commit 2. The five shader files that named it in comments
(mesh_modern.vert, particle.vert, particle.frag, sky.frag,
terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs
instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the
mandatory modern path already made unreachable, with zero C# consumers
and no compiled .spv — are deleted too. Regenerated via
tools/compile-shaders.ps1: 9/9 remaining shader pairs compile
(previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests
doc comment's "nine of ten are not Vulkan-expressible" was already
stale before this commit).
Test fallout: dead-subject test methods/files are deleted rather than
patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs,
GpuResourceRetirementTransactionTests.cs's GL queue tests, one
WorldRenderDiagnosticsTests source-order test, one
RenderFrameResourceControllerTests clear-phase-order test); tests whose
subject moved or was renamed are updated in place rather than deleted
(GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests'
pinned seven-member surface now reads six, ParticleBindlessInstanceTests'
cross-dialect check now covers the one surviving dialect,
WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was
always the parameter that actually threw).
Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors,
with the Silk.NET.OpenGL/.Extensions.ARB package references physically
removed from the csproj (not just unreferenced in code).
Tests: full-solution `dotnet test` green across every project.
Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.
The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.
The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.
Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.
That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.
That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.
Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).
Post-review fixes folded into this commit:
H1: AttachLocal (is_parent_local=1) follows live parent each frame.
ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
let the owning subsystem refresh AnchorPos every tick — matches
ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
parent frame when is_parent_local != 0. Drops the renderer-side
cameraOffset hack that only worked when the parent was the camera.
H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
retail-faithful (1 - translucency) opacity formula. The code was
right; the comment was a leftover from an earlier hypothesis and
would have invited a wrong "fix".
M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
and restores them to Repeat at end-of-pass, so non-sky renderers
that share the GL handle can't silently inherit clamped wrap state.
M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
weather-flagged AND bit 0x08 is clear, matching retail
GameSky::UpdatePosition 0x00506dd0. The old code applied it to
every post-scene object — a no-op today (every Dereth post-scene
entry happens to be weather-flagged) but a future post-scene-only
sun rim would have been pushed below the camera.
M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
handles from the per-entity tracking dictionaries, fixing a slow
leak where naturally-expired emitters' handles stayed in the
ConcurrentBag forever during long sessions.
M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
can't ever overlap the object-index range. Synthetic IDs stay in
the reserved 0xFxxxxxxx space.
New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small hygiene items flagged by external code-review reports
during the sky/weather investigation:
1. CullFace state leak in SkyRenderer.RenderPass.
Disabled CullFace at the start of the sky pass without restoring it
on exit. Benign today — the global convention in this codebase is
CullFace=off and subsequent renderers (InstancedMeshRenderer,
StaticMeshRenderer) explicitly enable on entry / disable on exit —
but a future caller assuming culling stays on across the sky pass
would have silently broken. Wrap with an IsEnabled save / Enable
restore using TextRenderer.cs's pattern.
2. Stale comment in SubMeshGpu.SurfTranslucency doc.
Said "the shader multiplies output alpha by (1 - x)". After commit
97fc1b5 the shader uses translucency DIRECTLY as opacity per retail
D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260).
Updated to reflect the current formula.
3. Stale comment in sky.frag header.
Said "fragment.a = texture.a × (1 - uTransparency) × (1 - uSurfTranslucency)".
Updated to "× uSurfTranslucency" with citation.
Not addressed: Report 2's "uLuminosity declared but never referenced"
claim. Verified false — the uniform was already removed; the only
remaining uLuminosity references are in comments documenting the
historical removal (sky.frag header line 13-14 explicitly says
"removed 2026-04-26"). Report 2 was reading stale content.
1314 tests pass.
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.
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) <noreply@anthropic.com>
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:
* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
(0..400m at midnight, up to 2400m during day) is calibrated for
terrain; sky meshes are authored at radii 1050-14271m which sits
past FogEnd universally, causing every sky pixel to saturate to
fogColor (dark navy). Stars, moon, dome texture all got
obliterated. The horizon-glow trade-off is noted in the shader
comment; research item to find retail's sky-specific fog range
later.
* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
vertex lighting saturates properly for bright keyframes. Retail's
FUN_0059da60 non-luminous path writes rep.Luminosity into
material.Emissive via the cache +0x3c slot; we were instead using
it as a post-fragment multiply which could only dim, never brighten.
Net effect: daytime clouds now render saturated white, dome dims
correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
and moon unchanged.
* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
(DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
pure ambient rather than getting an 8% sun floor.
New research / tooling (no runtime impact):
* docs/research/2026-04-24-lambert-brightness-split.md — retail's
ambient-brightness formula pinned from PE .rdata read + live
RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
where scale constant 0x0079a1e8 = 0.2f exactly.
* docs/research/2026-04-23-lightning-real.md — research note on the
dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
explicit PES-triggered flash SkyObjects with 5ms time windows).
* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
backwards).
* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
and the 0x0079a1e8 scale-factor readout.
* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
(A8R8G8B8 128x128 texture, 4% bright-pixel ratio).
* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
clouds decoded with proper alpha" type questions.
README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.
All 742 tests green.
Retail applies linear vertex fog with 3D range distance
(D3DRS_FOGVERTEXMODE=3=LINEAR, D3DRS_RANGEFOGENABLE=1,
D3DRS_FOGTABLEMODE=0=NONE) to ALL mesh draws including sky. Only
FOGCOLOR / FOGSTART / FOGEND are lerped per keyframe; the mode flags
are init-only.
Verified in `docs/research/2026-04-23-sky-fog.md`:
- chunk_005A0000.c:3361-3389 device-init sets the modes.
- Sky meshes render at world origin (translation zeroed, rotation-
only) with intrinsic mesh radii in the thousands of meters
(WorldBuilder's SkyboxRenderManager.cs:247 comment confirms).
- With keyframe MaxWorldFog = 2400m, the dome saturates to
WorldFogColor at its horizon band. THAT is retail's dusk/dawn
horizon-glow mechanism.
Port:
`sky.vert` now computes the vertex fog factor:
worldPos = uModel × aPos (camera-centered since view translation=0)
dist = length(worldPos.xyz)
fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1)
— outputs as varying vFogFactor. 1.0 means no fog contribution,
0.0 means full fog color.
`sky.frag` applies the mix BEFORE the lightning-flash bump:
rgb = mix(uFogColor.rgb, rgb, vFogFactor)
Uses the existing SceneLighting UBO's uFogParams (x=start, y=end,
z=flash, w=mode) and uFogColor — no new uniforms, no C# change.
Expected visual:
- Dome at dawn/dusk: horizon band blends toward keyframe fogColor
(warm orange at sunset, cool blue at dawn), matching retail's
sky/fog coupling.
- Close sky objects (sun disk at typical mesh radius): unaffected
since dist < fogStart.
- Clouds at intermediate distance: partial fog blend, subtly
muting their saturation with distance.
Note on lightning: the flash channel (uFogParams.z) stays wired but
is currently always 0 because no code drives it. Agent #5 is
researching retail's real lightning mechanism (PlayScript / SetLight
PhysicsScript / other). This commit does not attempt to port it.
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After Phase 4 landed the per-vertex lighting formula, user observed
acdream was still "a bit too bright" vs retail. Root cause:
- My Phase 4 shader deliberately left vTint unclamped so D3D-style
overbright contributions to emissive meshes (dome has Emissive=1 → lit
could reach 2.0 with ambient + sun) would clamp naturally at the
framebuffer.
- But the frag cap was 1.2 (leaving "headroom for lightning flash"),
letting dome vertices run 20% hotter than retail's per-channel 1.0.
Retail's D3D fixed-function pipeline clamps vertex lit colour at
D3DRS_COLORCLAMP=1 (default) BEFORE texture modulation. We now match:
- Clamp `vTint = clamp(lit, 0, 1)` in sky.vert so the saturate happens
at the vertex stage, exactly like D3D.
- Drop normal-frame frag cap from 1.2 → 1.0 (the 3.0 flash relaxation
stays so lightning strobes still visibly blow out).
Expected visual:
- Dome: identical appearance (was clamping to framebuffer 1.0 anyway),
but pure retail-spec rendering so no sneaky 20% headroom.
- Clouds: unchanged (already < 1.0 at morning Rainy keyframe).
- Fragment flash during storm: unchanged — cap relaxes to 3.0 on flash.
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-enables the Phase 2 lighting formula that was reverted in Phase 3b
due to a "blue-green-yellow sweep" across clouds. Root cause of that
earlier regression was NOT the formula — it was that we rolled the
wrong DayGroup (Sunny when retail was Cloudy), producing a sharp warm
sun against a sky that should have been rendered with diffuse
overcast light. After Phase 3g pinned the LCG multiplier to 360
(DaysPerYear) so retail + acdream agree on DayGroup, the same
per-vertex formula now faithfully reproduces retail's visuals.
The formula is verified in decompile agent Q2+Q4+Q6 results,
`docs/research/2026-04-23-sky-material-state.md`:
D3DRS_LIGHTING = ON (FUN_0059da60:10648)
D3DRS_AMBIENT = 0 (never written after init)
Material.Emissive = (Luminosity, Luminosity, Luminosity, 1)
Material.Ambient/Diffuse = defaults (≈1,1,1,1) for non-luminous
light.Ambient = keyframe AmbColor × AmbBright (via SetDirectionalLight)
light.Diffuse = keyframe DirColor × DirBright
Fixed-function lighting per vertex:
lit = Emissive + Ambient × lightAmbient + Diffuse × lightDiffuse × max(N·L, 0)
= Surface.Luminosity + AmbColor×AmbBright + DirColor×DirBright × max(N·L, 0)
Fragment: texture × lit × SkyObjectReplace.Luminosity.
Expected visual:
- Dome (Surface.Luminosity=1): `lit = 1 + amb + diff·N·L` saturates to 1
→ texture passthrough, baked gradient preserved.
- Clouds (Surface.Luminosity=0): `lit = 0 + amb + diff·N·L`
→ purple haze at night (ambient dominates, sun below horizon);
→ warm tan at dusk (ambient + warm sun on west-facing vertices);
→ pale cool gray at noon (ambient + white sun from above).
- Sun/moon (SurfaceType.Additive, Luminosity=1): same as dome +
additive blend — stays bright regardless.
The shader uniforms (uAmbientColor, uSunColor, uSunDir, uEmissive)
were already wired in the C# renderer from Phase 2; Phase 3b just
stopped using them in the shader. This commit re-activates them.
No clamp at the vertex — retail's D3D lighting allows Emissive+sum
to exceed 1, relies on the framebuffer per-channel saturation. We
keep the 1.2 ceiling in the frag (for lightning flash overbright
headroom) consistent with that convention.
No fog yet (Q1 confirmed retail leaves fog enabled for sky; will add
in a follow-up if horizon looks too bright).
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 added a per-vertex lighting path to the sky shader based on the
Phase 1 dump showing dome surfaces with Luminosity=1.0 and cloud
surfaces with Luminosity=0.0. Live visual verification vs retail at
MorntideAndHalf (dayFraction=0.48, user-observed 2026-04-23) disproved
the hypothesis:
retail: clean blue sky + white clouds
acdream: blue-green-yellow sky sweep + greyish clouds
The "sweep" is exactly the signature of per-vertex `diffuse × sunColor`
where sunColor=(250,215,151) warm gold at ~63° east: the west-facing
cloud faces get the gold tint, east-facing stay cool, and interpolation
across the mesh produces the color sweep. Retail's clean white clouds
at the same time of day means retail is NOT applying per-vertex lighting
to sky meshes.
Revised model (unlit + SkyObjectReplace modulation):
fragment.rgb = texture.rgb * uLuminosity
fragment.a = texture.a * (1 - uTransparency)
The "purple haze night / warm dusk" effect users describe from retail
comes from SkyObjectReplace per-keyframe Luminosity dimming + Transparent
fading, NOT from a shader ambient multiply. At midnight, for example,
Replace[0] dims the dome to 11% (Luminosity_raw=11) and Replace[2]
fully hides the drifting cloud (Transparent_raw=100) — so the camera
sees the dome texture at 11% × baked gradient colors, and any purple
the user perceives is baked into the dome texture's night gradient.
The retail-authoritative Surface.Luminosity flag probably feeds a
separate render path (material system? D3D emissive vs diffuse
coefficients?) that is NOT per-vertex GL lighting. A future phase can
revive it if the decompile hunt for the DayGroup selection algorithm
surfaces it.
Code change: sky.vert + sky.frag only. The C# renderer still pushes
uAmbientColor/uSunColor/uSunDir/uEmissive uniforms — they are declared
in the shaders but unused in Phase 3b. No renderer change needed; these
uniforms cost nothing and keep the port-forward path open.
Build + 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of the sky port. Empirically confirmed from the Phase 1 dump
(ACDREAM_DUMP_SKY=1 on the live Dereth region): retail distinguishes
self-illuminated sky meshes from lit ones by the `Surface.Luminosity`
FLOAT field (0..1), NOT by the `SurfaceType.Luminous` flag bit (none of
Dereth's sky meshes have the flag set).
Observed values on the 4 currently-visible sky GfxObjs:
GfxObj 0x010015EE (dome, 4 surfaces) Luminosity = 1.0
GfxObj 0x010015EF (upper cloud) Luminosity = 0.0
GfxObj 0x01004C36 (lower drift cloud) Luminosity = 0.0
GfxObj 0x01001348 (sun/moon additive) Luminosity = 1.0
Retail uses this as an emissive coefficient in the per-vertex lighting
formula (decompiled chunk_00500000.c:7535 FUN_00508010 + chunk_00530000.c
AdjustPlanes per-vertex math):
tint = clamp(vec3(Luminosity) + AmbColor*AmbBright
+ max(dot(N, -sunDir), 0) * DirColor*DirBright,
0.0, 1.0)
fragment = texture * tint
When Luminosity=1.0 the clamp saturates → full texture brightness
regardless of time of day (dome gradient preserved; sun/moon always
bright). When Luminosity=0.0 only the ambient + diffuse term drives the
tint, so clouds pick up the time-of-day ambient (purple at midnight
per AmbColor=(200,100,255)×AmbBright=0.4 ≈ (0.31,0.16,0.40); warm tan
at dusk; pale-cool at noon).
Also empirically confirmed: raw SkyObjectReplace Transparent/Luminosity
/MaxBright are in 0..100 percent range (observed 11, 15, 22, 66, 100,
and -1 sentinel). The `/100` divide in SkyDescLoader (eeae83a) is
retail-correct; `_DAT_007a1870` in the decompile must be 0.01f.
Code changes:
- src/AcDream.Core/Meshing/GfxObjSubMesh.cs: new `Luminosity` field on
the per-submesh record (0..1, defaults to 0 for non-sky meshes).
- src/AcDream.Core/Meshing/GfxObjMesh.cs: pull Surface.Luminosity when
building submeshes (alongside existing Translucency capture).
- src/AcDream.App/Rendering/Sky/SkyRenderer.cs:
- SubMeshGpu gains SurfLuminosity, propagated from GfxObjSubMesh.
- Render() pushes uAmbientColor/uSunColor/uSunDir once per frame from
the interpolated keyframe; uEmissive once per submesh.
- uTint uniform removed (replaced by the vTint varying computed in
the vertex shader).
- src/AcDream.App/Rendering/Shaders/sky.vert: computes vTint per-vertex
using the retail AdjustPlanes formula.
- src/AcDream.App/Rendering/Shaders/sky.frag: consumes vTint, drops
uTint uniform. uLuminosity (the per-keyframe SkyObjectReplace
override) still applied as a final scalar multiply.
Expected visual difference from Phase 1 baseline:
- Dome gradient: IDENTICAL (Luminosity=1 saturates).
- Sun / moon: IDENTICAL (Luminosity=1 saturates, additive blend).
- Clouds: now tinted by time of day. Midnight → purple haze. Noon →
pale cool. Dusk → warm tan.
Open questions (unchanged from Phase 1 doc):
- Does the 15s LightTickSize throttling need porting? Phase 3.
- Does FUN_00532440 (AdjustPlanes per-cell terrain relight) need
porting for non-sky geometry to follow the sky? Phase 3.
Build + 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's Region dat stores SkyObjectReplace.Luminosity / Transparent /
MaxBright as percentages in the 0..100 range. Our shader expects
fractions in 0..1. We were passing raw values (luminosity up to 100)
straight into the sky fragment shader's rgb-multiplier:
rgb = sampled.rgb * uTint.rgb * uLuminosity;
At the "Sunny" DayGroup's noon keyframes (verified via live diag),
Luminosity = 100 → shader multiplied the cloud texture RGB by 100 →
min(rgb, vec3(1.2)) clamped all channels to 1.2 → pure white sky.
Also gave the dawn/dusk purple sky effect on top of the pale texture.
Fix: SkyDescLoader.ConvertTimeOfDay divides Luminosity, Transparent
and MaxBright by 100 when loading each SkyObjectReplace. The Rotate
field stays as degrees (values like 270° are genuine headings, not
percentages).
Transparent was accidentally surviving via a 0..1 clamp downstream,
but we fix it for consistency and so brightness-attenuating values
in the 0..99 range (partial fade during dawn/dusk) work correctly
instead of rounding to full-transparent.
WorldBuilder's SkyboxRenderManager does NOT apply these fields at
all — that's why they never hit this bug. Our port applies them for
per-keyframe day-night fades, so we needed the unit conversion.
Also picked up in this commit (incidental, already running):
- Sky render: per-submesh blend mode from TranslucencyKind.Additive
for sun/moon-style self-bright objects (Additive bit 0x10000).
Luminous flag 0x40 intentionally NOT mapped to additive — that
flag is on the sky dome + cloud sheets and making them additive
produced the previous "fully white" iteration of this bug.
- ToD default seed: DayTicks/16 (Midsong = hour 9 = true noon)
instead of DayTicks*0.5 which landed on Gloaming-and-Half (sunset)
due to DerethDateTime's +7/16 day-fraction offset. Pre-TimeSync
view now correctly starts at noon.
- Lightning flash: brighter white-blue (vec3(1.5,1.5,1.8)) instead
of dim grey; ceiling relaxed during flash so the strobe actually
blows out. Cadence (strike intervals, decay) unchanged.
- Saved docs/research/2026-04-21-sky-deep-audit.md with the
decompile+ACE+ACME+WorldBuilder research done to corner this bug.
Open follow-up (not fixed here): sky clouds are white at noon /
don't get the dusk/night purple tint. Our sky shader is fully unlit
— doesn't apply sun/ambient directional light like the terrain
shader does. AmbientColor in the keyframe data carries the right
tint (purple at midnight, magenta at dusk) but we pass
uTint = Vector4.One instead of the keyframe value. Next commit will
wire directional-sun + ambient into sky.frag so cloud meshes pick
up the time-of-day color.
All 717 tests green. User-confirmed: sky colors are now "much
better" after this change (previously fully white).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
- 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
- Ambient RGB + active light count
- Fog start/end/mode + color + lightning flash scalar
- Camera world position + day fraction
The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.
Shader changes:
- mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
fragment using the retail no-attenuation hard-cutoff model
(r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
Additive lightning flash + linear fog layered on top. Saturate
clamps per-channel to 1.0.
- terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
fog + flash on top of the baked vertex color.
- mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
stage can do per-pixel lighting against world-space positions.
- New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.
SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.
GameWindow integration:
- OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
WorldTime's provider to the dat-accurate keyframes. Seeds to noon
for offline rendering. Creates the SceneLightingUboBinding and the
SkyRenderer.
- OnRender: set clear color from atmosphere fog, tick WeatherSystem,
spawn/stop rain/snow camera-local emitters on kind change, feed
sun to LightManager (zero intensity indoors — r13 §13.7), tick
LightManager against viewer pos, build + upload the UBO, draw
sky before terrain, draw terrain + static + instanced using the
shared UBO.
5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>