diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 79ece7b..6ab3a19 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -177,26 +177,6 @@ missing is the plugin-API surface. --- -## #1 — Rain falls only to horizon, not to the player's feet - -**Status:** OPEN -**Severity:** MEDIUM -**Filed:** 2026-04-25 -**Component:** weather / particles - -**Description:** During Rainy DayGroups, rain particles are visible in the upper sky band but fade out before reaching the camera / ground level. Retail's rain falls all the way past the camera to the terrain. - -**Root cause / status:** Unknown. Likely one of: (a) particle emitter volume too short in Z, (b) particle lifetime shorter than the time it takes to traverse emitter-top → ground, (c) emitter anchored in world-space so particles escape the player's reference frame as they fall, (d) camera-relative spawn origin is offset too high above the player. - -**Files:** -- `src/AcDream.App/Rendering/GameWindow.cs` — `UpdateWeatherParticles` (~line 4591) -- `src/AcDream.Core/Vfx/ParticleSystem.cs` — emitter spawn config + lifetime integration - -**Research:** `docs/research/deepdives/r12-weather-daynight.md` (rain mechanism — but does not pin volume / lifetime values). - -**Acceptance:** Standing at 9,115 in Holtburg during a Rainy DayGroup, rain drops visibly fall all the way from the sky band past the camera to the ground level. - ---- ## #2 — Lightning visual not wired (dat-baked PES triggers) @@ -292,10 +272,111 @@ missing is the plugin-API surface. --- +## #28 — Aurora ("northern lights") effect not rendered + +**Status:** OPEN +**Severity:** LOW (aesthetic feature-parity) +**Filed:** 2026-04-26 +**Component:** sky / vfx + +**Description:** Retail renders a dynamic colored "light play" effect in the sky during certain Rainy/Cloudy DayGroup time windows. The user describes it as aurora-borealis-style. acdream renders no comparable effect. + +**Root cause:** PES (Particle Effect Schedule) particles attached to SkyObjects via the `CelestialPosition.pes_id` field. Retail header at `acclient.h` line 35451 (verbatim): + +```c +struct CelestialPosition { + IDClass<...> gfx_id; + IDClass<...> pes_id; // ← particle scheduler ID + float heading; float rotation; + Vector3 tex_velocity; + float transparent; float luminosity; float max_bright; + unsigned int properties; +}; +``` + +`StarsProbe` confirmed Dereth Rainy DayGroup 3 carries multiple PES-bearing entries (verified 2026-04-27). Sample for the user's observed Warmtide-Rainy state: + +| OI | Gfx | **PES** | Active window | Notes | +|----|-----|---------|----|----| +| 5 | 0x02000714 | 0x330007DB | always | low-rate background | +| 7 | 0x02000BA6 | 0x33000453 | 0.03–0.19 | early morning | +| 17 | 0x02000589 | **0x3300042C** | **0.27–0.91** | **active during user's screenshot** | + +acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetupUploaded` walks `Setup.Parts` for `0x020xxx` IDs). The dynamic visual half — emitting and animating the PES particles — is unimplemented and provides the actual aurora look. Phase E.3 already has data-only PES support per memory crib `project_session_2026_04_18.md`; this issue requires the runtime + visual half. + +**Implementation outline:** +1. PES dat decode (already partially in `AcDream.Core.World.PesData` per Phase E.3). +2. PES emitter runtime — schedule, spawn, advect, color-cycle, expire each particle. +3. `SkyRenderer` integration — when `MakeObject` sees `pes_id != 0`, spawn the PES at the SkyObject's celestial position. +4. PES vertex-sprite renderer — billboarded textured quads with additive blending and color cycling. Probably reuses the future general-purpose particle renderer (issue #L? — TBD). + +**Decomp pointers:** +- `CPhysicsObj::InitPartArrayObject` decomp ~280484 — dispatches type 7 to Setup loader. +- `CPartArray::CreateSetup` decomp ~287490 — Setup → Parts → optional PES wiring. + +**Files:** +- `src/AcDream.Core/World/SkyDescLoader.cs` — `SkyObjectData` needs to carry `PesObjectId` (currently dropped on the floor). +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — needs a particle-emission step alongside the per-SkyObject mesh draw. + +**Acceptance:** When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time. + +--- + +## #29 — Cloud surface 0x08000023 still appears thinner than retail despite blend-mode + Setup fixes + +**Status:** OPEN +**Severity:** LOW (aesthetic feature-parity) +**Filed:** 2026-04-27 +**Component:** sky / clouds + +**Description:** User screenshot comparison showed acdream's clouds let too much sun through; retail's are denser and have a purpleish tint. Two follow-up fixes landed without visible improvement: + +1. `TranslucencyKindExtensions.FromSurfaceType` now applies retail's Translucent-override at `D3DPolyRender::SetSurface` (decomp 425246-425260) — surface `0x08000023` (Type=`0x10114` = `B1ClipMap | Translucent | Alpha | Additive`) is now correctly classified as `AlphaBlend` instead of `Additive`. +2. `SkyRenderer.EnsureSetupUploaded` now loads `0x020xxxxx` Setup IDs (e.g. `0x02000588`, `0x02000589`, `0x02000714`, `0x02000BA6`) which were silently dropped. Setup parts are flattened via `SetupMesh.Flatten` and uploaded with their per-part transform baked into vertex positions. + +Despite both being decomp-correct fixes, the user reports no observable visual change in dual-client comparison. Two follow-up hypotheses: + +- The Setup objects are tiny placeholder meshes (one `0x010001EC` part each) that exist mainly to anchor a PES emitter — the cloud "density" / "purple sheen" the user perceives is entirely the PES particle layer, not the static mesh. +- The cloud surface might still be rendering correctly per its dat data, and what looks "thicker" in retail is the additional aurora-like PES sheen overlaid on top. + +If hypothesis (a) is correct, this issue effectively rolls into **#28** — the PES rendering work would resolve both. + +**Files:** +- `src/AcDream.Core/Meshing/TranslucencyKind.cs` — Translucent override +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — `EnsureSetupUploaded` + +**Acceptance:** Cloud sheets look as dense/purple as retail in dual-client side-by-side. May require #28 (PES) to land first. + +--- + --- # Recently closed +## #27 — [DONE 2026-04-26] Cloud meshes appeared missing or faint vs retail + +**Closed:** 2026-04-26 +**Commit:** `4678b3e fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather` +**Resolution:** Resolved as a side-effect of the Bug A fix. The original observation came from a session where every sky mesh got `effEmissive = 1.0` (saturated `vTint` to white), which made stars/clouds look full-bright instead of time-of-day-tinted. Fix 2 corrected the emissive default to `sub.SurfLuminosity` so cloud surfaces (Lum=0.0) now run through the ambient+diffuse vertex-lit path and pick up keyframe tint. Fix 1 separately plumbed `surface.Translucency` to the shader, picking up the 0.25 translucency on cloud surface `0x08000023` (75% opacity). Visual verification under Phase 0 of the followup plan: clouds and colors now match retail at LCG-picked DayGroups across the day cycle. + +--- + +## #1 — [DONE 2026-04-26] Rain falls only to horizon, not to the player's feet + +**Closed:** 2026-04-26 +**Commits:** `3e0da49` (sky pass split + retail -120m Z offset), `4678b3e` (Surface.Translucency + Luminosity correctness), `d95a8d2` (legacy emitter delete) +**Resolution:** Two-part fix. First, rain rendering was completely re-architected to match retail's `LScape::draw` pattern at `0x00506330` — sky pass before the landblock loop (`RenderSky`), weather pass after (`RenderWeather`). Weather meshes now overlay terrain instead of being painted over. Camera anchored inside the rain cylinder via the retail-correct -120m Z offset (constant `0xc2f00000` in `GameSky::UpdatePosition` at `0x00506dd0`). Second, the per-Surface `Translucency` float (rain = 0.5) and `Luminosity` float (rain = 0.1484) were both being ignored by the renderer; plumbed end-to-end so streaks contribute at retail-correct intensity instead of 6.7× too bright. Legacy camera-attached particle emitter (`UpdateWeatherParticles` + `BuildRainDesc` + `BuildSnowDesc`) deleted; world-space mesh is the only path now. Snow rides the same fix automatically. Filed alongside two follow-up issues from the visual-verify session: `#27` (cloud rendering parity), `#28` (aurora/northern lights). + +--- + +## #26 — [DONE 2026-04-26] Stars rendered as a square in one corner of the sky + +**Closed:** 2026-04-26 +**Commit:** `7b88fde fix(sky): drive wrap mode from mesh UV range — fixes Bug B (stars-as-square)` +**Resolution:** SkyRenderer's wrap-mode heuristic was `GL_CLAMP_TO_EDGE unless TexVelocity != 0`, which mis-classified the inner sky/star layer `0x010015EF` (UVs in `[0.398, 4.602]`, TexVel=0). Most of the dome sampled the texture's edge texels; only the small region where UVs fell in `[0,1]` showed actual texture content. Fixed by computing `NeedsUvRepeat` per submesh from the actual UV range during `GfxObjMesh.Build()` and driving the wrap-mode choice from that flag plus the existing scrolling check. Outer dome `0x010015EE/F0/F1/F2` (UVs strictly in `[0,1]`) keeps `CLAMP_TO_EDGE` so no seam regression. Probe `tools/StarsProbe/` (commit `991fb9a`) committed alongside as the diagnostic that found this. + +--- + ## #25 — [DONE 2026-04-26] Phase K.3 — Settings panel + click-to-rebind UI **Closed:** 2026-04-26 diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0fbe73d..874aa94 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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: , + // PY Time: "). 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 } } - /// - /// 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 () - /// so walking never leaves the rain volume (r12 §7). - /// - 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; - } - - /// - /// 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. - /// - 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, - }; - - /// - /// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling - /// sideways drift, small billboards, 100 flakes/sec, long lifespan. - /// - 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 diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 4ddfbde..e492f6e 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -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 1050–1820m - // 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 1050–1820m. 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); } diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 48ac917..1aa9550 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -70,8 +70,18 @@ public sealed unsafe class SkyRenderer : IDisposable } /// - /// Draw the sky for this frame. Called FIRST in the render loop — - /// terrain / meshes / debug lines / overlay land on top. + /// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds — + /// every SkyObject with Properties & 0x04 == 0). + /// Called BEFORE the scene; terrain / meshes / debug lines / overlay + /// land on top via depth-test. + /// + /// + /// Mirrors the first half of retail's LScape::draw at + /// 0x00506330: that function calls GameSky::Draw(0) + /// (sky pass) before the landblock loop, then GameSky::Draw(1) + /// (weather pass) after. acdream splits the same way — see + /// for the post-scene companion. + /// /// /// /// Each submesh renders with retail's per-vertex lighting formula: @@ -91,12 +101,57 @@ public sealed unsafe class SkyRenderer : IDisposable /// field. /// /// - public void Render( + public void RenderSky( ICamera camera, Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, SkyKeyframe keyframe) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false); + + /// + /// Draw the POST-SCENE sky objects (the foreground rain mesh + /// 0x01004C44 on Rainy DayGroups, plus any other SkyObject with + /// Properties & 0x01 != 0). Called AFTER the scene so these + /// meshes paint on top of terrain and entities — retail-faithful order + /// from LScape::draw at 0x00506330, where + /// GameSky::Draw(1) fires after the DrawBlock loop and + /// renders the after_sky_cell 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. + /// + /// Method name kept as RenderWeather for API stability; the + /// pass actually partitions on + /// (Properties bit 0x01), not + /// (bit 0x04). The two bits are independent in retail per + /// GameSky::CreateDeletePhysicsObjects at 0x005073c0. + /// + /// + public void RenderWeather( + ICamera camera, + Vector3 cameraWorldPos, + float dayFraction, + DayGroupData? group, + SkyKeyframe keyframe) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true); + + /// + /// Shared pass for and . + /// Sets up the same GL state for both (depth-test off, additive + + /// alpha-blend per submesh, camera-anchored translation) and iterates + /// only the SkyObjects matching the requested partition by + /// — bit 0x01 per the + /// retail decomp at GameSky::MakeObject (0x00506ee0). + /// + 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 } /// - /// Lazy GfxObj build — reuses so the - /// pos/neg polygon splitting logic stays consistent with the main - /// static-mesh pipeline. Most sky meshes are single-surface. + /// Lazy mesh build for a sky object. Handles two cases: + /// + /// + /// 0x010xxxxx — direct . Reuses + /// so the pos/neg polygon + /// splitting logic stays consistent with the main static-mesh + /// pipeline. Most sky meshes are single-surface. + /// + /// + /// 0x020xxxxx. The agent at + /// 2026-04-27 found these Setup-backed sky objects (e.g. + /// 0x02000588, 0x02000589, 0x02000714, + /// 0x02000BA6) were silently dropped: every cache miss + /// fell into the GfxObj branch, returned null, and got cached + /// as an empty submesh list. Per the named retail decomp + /// CPhysicsObj::InitPartArrayObject at 0x0050ed40 + /// dispatches type 7 to CPartArray::CreateSetup + /// (decomp 280484) which loads the Setup and walks its parts. + /// We mirror that here: walks + /// Setup.Parts at the default placement frame and + /// produces submeshes for each + /// part. Per-part transforms are baked into vertex positions + /// (sky setups are static — no animation needed for the static + /// mesh half of the visual). + /// + /// + /// + /// Even with this fix the visible aurora-style sheen most retail + /// rainy/cloudy setups produce comes from the pes_id field + /// on each (a Particle + /// Effect Schedule) — that's a separate Phase-level feature. + /// Rendering the Setup's static parts here is the geometry half; + /// the dynamic particle half is deferred. + /// /// private void EnsureMeshUploaded(uint gfxObjId) { if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; + // Setup-backed sky object: walk Setup.Parts and bake per-part + // transforms into the per-vertex positions. See doc comment above. + if ((gfxObjId & 0xFF000000u) == 0x02000000u) + { + EnsureSetupUploaded(gfxObjId); + return; + } + // DatCollection isn't thread-safe and the streaming loader can be // actively reading a shared DatBinReader buffer; sky meshes are // loaded on the render thread but GfxObj.Unpack can race with the @@ -331,6 +526,71 @@ public sealed unsafe class SkyRenderer : IDisposable _gpuByGfxObj[gfxObjId] = gpuList; } + /// + /// Setup-backed sky object loader. Walks at + /// the default placement frame, builds submeshes via + /// , and bakes the per-part transform + /// into the vertex positions before upload. Static-pose only — sky + /// setups don't animate in any meaningful way for the visual we care + /// about (the dynamic look comes from pes_id particles, not + /// the underlying mesh). + /// + /// Mirrors retail's at + /// decomp 280484 dispatching type 7 → CPartArray::CreateSetup + /// → CSetup::SetSetupID, which loads the setup and instantiates + /// each part as a separate CPhysicsObj child. We collapse the + /// children into a flat submesh list because the sky pass renders + /// without per-part transforms anyway. + /// + /// + private void EnsureSetupUploaded(uint setupId) + { + Setup? setup = null; + try { setup = _dats.Get(setupId); } + catch { setup = null; } + + if (setup is null) + { + _gpuByGfxObj[setupId] = new List(); + return; + } + + var parts = SetupMesh.Flatten(setup); + var allSubs = new List(parts.Count); + foreach (var partRef in parts) + { + GfxObj? partGfx = null; + try { partGfx = _dats.Get(partRef.GfxObjId); } + catch { partGfx = null; } + if (partGfx is null) continue; + + System.Collections.Generic.IReadOnlyList? partSubs = null; + try { partSubs = GfxObjMesh.Build(partGfx, _dats); } + catch { partSubs = null; } + if (partSubs is null) continue; + + // Bake the part's local transform into the vertices. For sky + // setups we don't expect non-uniform scale, so transforming + // normals as directions is fine; if a future sky setup ever + // breaks that assumption we'd need an inverse-transpose here. + var partTx = partRef.PartTransform; + foreach (var sub in partSubs) + { + var transformed = new Vertex[sub.Vertices.Length]; + for (int i = 0; i < sub.Vertices.Length; i++) + { + var v = sub.Vertices[i]; + var p = Vector3.Transform(v.Position, partTx); + var n = Vector3.Normalize(Vector3.TransformNormal(v.Normal, partTx)); + transformed[i] = v with { Position = p, Normal = n }; + } + var rebuilt = sub with { Vertices = transformed }; + allSubs.Add(UploadSubMesh(rebuilt)); + } + } + _gpuByGfxObj[setupId] = allSubs; + } + /// /// Log each surface's raw flag bits and the derived /// . Called once per GfxObj when @@ -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 /// docs/research/2026-04-23-sky-retail-verbatim.md §6. /// public float SurfLuminosity; + /// + /// 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 GL_REPEAT 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. + /// + public bool NeedsUvRepeat; + /// + /// Surface.Translucency float (0..1) carried through from + /// . Passed to the + /// sky fragment shader as uSurfTranslucency; the shader + /// multiplies output alpha by (1 - x). 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 curr_alpha derivation in + /// D3DPolyRender::SetSurface at 0x59c767. + /// + public float SurfTranslucency; } } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index f468dae..47f4368 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -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(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; diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index d6a9cd0..31542a6 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -39,4 +39,41 @@ public sealed record GfxObjSubMesh( /// normal lighting path without change. /// public float Luminosity { get; init; } = 0f; + + /// + /// True when at least one vertex's UV component lies outside the + /// [0, 1] range, meaning the mesh was authored to have its + /// texture tile across the geometry (i.e. it expects + /// GL_REPEAT/D3DTADDRESS_WRAP). The sky renderer reads + /// this to decide between GL_REPEAT (this flag set, or any + /// scrolling layer) and GL_CLAMP_TO_EDGE (all UVs strictly + /// in [0,1]), which avoids wall-seam bleed on the dome + /// (UVs in [0,1]) while still tiling the inner star/cloud + /// layers (UVs in [~0.4, ~4.6]) correctly. + /// Defaults to false so non-sky consumers get the previous behavior. + /// + public bool NeedsUvRepeat { get; init; } = false; + + /// + /// Surface.Translucency float (0..1) treated as an OPACITY + /// multiplier on fragment alpha. 1.0 = fully opaque (default for + /// non-Translucent surfaces). Distinct from the + /// classifier above, which buckets the + /// flag bits. Retail's D3DPolyRender::SetSurface at + /// 0x59c7a6 (decomp lines 425255-425260) reads + /// Surface.Translucency when the Translucent (0x10) bit + /// is set, computes curr_alpha = _ftol2(translucency × 255), + /// and writes that as vertex alpha — i.e. the dat's Translucency float + /// is used DIRECTLY as opacity, NOT inverted. ACViewer + /// (TextureCache.cs:142) and WorldBuilder + /// (ObjectMeshManager.cs:1115) both use 1 - translucency + /// and are wrong by the same misread. + /// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5; + /// with the (SrcAlpha, One) 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. + /// + public float SurfTranslucency { get; init; } = 1f; } diff --git a/src/AcDream.Core/Meshing/TranslucencyKind.cs b/src/AcDream.Core/Meshing/TranslucencyKind.cs index 9d0ab7b..07aaa29 100644 --- a/src/AcDream.Core/Meshing/TranslucencyKind.cs +++ b/src/AcDream.Core/Meshing/TranslucencyKind.cs @@ -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. /// /// Maps a flags value to the correct @@ -58,6 +79,19 @@ public static class TranslucencyKindExtensions /// 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; diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs index 592b868..a2e843d 100644 --- a/src/AcDream.Core/World/DerethDateTime.cs +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -90,21 +90,30 @@ public static class DerethDateTime GloamingAndHalf, } - /// Derethian months (Snowreap..Frostfell, 12 total). + /// + /// Derethian months in chronological order. Year-0 begins at month 0 + /// () and progresses through the 12-month + /// cycle. Names + order match retail's calendar display + /// (GameTime::CalcDayBegin + GetDateTimeString at + /// 0x005a6530) and ACE's DerethDateTime.cs. Verified + /// against retail's @timestamp output in 2026-04-27 dual- + /// client comparison: at day-of-year 83, retail shows + /// "Seedsow 24" — that fixes month index 2 = Seedsow. + /// 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, } /// @@ -127,12 +136,15 @@ public static class DerethDateTime /// for the boot window before the dat parses. /// /// - /// Live Dereth dat value: 3600. 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 - /// docs/research/2026-04-23-daygroup-selection.md §4 and - /// the Phase 3f commit. + /// Live Dereth dat value: 3600. Retail's + /// GameTime::CalcDayBegin at 0x005a6400 (decomp line + /// 434549) computes arg2 + zero_time_of_year as the basis for + /// year/day-of-year extraction, then derives time_of_day_begin + /// such that (arg2 - time_of_day_begin) / day_length in + /// CalcTimeOfDay gives (arg2 + zero_time_of_year) mod day_length / day_length. + /// 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.) /// /// public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks; @@ -186,7 +198,10 @@ public static class DerethDateTime /// /// Derethian calendar breakdown: (year, month, day, hour). - /// Year starts at PY 0. Day is 1-based within the month (1..30). + /// is the absolute Portal Year (= relative-year + + /// ) so the value matches retail's + /// @timestamp output ("Date: <Month> <Day>, + /// <Year> P.Y."). Day is 1-based within the month (1..30). /// 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)); } /// diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index dda09ca..ada2753 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -36,6 +36,45 @@ public sealed class SkyObjectData public uint GfxObjId; public uint Properties; + /// + /// True when this SkyObject is gated on the weather system (Properties + /// bit 0x04). Per the named retail decomp, + /// GameSky::CreateDeletePhysicsObjects at 0x005073c0 + /// passes Properties & 4 as arg5 of + /// GameSky::MakeObject (0x00506ee0); the inner + /// (arg5 == 0 || LScape::weather_enabled != 0) guard at decomp + /// line 268630 means weather-flagged objects only get instantiated when + /// the global weather flag is on. This bit does not control + /// pre/post-scene placement — that's . + /// 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. + /// + public bool IsWeather => (Properties & 0x04u) != 0u; + + /// + /// True when this SkyObject renders after the world scene + /// (Properties bit 0x01) — i.e. as foreground over terrain and + /// entities. Per the named retail decomp, + /// GameSky::CreateDeletePhysicsObjects passes + /// Properties & 1 as arg4 of + /// GameSky::MakeObject (decomp line 269036); MakeObject at + /// decomp 268656 routes arg4 != 0 objects into + /// after_sky_cell instead of before_sky_cell, and + /// GameSky::Draw(arg2=1) at 0x00506ff0 draws + /// after_sky_cell as a separate post-scene pass. + /// + /// In Dereth's Rainy DayGroup this distinguishes the two rain + /// cylinders: 0x01004C44 (Props=0x05) is foreground rain + /// rendered after terrain; 0x01004C42 (Props=0x04 alone) is + /// background rain rendered with the sky dome. Earlier + /// versions of acdream incorrectly split on + /// (bit 0x04) so both rain meshes ended up in the post-scene pass, + /// double-rendering rain in the foreground. + /// + /// + public bool IsPostScene => (Properties & 0x01u) != 0u; + /// Object is visible at day-fraction /// by retail's begin/end semantics (r12 §2). Three cases: /// @@ -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, diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 94e1ab5..5acf2d3 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -34,24 +34,82 @@ public enum FogMode /// /// /// -/// 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 DirBright / AmbBright. 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 +/// / with the brightness +/// scalars in / . Retail's +/// SkyDesc::GetLighting at 0x00500ac9 (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). +/// +/// +/// The computed properties and +/// return the post-multiplied product, so +/// downstream shader uniform plumbing (sky.vert / mesh.vert / +/// SceneLightingUbo) is unchanged. /// /// 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) +{ + /// + /// Final directional sun color the shader feeds into N·L lighting. + /// Retail-faithful magnitude formula: + /// SunColor = DirColor × |sunVec| + /// where sunVec is retail's heading+pitch+brightness vector + /// (see ). + /// + /// + /// Why |sunVec| instead of DirBright directly: retail's + /// PrimD3DRender::UpdateLightsInternal at 0x0059b57c + /// (decomp line 424118-424119) computes + /// D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²) + /// from the sun vector SkyDesc::GetLighting built at + /// 0x00500ac9 (decomp lines 261343-261353): + /// + /// sunVec.x = sin(H) × DirBright × cos(P) + /// sunVec.y = cos(P) // NOT scaled by DirBright + /// sunVec.z = DirBright × sin(P) + /// + /// Because Y is unscaled by DirBright, |sunVec| ≠ + /// DirBright in general — it varies with sun pitch and heading. + /// Using DirBright alone underweighted the warm directional + /// term, letting the cool ambient/fog dominate ⇒ acdream rendered + /// blue-white at keyframes where retail looked warm-gray. + /// + /// + public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length(); + + /// + /// Final ambient color the shader feeds into the per-vertex tint. + /// Retail-faithful magnitude formula: + /// AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|) + /// matching SmartBox::SetWorldAmbientLight as called at + /// 0x0050560b (decomp line 267117): + /// SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color) + /// 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 AmbBright alone, which + /// is roughly 44% too dim mid-day ⇒ contributed to the blue-white + /// bias because the warm fill was missing. + /// + public Vector3 AmbientColor => + AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length()); +} /// /// 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; } + /// + /// Retail's raw sun vector (NOT normalized) — the same vector + /// SkyDesc::GetLighting writes at 0x00500ac9 + /// (decomp lines 261343, 261352, 261353): + /// + /// sunVec.x = sin(H_rad) × DirBright × cos(P_rad) + /// sunVec.y = cos(P_rad) // NOT scaled by DirBright + /// sunVec.z = DirBright × sin(P_rad) + /// + /// Y is unscaled by brightness on purpose — that's what makes + /// |sunVec|DirBright 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 + /// uSunDir uniform uses the NORMALIZED vector for N·L; the + /// magnitude feeds intensity and + /// the ambient brightness boost in . + /// + 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) + } + /// /// 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 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. /// 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; } } diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs index 19d44ef..86fb5a9 100644 --- a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs +++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs @@ -75,26 +75,56 @@ public sealed class DerethDateTimeTests } [Fact] - public void ToCalendar_PY0Day1_Snowreap() + public void ToCalendar_PY10Day1_Morningthaw() { + // Tick 0 maps to PY 10 (= relative year 0 + ZeroYear=10), + // Morningthaw 1 — matches retail's calendar epoch + // (ACE DerethDateTime.cs: dayZeroTicks = 0; // Morningthaw 1, 10 P.Y.). var cal = DerethDateTime.ToCalendar(0); - Assert.Equal(0, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(DerethDateTime.ZeroYear, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); Assert.Equal(1, cal.Day); } [Fact] public void ToCalendar_AdvancesCorrectly() { - // One year from start → PY 1, Snowreap 1. + // One year from start → PY (10 + 1) = 11, Morningthaw 1. var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks); - Assert.Equal(1, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(DerethDateTime.ZeroYear + 1, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); Assert.Equal(1, cal.Day); - // One month into year 1. + // One month into year 11 → Solclaim (next month after Morningthaw). var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks); - Assert.Equal(1, cal2.Year); - Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month); + Assert.Equal(DerethDateTime.ZeroYear + 1, cal2.Year); + Assert.Equal(DerethDateTime.MonthName.Solclaim, cal2.Month); + } + + [Fact] + public void ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat() + { + // Regression guard for the 2026-04-27 dual-client comparison. + // Retail @timestamp output format is + // "Date: , P.Y." + // Pick a tick at the exact start of Seedsow 24 in relative year 106: + // shifted = 106 * YearTicks + 2 * MonthTicks + 23 * DayTicks + // Derived: 290,779,200 + 457,200 + 175,260 = 291,411,660. Subtract + // OriginOffsetTicks (3600 in Dereth dat) to get the input tick: + // 291,411,660 - 3600 = 291,408,060 + // Expected output: PY 116 (= ZeroYear 10 + relative 106), Seedsow, + // day 24 1-indexed. + DerethDateTime.SetOriginOffsetFromDat(3600.0); + try + { + var cal = DerethDateTime.ToCalendar(291_408_060.0); + Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month); + Assert.Equal(24, cal.Day); + } + finally + { + DerethDateTime.SetOriginOffsetFromDat(DerethDateTime.DayFractionOriginOffsetTicks); + } } } diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index bbb619d..3331e85 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -73,15 +73,26 @@ public sealed class SkyDescLoaderTests } [Fact] - public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness() + public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude() { + // The loader stores DirColor and DirBright RAW. The SunColor property + // composes them via |sunVec| per retail's UpdateLightsInternal at + // 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²) + // where the sun vector is built from heading/pitch/brightness with + // Y unscaled by brightness (decomp 261352). + // + // For this region: H=180°, P=70°, B=1.5 + // sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70)) + // = (0, 0.342, 1.410) + // |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509 + // DirColor.X = 200/255 = 0.7843 + // SunColor.X = 0.7843 × 1.4509 = 1.138 var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200); var loaded = SkyDescLoader.LoadFromRegion(region); Assert.NotNull(loaded); var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe; - // R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176 - Assert.InRange(kf.SunColor.X, 1.17f, 1.19f); + Assert.InRange(kf.SunColor.X, 1.13f, 1.15f); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index 272bdc5..bd3bc73 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -25,17 +25,105 @@ public sealed class SkyStateTests } [Fact] - public void Interpolate_BetweenKeyframes_LerpsColors() + public void Interpolate_BetweenKeyframes_LerpsRawInputs() { var sky = SkyStateProvider.Default(); var dawn = sky.Interpolate(0.25f); var noon = sky.Interpolate(0.5f); var midPt = sky.Interpolate(0.375f); - // Midpoint should fall between dawn & noon for sun color Y (green channel). - float low = System.Math.Min(dawn.SunColor.Y, noon.SunColor.Y); - float high = System.Math.Max(dawn.SunColor.Y, noon.SunColor.Y); - Assert.InRange(midPt.SunColor.Y, low, high); + // The RAW per-channel inputs (DirColor, AmbColor, brightness scalars) + // lerp linearly between adjacent keyframes — that's the retail-faithful + // separate-channel interpolation. The composite SunColor / AmbientColor + // properties intentionally do NOT lerp linearly (their magnitude + // depends nonlinearly on heading/pitch/brightness via the retail + // sun-vector formula), so we assert on the raw inputs here. + float low = System.Math.Min(dawn.DirColor.Y, noon.DirColor.Y); + float high = System.Math.Max(dawn.DirColor.Y, noon.DirColor.Y); + Assert.InRange(midPt.DirColor.Y, low, high); + } + + [Fact] + public void RetailSunVector_AtZenith_HasMagnitudeEqualToBrightness() + { + // Sun straight up (P=90°): cos(P)=0, sin(P)=1. + // sunVec = (sin(H)×B×0, 0, B×1) = (0, 0, B) + // |sunVec| = B + var kf = new SkyKeyframe( + Begin: 0.5f, + SunHeadingDeg: 0f, + SunPitchDeg: 90f, + DirColor: Vector3.One, + DirBright: 1.5f, + AmbColor: Vector3.One, + AmbBright: 0.3f, + FogColor: Vector3.One, + FogDensity: 0f); + + var v = SkyStateProvider.RetailSunVector(kf); + Assert.InRange(v.Length(), 1.49f, 1.51f); + } + + [Fact] + public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne() + { + // Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0. + // sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0) + // |sunVec| = 1 regardless of B (because Y is unscaled by B) + var kf = new SkyKeyframe( + Begin: 0f, + SunHeadingDeg: 0f, + SunPitchDeg: 0f, + DirColor: Vector3.One, + DirBright: 2.0f, // anything + AmbColor: Vector3.One, + AmbBright: 1f, + FogColor: Vector3.One, + FogDensity: 0f); + + var v = SkyStateProvider.RetailSunVector(kf); + Assert.InRange(v.Length(), 0.99f, 1.01f); + } + + [Fact] + public void SunColor_UsesRetailMagnitudeNotDirBrightDirectly() + { + // At sun pitch 90° (zenith) with H=0, B=2: |sunVec| = 2. + // SunColor = DirColor × |sunVec| = (0.5, 0.5, 0.5) × 2 = (1, 1, 1). + var kf = new SkyKeyframe( + Begin: 0.5f, + SunHeadingDeg: 0f, + SunPitchDeg: 90f, + DirColor: new Vector3(0.5f, 0.5f, 0.5f), + DirBright: 2.0f, + AmbColor: Vector3.One, + AmbBright: 0.3f, + FogColor: Vector3.One, + FogDensity: 0f); + + Assert.InRange(kf.SunColor.X, 0.99f, 1.01f); + Assert.InRange(kf.SunColor.Y, 0.99f, 1.01f); + Assert.InRange(kf.SunColor.Z, 0.99f, 1.01f); + } + + [Fact] + public void AmbientColor_BoostsByTwentyPercentOfSunVectorLength() + { + // |sunVec| = 1 (horizon north), AmbBright = 0.4, AmbColor = (1,1,1). + // AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|) + // = (1,1,1) × (0.4 + 0.2) = (0.6, 0.6, 0.6). + var kf = new SkyKeyframe( + Begin: 0f, + SunHeadingDeg: 0f, + SunPitchDeg: 0f, + DirColor: Vector3.One, + DirBright: 1f, + AmbColor: Vector3.One, + AmbBright: 0.4f, + FogColor: Vector3.One, + FogDensity: 0f); + + Assert.InRange(kf.AmbientColor.X, 0.59f, 0.61f); } [Fact] @@ -56,8 +144,10 @@ public sealed class SkyStateTests Begin: 0.5f, SunHeadingDeg: 180f, // south SunPitchDeg: 70f, - SunColor: Vector3.One, - AmbientColor: Vector3.One, + DirColor: Vector3.One, + DirBright: 1f, + AmbColor: Vector3.One, + AmbBright: 1f, FogColor: Vector3.One, FogDensity: 0.001f); diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs index b1d6c24..7acf0d1 100644 --- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs +++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs @@ -58,8 +58,10 @@ public sealed class WorldTimeDebugTests Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 90f, - SunColor: System.Numerics.Vector3.One, - AmbientColor: System.Numerics.Vector3.One, + DirColor: System.Numerics.Vector3.One, + DirBright: 1f, + AmbColor: System.Numerics.Vector3.One, + AmbBright: 1f, FogColor: System.Numerics.Vector3.Zero, FogDensity: 0f), }); diff --git a/tools/RainMeshProbe/Program.cs b/tools/RainMeshProbe/Program.cs new file mode 100644 index 0000000..0839f3d --- /dev/null +++ b/tools/RainMeshProbe/Program.cs @@ -0,0 +1,196 @@ +// RainMeshProbe — independent code-review recommended probe (Bug A, post-#26). +// +// Per Report 1's §5: "Run one targeted probe for 0x01004C42/0x01004C44: print +// surface raw type/translucency, each polygon's SidesType/Stippling, and +// GfxObjMesh.Build() submesh/index counts. If one cylinder has more than 48 +// indices per side-equivalent, fix the duplicate-side/cull behavior together +// with the surface-opacity uniform." +// +// The cylinder has 8 wall quads. With fan-triangulation each quad → 2 tris → +// 6 indices, total 48 indices per side. If pos-only emission: 48. If pos+neg: +// 96. The threshold tells us whether double-sided drawing is happening. +using System; +using System.IO; +using System.Linq; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using AcDream.Core.Meshing; +using SysEnv = System.Environment; + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); +Console.WriteLine($"datDir = {datDir}"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +uint[] gfxIds = { 0x01004C42u, 0x01004C44u }; +foreach (uint gid in gfxIds) ProbeRain(dats, gid); + +// Phase 7c: also dump every sky surface we know to test the LUMINOUS flag. +// Two existing code comments contradict each other about whether Dereth's +// dome/sun/moon meshes carry the LUMINOUS bit. Resolve empirically. +Console.WriteLine(); +Console.WriteLine("================ Sky Surface LUMINOUS audit ================"); +uint[] skySurfaceIds = { + 0x08000048u, 0x08000049u, 0x0800004Au, 0x0800004Bu, // dome 0x010015EE + 0x0800004Du, // star sheet 0x010015EF + 0x0800004Eu, 0x0800004Fu, 0x08000050u, 0x08000051u, // dome 0x010015F0 + 0x08000053u, 0x08000054u, 0x08000055u, 0x08000056u, // dome 0x010015F1 + 0x08000057u, 0x08000058u, 0x08000059u, 0x0800005Au, // dome 0x010015F2 + 0x080000D1u, // celestial 0x01001348 + 0x080000D2u, // sun-like 0x01001F67 + 0x080000D6u, 0x080000D7u, // moon 0x01001F6A + 0x080000D4u, // cloud 0x01004C36/37 + 0x08000023u, // cloud 0x01004C35 + 0x08000024u, 0x08000025u, // cloud 0x01004C39/3A + 0x080000D5u, // dome variant 0x010015B6 + 0x080000C5u, // RAIN — control row, expected NO Luminous +}; +foreach (uint sid in skySurfaceIds) ProbeSkySurface(dats, sid); + +return 0; + +static void ProbeSkySurface(DatCollection dats, uint sid) +{ + if (!dats.TryGet(sid, out var s) || s is null) + { Console.WriteLine($" Surface 0x{sid:X8} (NOT FOUND)"); return; } + uint t = (uint)s.Type; + bool luminous = (t & 0x40u) != 0u; + Console.Write($" Surface 0x{sid:X8} Type=0x{t:X8} Luminous={(luminous ? "YES" : "no ")} Lum={s.Luminosity:F4} Trans={s.Translucency:F4} Diff={s.Diffuse:F4} "); + // Decode bits inline. + var bits = new (uint mask, string n)[] { + (0x01u,"B1Solid"),(0x02u,"B1Image"),(0x04u,"B1ClipMap"),(0x10u,"Translucent"), + (0x20u,"Diffuse"),(0x40u,"Luminous"),(0x100u,"Alpha"),(0x200u,"InvAlpha"), + (0x10000u,"Additive"),(0x20000u,"Detail"), + }; + Console.WriteLine(string.Join("|", bits.Where(b => (t & b.mask) != 0).Select(b => b.n))); +} + +static void ProbeRain(DatCollection dats, uint gid) +{ + Console.WriteLine(); + Console.WriteLine($"================ GfxObj 0x{gid:X8} ================"); + if (!dats.TryGet(gid, out var go) || go is null) + { + Console.WriteLine(" (NOT FOUND)"); + return; + } + + Console.WriteLine($" Flags={go.Flags}"); + Console.WriteLine($" VertexArray.Vertices.Count={go.VertexArray?.Vertices.Count ?? 0}"); + Console.WriteLine($" Polygons.Count={go.Polygons?.Count ?? 0}"); + Console.WriteLine($" Surfaces.Count={go.Surfaces?.Count ?? 0}"); + Console.WriteLine($" PhysicsPolygons.Count={go.PhysicsPolygons?.Count ?? 0}"); + Console.WriteLine($" SortCenter=({go.SortCenter.X:F2},{go.SortCenter.Y:F2},{go.SortCenter.Z:F2})"); + + // ----- Per-Surface dump ----- + Console.WriteLine(); + Console.WriteLine(" --- Surfaces (raw dat record) ---"); + if (go.Surfaces is { Count: > 0 }) + { + for (int i = 0; i < go.Surfaces.Count; i++) + { + uint sid = (uint)go.Surfaces[i]; + Console.WriteLine($" Surface[{i}] = 0x{sid:X8}"); + if (!dats.TryGet(sid, out var surf) || surf is null) + { + Console.WriteLine(" (Surface NOT FOUND)"); + continue; + } + uint typeRaw = (uint)surf.Type; + Console.WriteLine($" Type=0x{typeRaw:X8} ({surf.Type})"); + Console.WriteLine($" decoded bits:"); + DumpFlagBits(typeRaw); + Console.WriteLine($" Translucency={surf.Translucency:F4} (1.0 - x = opacity = {1f - surf.Translucency:F4})"); + Console.WriteLine($" Luminosity={surf.Luminosity:F4}"); + Console.WriteLine($" Diffuse={surf.Diffuse:F4}"); + Console.WriteLine($" ColorValue=" + (surf.ColorValue is null ? "null" : + $"A:{surf.ColorValue.Alpha} R:{surf.ColorValue.Red} G:{surf.ColorValue.Green} B:{surf.ColorValue.Blue}")); + Console.WriteLine($" OrigTextureId=0x{(uint)surf.OrigTextureId:X8}"); + Console.WriteLine($" OrigPaletteId=0x{(uint)surf.OrigPaletteId:X8}"); + } + } + + // ----- Per-Polygon dump ----- + Console.WriteLine(); + Console.WriteLine(" --- Polygons (sides + stippling — checks Report 1 hypothesis) ---"); + if (go.Polygons is { Count: > 0 }) + { + int posCount = 0, negCount = 0; + foreach (var kv in go.Polygons) + { + var p = kv.Value; + // Mirror the GfxObjMesh.Build() emission rule (lines 71-91): + bool hasPos = !p.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = + p.Stippling.HasFlag(StipplingType.Negative) || + p.Stippling.HasFlag(StipplingType.Both) || + (!p.Stippling.HasFlag(StipplingType.NoNeg) && p.SidesType == CullMode.Clockwise); + if (hasPos) posCount++; + if (hasNeg) negCount++; + + Console.WriteLine( + $" Poly[{kv.Key,3}] VertexIds={p.VertexIds.Count} " + + $"PosSurface={p.PosSurface} NegSurface={p.NegSurface} " + + $"Stippling={p.Stippling} SidesType={p.SidesType} " + + $"hasPos={hasPos} hasNeg={hasNeg} " + + $"PosUVIdx={p.PosUVIndices.Count} NegUVIdx={p.NegUVIndices.Count}"); + } + Console.WriteLine($" Build emission summary: pos-side polys={posCount} neg-side polys={negCount}"); + } + + // ----- GfxObjMesh.Build() output ----- + Console.WriteLine(); + Console.WriteLine(" --- GfxObjMesh.Build() output ---"); + var subs = GfxObjMesh.Build(go, dats); + Console.WriteLine($" Submesh count: {subs.Count}"); + int totalVerts = 0, totalIndices = 0; + for (int i = 0; i < subs.Count; i++) + { + var s = subs[i]; + totalVerts += s.Vertices.Length; + totalIndices += s.Indices.Length; + Console.WriteLine( + $" Submesh[{i}] SurfaceId=0x{s.SurfaceId:X8} " + + $"Vertices={s.Vertices.Length} Indices={s.Indices.Length} " + + $"Translucency={s.Translucency} Luminosity={s.Luminosity:F2} " + + $"NeedsUvRepeat={s.NeedsUvRepeat}"); + } + Console.WriteLine($" TOTAL: verts={totalVerts} indices={totalIndices}"); + Console.WriteLine(); + Console.WriteLine($" Report 1 threshold check: with 8 wall quads × 2 tris × 3 indices = 48 indices per side."); + Console.WriteLine($" pos-only emission expects ~48 indices total."); + Console.WriteLine($" pos+neg emission expects ~96 indices total."); + Console.WriteLine($" OBSERVED: {totalIndices} indices → " + + (totalIndices > 60 ? "*** DOUBLE-SIDED — duplicate-side rendering active ***" : "single-sided")); +} + +static void DumpFlagBits(uint type) +{ + // From docs/research/named-retail/acclient.h:5820-5836. + // Print every named SurfaceType bit that's set. + var bits = new (uint mask, string name)[] + { + (0x00000001u, "Base1Solid"), + (0x00000002u, "Base1Image"), + (0x00000004u, "Base1ClipMap"), + (0x00000010u, "Translucent"), + (0x00000020u, "Diffuse"), + (0x00000040u, "Luminous"), + (0x00000100u, "Alpha"), + (0x00000200u, "InvAlpha"), + (0x00010000u, "Additive"), + (0x00020000u, "Detail"), + (0x10000000u, "Gouraud"), + (0x40000000u, "Stippled"), + (0x80000000u, "Perspective"), + }; + foreach (var (mask, name) in bits) + { + if ((type & mask) != 0) + Console.WriteLine($" {name} (0x{mask:X8})"); + } +} diff --git a/tools/RainMeshProbe/RainMeshProbe.csproj b/tools/RainMeshProbe/RainMeshProbe.csproj new file mode 100644 index 0000000..7e499da --- /dev/null +++ b/tools/RainMeshProbe/RainMeshProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + RainMeshProbe + + + + + + + diff --git a/tools/StarsProbe/Program.cs b/tools/StarsProbe/Program.cs new file mode 100644 index 0000000..2902a0a --- /dev/null +++ b/tools/StarsProbe/Program.cs @@ -0,0 +1,153 @@ +// StarsProbe — Bug B (sky-investigation-handoff §"Bug B"): dump every +// SkyObject's geometry + UVs to identify the star object and verify +// whether its UV range matches what GL_CLAMP_TO_EDGE supports. +// +// Sibling of WeatherEnumerator/SetupProbe/etc under tools/. Walks all +// DayGroups in the Dereth Region (0x13000000), prints every SkyObject +// (Properties bits, TexVelocity, BeginTime/EndTime), then dumps the +// underlying GfxObj's vertices, UV ranges, and surfaces. The crucial +// diagnostic is the per-GfxObj "UV range outside [0,1]" flag — when +// that's set on a static (non-scrolling) sky object, our SkyRenderer's +// CLAMP_TO_EDGE heuristic mis-samples and the texture appears as a +// "square in one corner" of the geometry. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + +Console.WriteLine($"datDir = {datDir}"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +if (!dats.TryGet(0x13000000u, out var region) || region is null) +{ + Console.Error.WriteLine("ERROR: Cannot read Region 0x13000000"); + return 1; +} +var dayGroups = region.SkyInfo?.DayGroups; +if (dayGroups is null) { Console.Error.WriteLine("No DayGroups"); return 1; } + +Console.WriteLine($"Region loaded. {dayGroups.Count} DayGroups."); +Console.WriteLine(); + +var seenGfx = new HashSet(); + +for (int dg = 0; dg < dayGroups.Count; dg++) +{ + var group = dayGroups[dg]; + string name = group.DayName?.Value ?? "(null)"; + Console.WriteLine($"=== DayGroup[{dg}] \"{name}\" Chance={group.ChanceOfOccur:F3} SkyObjects={group.SkyObjects.Count} ==="); + + for (int oi = 0; oi < group.SkyObjects.Count; oi++) + { + var so = group.SkyObjects[oi]; + uint gfx = (uint)so.DefaultGfxObjectId; + uint pes = (uint)so.DefaultPesObjectId; + bool wrapsMidnight = so.BeginTime > so.EndTime; + Console.WriteLine( + $" OI={oi,2} Begin={so.BeginTime:F3} End={so.EndTime:F3} {(wrapsMidnight ? "(wraps midnight — night candidate)" : "")}"); + Console.WriteLine( + $" BeginAng={so.BeginAngle:F1} EndAng={so.EndAngle:F1} TexVel=({so.TexVelocityX:F4},{so.TexVelocityY:F4})"); + Console.WriteLine( + $" Gfx=0x{gfx:X8} Pes=0x{pes:X8} Props=0x{so.Properties:X8} (bin={Convert.ToString(so.Properties, 2).PadLeft(8, '0')})"); + if (gfx != 0) seenGfx.Add(gfx); + } + + // SkyTime replaces (some sky objects swap GfxObj at specific times). + foreach (var st in group.SkyTime) + foreach (var r in st.SkyObjReplace) + { + uint gfx = (uint)r.GfxObjId; + if (gfx != 0 && seenGfx.Add(gfx)) + Console.WriteLine($" REPLACE SkyTime.Begin={st.Begin:F3} OI={r.ObjectIndex} Gfx=0x{gfx:X8}"); + } + + Console.WriteLine(); +} + +Console.WriteLine($"Unique GfxObjIds across all DayGroups: {seenGfx.Count}"); +Console.WriteLine(); +Console.WriteLine("=== Per-GfxObj geometry + UV summary ==="); + +foreach (uint gid in seenGfx.OrderBy(x => x)) + DumpGeoAndUVs(dats, gid); + +return 0; + +static void DumpGeoAndUVs(DatCollection dats, uint gid) +{ + if (gid >= 0x02000000u) + { + if (!dats.TryGet(gid, out var setup) || setup is null) + { Console.WriteLine($"0x{gid:X8} | (Setup not found)"); return; } + Console.WriteLine($"0x{gid:X8} | Setup with {setup.Parts.Count} part(s):"); + foreach (var p in setup.Parts) DumpGfx(dats, (uint)p, indent: " "); + return; + } + DumpGfx(dats, gid, indent: ""); +} + +static void DumpGfx(DatCollection dats, uint gid, string indent) +{ + if (!dats.TryGet(gid, out var go) || go is null) + { Console.WriteLine($"{indent}0x{gid:X8} | (GfxObj not found)"); return; } + var verts = go.VertexArray?.Vertices; + if (verts is null || verts.Count == 0) + { Console.WriteLine($"{indent}0x{gid:X8} | 0 verts"); return; } + + Vector3 mn = new(float.MaxValue), mx = new(float.MinValue); + float uMin = float.MaxValue, uMax = float.MinValue; + float vMin = float.MaxValue, vMax = float.MinValue; + int uvLayerMax = 0; + foreach (var kv in verts) + { + var v = kv.Value; + var p = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); + mn = Vector3.Min(mn, p); mx = Vector3.Max(mx, p); + if (v.UVs is { Count: > 0 } uvs) + { + uvLayerMax = Math.Max(uvLayerMax, uvs.Count); + foreach (var uv in uvs) + { + uMin = Math.Min(uMin, uv.U); uMax = Math.Max(uMax, uv.U); + vMin = Math.Min(vMin, uv.V); vMax = Math.Max(vMax, uv.V); + } + } + } + var size = mx - mn; + int polyCount = go.Polygons?.Count ?? 0; + int surfCount = go.Surfaces?.Count ?? 0; + bool uvOutsideUnit = uvLayerMax > 0 + && (uMin < 0f || uMax > 1f || vMin < 0f || vMax > 1f); + + Console.WriteLine($"{indent}0x{gid:X8} | verts={verts.Count} polys={polyCount} surfaces={surfCount} uvLayers={uvLayerMax}"); + Console.WriteLine($"{indent} bbox min=({mn.X:F2},{mn.Y:F2},{mn.Z:F2}) max=({mx.X:F2},{mx.Y:F2},{mx.Z:F2}) size=({size.X:F2},{size.Y:F2},{size.Z:F2})"); + if (uvLayerMax > 0) + Console.WriteLine($"{indent} UV range U=[{uMin:F3}, {uMax:F3}] V=[{vMin:F3}, {vMax:F3}] {(uvOutsideUnit ? "*** OUTSIDE [0,1] — needs REPEAT wrap ***" : "in [0,1]")}"); + else + Console.WriteLine($"{indent} UV range (no UVs on any vertex)"); + + if (go.Surfaces is { Count: > 0 }) + for (int i = 0; i < go.Surfaces.Count; i++) + Console.WriteLine($"{indent} Surface[{i}]=0x{(uint)go.Surfaces[i]:X8}"); + + // Verbose per-vertex dump (capped at 64 verts to keep output bounded). + int dumpN = Math.Min(verts.Count, 64); + int shown = 0; + foreach (var kv in verts) + { + if (shown++ >= dumpN) { Console.WriteLine($"{indent} ...({verts.Count - dumpN} more verts)"); break; } + var v = kv.Value; + string uvStr = v.UVs is null || v.UVs.Count == 0 ? "(none)" : string.Join(" ", v.UVs.Select(u => $"({u.U:F3},{u.V:F3})")); + Console.WriteLine($"{indent} v[{kv.Key,3}] pos=({v.Origin.X,7:F2},{v.Origin.Y,7:F2},{v.Origin.Z,7:F2}) uv={uvStr}"); + } +} diff --git a/tools/StarsProbe/StarsProbe.csproj b/tools/StarsProbe/StarsProbe.csproj new file mode 100644 index 0000000..a70fd01 --- /dev/null +++ b/tools/StarsProbe/StarsProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + StarsProbe + + + + + + +