Commit graph

16 commits

Author SHA1 Message Date
Erik
3d21c1352a refactor(sky): replace per-frame wrap-mode mutation with persistent samplers
Ports WorldBuilder's GL sampler-object pattern
(references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132,
SkyboxRenderManager.cs:312). Two persistent samplers (Repeat +
ClampToEdge) are created once at GL init; the sky pass binds the
appropriate one to texture unit 0 per submesh instead of mutating
per-texture GL_TEXTURE_WRAP_S/T state.

Why this is better than the prior M1 track-and-restore hack:

  1. Sampler state is decoupled from texture state. Two renderers can
     share the same texture handle but sample it with different wrap
     modes simultaneously and safely — sampler state at the bind point
     overrides the texture's own wrap parameters.

  2. No bookkeeping. Drops the HashSet<uint> clamped-textures tracking
     and the end-of-pass restore loop. The only restore needed is
     BindSampler(0, 0) to release unit 0 back to per-texture state.

  3. Constant cost. Sampler objects are created once per GL context,
     not per draw. Filter modes match TextureCache's upload defaults
     (Linear/Linear, no mipmaps) so the binding is purely a wrap-mode
     selection.

Field count: SkyRenderer.cs -28 lines, +14 lines. GameWindow.cs gets
the SamplerCache field + ctor + Dispose. SkyRenderer disposed before
SamplerCache so the sky teardown path doesn't reference a freed
sampler handle.

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:08:26 +02:00
Erik
ec1bbb4f43 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
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>
2026-04-28 22:47:11 +02:00
Erik
e4bc6de7ba chore(sky): post-merge cleanups — CullFace save/restore + stale comments
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.
2026-04-27 23:34:21 +02:00
Erik
646ccca85e feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
Independent code review by an external agent (2026-04-27) flagged
that SkyRenderer.EnsureMeshUploaded only ever called
_dats.Get<GfxObj>(...) — every 0x020xxx Setup ID returned null and
got cached as an empty submesh list, silently dropping every
Setup-backed sky object across the Dereth Region. In Rainy DG3
alone that's 6 dropped SkyObjects (0x02000714, 0x02000BA6 ×2,
0x02000588 ×4, 0x02000589 ×3 across various time-of-day windows).

Verbatim from retail's CelestialPosition struct at acclient.h:35451:

    struct CelestialPosition {
        IDClass<...> gfx_id;
        IDClass<...> pes_id;          // particle scheduler
        float heading; float rotation;
        Vector3 tex_velocity;
        float transparent; float luminosity; float max_bright;
        unsigned int properties;
    };

Per the named retail decomp, CPhysicsObj::InitPartArrayObject (decomp
~280484) dispatches gfx_id by type prefix: type 6 → direct GfxObj,
type 7 → Setup via CPartArray::CreateSetup (decomp ~287490) which
walks Setup.Parts. Mirror that here: detect 0x020xxxxx in
EnsureMeshUploaded, route to a new EnsureSetupUploaded helper that
flattens via SetupMesh.Flatten (existing Phase-2 utility) and bakes
each part's transform into the vertex positions before upload.
Sky setups don't animate in any way that affects the static-mesh
visual we render here.

Probe extension: also added the Diffuse column to RainMeshProbe's
sky-surface audit so the (Type, Translucency, Luminosity, Diffuse)
quadruple is visible on every flag-bit row.

Visual impact at verification launch: not observable. The Setup
objects in Rainy DGs appear to be tiny placeholder meshes existing
mainly to anchor PES emitters. The dynamic "aurora-like" sheen the
user observes in retail comes from the PES particle layer, which
remains unimplemented (issue #28). Keeping this fix because the
geometry path is now decomp-correct and provides foundation for
the eventual PES wiring.

Issue #29 filed for the residual cloud-density gap. 1227 tests pass.
2026-04-27 23:24:09 +02:00
Erik
034a684f02 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
The pre/post-scene sky pass split was using SkyObjectData.IsWeather
(bit 0x04) — the wrong bit. Per the named retail decomp:

  GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp 269036:
    MakeObject(this, gfx_id, &tex_velocity,
               (properties & 1),    // arg4: post-scene flag
               (properties & 4));   // arg5: weather gate

  GameSky::MakeObject at 0x00506ee0 / decomp 268656:
    if (arg4 != 0)
      AddObjectToSingleCell(result, after_sky_cell);   // post-scene
    else
      AddObjectToSingleCell(result, before_sky_cell);  // pre-scene

So bit 0x01 routes between before_sky_cell (rendered pre-scene by
GameSky::Draw(0)) and after_sky_cell (rendered post-scene by
GameSky::Draw(1)). Bit 0x04 is independent — it gates whether the
object is instantiated at all when LScape::weather_enabled is false.

In Dereth's Rainy DayGroup this matters for the rain cylinders:
  0x01004C42  Props=0x04 (bit 0x04 only)  → pre-scene + weather-gated
  0x01004C44  Props=0x05 (bits 0x01+0x04) → post-scene + weather-gated
  0x01004C35  Props=0x02 (bit 0x02 only)  → pre-scene (cloud, fog-hide)

Before this fix acdream put BOTH rain cylinders in the post-scene
pass (because both have bit 0x04). That double-rendered foreground
rain — explained why acdream's foreground rain looked thicker than
retail's. Now only 0x01004C44 is foreground; 0x01004C42 renders with
the sky dome.

Added SkyObjectData.IsPostScene (bit 0x01) with citations. Renamed
the internal RenderPass parameter weatherPass → postScenePass and
updated both the partition criterion and the -120m foreground-rain
Z offset to gate on it. Public RenderSky / RenderWeather entry
points kept their names for API stability; doc comments updated to
explain the bit semantics.

Independent confirmation from one of the user's external code-review
agents — the report's Setup-objects-silently-dropped finding is the
remaining defect in the same family (Setup IDs 0x020xxx aren't
loaded by EnsureMeshUploaded; deferred to a separate phase).

1227 tests pass.
2026-04-27 22:43:14 +02:00
Erik
97fc1b51d8 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
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.
2026-04-27 19:49:51 +02:00
Erik
4678b3ee6b fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather
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>
2026-04-27 12:04:55 +02:00
Erik
3e0da496e0 feat(sky): split SkyRenderer into pre-/post-scene passes + retail -120m weather Z offset
Bug A (foreground rain) from docs/research/2026-04-26-sky-investigation-handoff.md:
rain mesh was only visible at horizon, not in the air between camera and
character. Two retail mechanisms ported here:

1. **Render order split.** Retail's `LScape::draw` at 0x00506330 calls
   `GameSky::Draw(0)` BEFORE the landblock DrawBlock loop and
   `GameSky::Draw(1)` AFTER — i.e. weather meshes render after scene
   geometry so additive rain streaks paint on top of terrain and entities.
   Acdream was rendering both passes pre-scene, so terrain immediately
   painted over the rain.

   Refactored `SkyRenderer.Render` into `RenderSky` (filter !IsWeather)
   and `RenderWeather` (filter IsWeather) sharing a private `RenderPass`
   core that takes a `weatherPass` bool. Partition is per-SkyObject by
   `Properties & 0x04` (the WEATHER_BIT, mirroring tools/WeatherEnumerator).
   Added `SkyObjectData.IsWeather` getter for the partition.

   `GameWindow.OnRender` now calls `RenderSky` before terrain/static-mesh/
   particles (line ~4322) and `RenderWeather` after particles (line ~4368).

2. **Weather Z offset.** Retail `GameSky::UpdatePosition` at 0x00506dd0,
   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) get 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 sat just above the
   camera; with -120m the cylinder spans (camera-119.89)..(camera+694.90)
   so the camera is inside.

   `SkyRenderer.RenderPass` applies the -120m model translation when
   `weatherPass` is true (line ~253-254).

3. **Legacy camera-attached emitter gated.** `UpdateWeatherParticles` —
   the pre-research workaround that emitted camera-attached rain particles
   (broken alpha fade, fixed disk around camera) — is now gated behind
   `ACDREAM_FAKE_RAIN_PARTICLES=1`. Default off; the retail-faithful
   world-space mesh is the default path.

User-verified: rain is now visible in foreground from many perspectives,
but the cylinder's open-top rim is still visible when looking straight up.
That rim issue is a separate brightness-excess bug filed for follow-up
(Translucency float not plumbed to shader; surface.Translucency=0.5 ignored
so streaks render at 2× retail intensity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:49:42 +02:00
Erik
7b88fde52d fix(sky): drive wrap mode from mesh UV range — fixes Bug B (stars-as-square)
Bug B in docs/research/2026-04-26-sky-investigation-handoff.md: stars
rendered as a small square in one corner of the sky instead of stretching
across the dome.

Root cause: the wrap-mode heuristic at SkyRenderer.cs:234-237 was
"GL_CLAMP_TO_EDGE unless TexVelocity != 0". That heuristic was tuned to
fix a separate symptom (the outer dome 0x010015EE/F0/F1/F2 shows
wall-seam bleed under GL_REPEAT because of bilinear-filter sampling at
texel boundaries). But it misclassified any *static* sky object whose
mesh UVs are deliberately authored outside [0,1] to tile the texture
across the geometry.

The smoking gun: GfxObj 0x010015EF is OI-1 in EVERY DayGroup (always
loaded), has TexVelocity = 0 (no scrolling), and authors UVs in
[0.398, 4.602] (texture tiles ~4× across each face). Under
CLAMP_TO_EDGE the bulk of the inner dome sampled the texture's edge
texels; only the small region where UVs happened to fall in [0,1]
showed actual texture content. Hence "a square in one corner".

Fix:

* GfxObjMesh.Build() now scans the resulting per-vertex UVs and sets
  GfxObjSubMesh.NeedsUvRepeat true when any component lies outside
  [0,1]. Mesh-time scan, not draw-time guess.
* SubMeshGpu carries the flag through to draw time.
* SkyRenderer uses `sub.NeedsUvRepeat || obj.TexVelocity != 0` to
  decide REPEAT vs CLAMP_TO_EDGE. The dome (UVs in [0,1]) keeps
  CLAMP — no seam regression. The inner star/sky layer 0x010015EF
  (UVs outside [0,1]) gets REPEAT — texture tiles across the dome.
  Cloud meshes (UVs outside [0,1] AND non-zero TexVelocity) keep
  REPEAT via either branch.

Probe-driven: tools/StarsProbe (committed in 991fb9a) dumps every
SkyObject's geometry + UVs and flags meshes whose UV range exceeds
[0,1]. Run `dotnet run --project tools/StarsProbe -c Release` to
re-derive.

Verified visually by user against the live ACE server in Holtburg —
stars now stretch across the night sky instead of appearing as a
square in one corner. Build green, dotnet test 1222 pass.

Note: this is functionally retail-equivalent for the reported bug but
not the exact retail mechanism. Retail's GameSky::Draw at 0x00506ff0
relies on D3D's global default D3DTADDRESS_WRAP (i.e. REPEAT
everywhere). True retail-faithfulness would require investigating why
our pipeline shows seams on the dome under REPEAT (likely a bilinear
filter / non-seamless texture detail). The data-driven approach taken
here preserves working dome behavior while fixing the broken star
behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:55:24 +02:00
Erik
593b76fda1 sky(phase-8.1): CLAMP_TO_EDGE on static sky meshes
The dome is 5 flat quads meeting at edges. Under GL_REPEAT + LINEAR
filtering, the edge pixels of each wall wrap-sample to the OPPOSITE
edge of the SAME texture (repeat wraps 1.0→0.0). Those opposite-edge
pixels never match the adjacent wall's border, so every dome seam
showed up as a visible line — four vertical wall lines plus the top
cap formed a huge cube-outline in the sky.

Switch to GL_CLAMP_TO_EDGE for any sky submesh whose SkyObject has
TexVelocityX == TexVelocityY == 0 (i.e. not a scrolling cloud layer).
Scrolling clouds keep GL_REPEAT because their UV-offset accumulates
and needs seamless wrap. One-line heuristic on TexVelocity picks the
mode per-object per draw.

User confirmed both the cube-edges artifact and the "huge square in
the sky" artifact are gone after this change. Both were the same bug
— the square was the four wall seams forming a closed rectangle
across the view.
2026-04-24 20:42:59 +02:00
Erik
1d54880213 sky(phase-8): retail-faithful night sky + README refresh
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.
2026-04-24 20:34:36 +02:00
Erik
aa2e20a42e sky(phase-2): retail-verbatim per-vertex lighting via Surface.Luminosity
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>
2026-04-23 18:19:22 +02:00
Erik
58afd4850f sky(phase-1): revert speculative tint, add ACDREAM_DUMP_SKY diagnostic
The uncommitted uTint=AmbientColor-for-alpha-submeshes experiment (from
the 2026-04-22 inference) dimmed the sky dome's baked gradient — a
user-verified visual regression. Reverting to the eeae83a baseline
(uTint=Vector4.One for every submesh) while we execute the proper
retail-verbatim port.

Research: three parallel decompile-hunt agents landed verifying
retail's ground-truth sky pipeline for the first time (prior audits
searched for stripped symbol names; the trail opened via the Region
dat-type-index 0x1c registration at chunk_00410000.c:12952). Key
retail functions now mapped in chunk_00500000.c:1097-7535:
  - FUN_00501530: keyframe bracket-picker (with 1.0f wrap denominator)
  - FUN_00501600: sun+ambient interpolator (sunVec = DirBright ×
                  (sin yaw·cos pit, cos yaw·cos pit, sin pit))
  - FUN_00501860: fog interpolator
  - FUN_00502820: SkyDesc::Unpack (2 doubles + DayGroup list)
  - FUN_00502a10: build per-frame sky-object table
  - FUN_00505f30: apply light state + per-cell AdjustPlanes relight
  - FUN_005062e0: per-frame sky tick (throttled by LightTickSize)
  - FUN_00508010: sky-object render loop (enqueues through the NORMAL
                  mesh pipeline via FUN_00514b90 — not a bespoke path)

Surprise findings:
  - D3DRS_AMBIENT is set to 0 once at init and NEVER changes per-frame
    (chunk_005A0000.c). The r12-inferred "clouds = texture × D3DRS_
    AMBIENT" formula is falsified. Retail instead routes keyframe
    AmbColor through per-vertex lighting on non-Luminous sky meshes
    via _DAT_008682bc/c0/c4.
  - Retail does NOT anchor the sky to the camera or use a separate
    sky projection. Sky meshes live in world space and follow the
    camera via scene-graph parent.
  - FUN_00532440 (AdjustPlanes) re-lights every terrain cell on every
    keyframe tick — the "terrain follows the sky" effect we don't yet
    reproduce.

Phase 1 code change (this commit):
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: revert uTint to white
    for all submeshes (the per-submesh blend split stays — sun gets
    additive, clouds get alpha). Keep the `keyframe` parameter in the
    signature for Phase 2 readiness. Comments now cite the retail
    functions and reference docs instead of the (disproven) r12 formula.
  - src/AcDream.Core/World/SkyDescLoader.cs: ACDREAM_DUMP_SKY=1 logs
    the entire Region SkyDesc on load — DayGroups, SkyObjects, every
    SkyTimeOfDay keyframe, and every SkyObjectReplace with RAW pre-/100
    Transparent/Luminosity/MaxBright values so we can settle the unit
    question empirically.
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: ACDREAM_DUMP_SKY=1
    additionally logs each sky GfxObj's Surfaces and their SurfaceType
    flags on first load, so we can identify which meshes carry the
    Luminous bit (dome? sun? moon? stars?) vs which are lit.
  - src/AcDream.App/Rendering/GameWindow.cs: passes the interpolated
    keyframe to the sky renderer (kept — needed for Phase 2).

Research docs (pushed as part of this commit):
  - docs/research/2026-04-23-sky-retail-verbatim.md: full synthesis
    with retail function map, struct layouts, globals, pseudocode, and
    a 4-phase port plan.
  - docs/research/2026-04-23-sky-decompile-hunt-{A,B,C}.md: raw hunt
    outputs.
  - docs/research/2026-04-23-sky-references-crossref.md: WorldBuilder/
    ACE/ACViewer/holtburger/Chorizite coverage.
  - docs/research/2026-04-23-sky-dat-schema.md: full dat schema + unit
    analysis.
  - docs/research/2026-04-22-sky-lighting-decompile.md: prior agent's
    (superseded) inference — kept for provenance.

Phase 2 will port Surface.Luminous-flag-aware per-vertex lighting for
sky submeshes once the dump resolves the open questions (Luminous-flag
distribution per Dereth sky mesh; _DAT_007a1870 scale constant value).

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:06:52 +02:00
Erik
eeae83a14e fix(sky): scale keyframe Luminosity/Transparent/MaxBright from percent → fraction
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>
2026-04-22 17:38:44 +02:00
Erik
187226f504 fix(render): shader reserved-word + defensive SkyRenderer dat reads
Two runtime blockers discovered after merging the sky/weather/lighting
branch:

1. GLSL reserved word: mesh.frag + mesh_instanced.frag used \`int active\`
   as a local. On GLSL ES / some drivers \`active\` is a reserved identifier
   and compile fails hard (\"ERROR: 0:38: 'active' : Reserved word\").
   Renamed to \`activeLights\`.

2. SkyRenderer.EnsureMeshUploaded called DatCollection.Get<GfxObj>
   without the _datLock that wraps the streaming pipeline's dat reads.
   DatBinReader has shared buffer state; concurrent reads race and
   throw ArgumentOutOfRangeException from Vec2Duv.Unpack deep in the
   mesh parse. Wrapped both Get<GfxObj> and GfxObjMesh.Build in
   try/catch and cache a null entry on failure so we don't retry every
   frame and crash the render loop. Full fix would plumb _datLock into
   the sky renderer, left as a TODO.

Client now stable end-to-end — in-world, spawn stream flowing,
animation + audio + sky + light UBO all live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:00:34 +02:00
Erik
9957070cab feat(render): Phase G.1/G.2 — SceneLighting UBO + sky renderer + shader integration
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>
2026-04-19 10:39:48 +02:00