diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6ab3a19d..79ece7b3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -177,6 +177,26 @@ 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) @@ -272,111 +292,10 @@ 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 874aa941..0fbe73d5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -373,6 +373,12 @@ 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 @@ -4365,19 +4371,10 @@ public sealed class GameWindow : IDisposable Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds); _weatherAccum += deltaSeconds; - // (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.) + // 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); // Phase E.3: advance live particle emitters AFTER animation tick // so emitters spawned by hooks fired this frame get integrated. @@ -4479,17 +4476,9 @@ 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?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf); } @@ -4525,20 +4514,6 @@ 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) @@ -4755,28 +4730,12 @@ 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; - 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"; - } + _window!.Title = showFps + ? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} visible | " + + $"ent {entityCount} | anim {animatedCount}" + : "acdream"; _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; @@ -5434,11 +5393,9 @@ public sealed class GameWindow : IDisposable } else { - // 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. + // Outdoor: full keyframe sun + ambient; colors are already + // pre-multiplied by DirBright / AmbBright inside + // SkyDescLoader so we feed them straight into the UBO. Lighting.Sun = new AcDream.Core.Lighting.LightSource { Kind = AcDream.Core.Lighting.LightKind.Directional, @@ -5454,6 +5411,114 @@ 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 c7044676..4ddfbded 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -2,18 +2,17 @@ // Sky mesh fragment shader — final composite matching retail's // D3D fixed-function: // -// fragment.rgb = texture.rgb × vTint + lightning_flash -// fragment.a = texture.a × (1 - uTransparency) × uSurfTranslucency -// (uSurfTranslucency is OPACITY directly per retail's -// D3DPolyRender::SetSurface at 0x59c7a6, NOT 1-x) +// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash +// fragment.a = texture.a × (1 - uTransparency) // // 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. 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. +// 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. // // See `docs/research/2026-04-23-sky-material-state.md`. @@ -23,20 +22,8 @@ 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 -// 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; +uniform float uTransparency; // 0 = fully visible, 1 = fully transparent +uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1) // Shared SceneLighting UBO — fog params drive the mix, flash channel // bumps sky brightness during lightning strikes. Matches sky.vert's @@ -58,45 +45,24 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // 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. + // 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. vec3 rgb = sampled.rgb * vTint; - // 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); - } + // 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); // Lightning additive bump — client-driven during storm flashes. // NOTE: the exact retail mechanism for lightning visual is still @@ -113,24 +79,7 @@ void main() { float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); - // 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; + float a = sampled.a * (1.0 - uTransparency); 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 c5939507..48ac9178 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -70,18 +70,8 @@ public sealed unsafe class SkyRenderer : IDisposable } /// - /// 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. - /// + /// Draw the sky for this frame. Called FIRST in the render loop — + /// terrain / meshes / debug lines / overlay land on top. /// /// /// Each submesh renders with retail's per-vertex lighting formula: @@ -101,57 +91,12 @@ public sealed unsafe class SkyRenderer : IDisposable /// field. /// /// - public void RenderSky( + public void Render( 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; @@ -186,14 +131,6 @@ public sealed unsafe class SkyRenderer : IDisposable // Save + override GL state. _gl.DepthMask(false); _gl.Disable(EnableCap.DepthTest); - // Save + disable CullFace for the sky pass; restore at the end. - // Mirrors TextRenderer.cs's save/restore pattern. Without this the - // sky pass left CullFace disabled regardless of its prior state, - // which is benign today (the global convention in this codebase is - // off and subsequent renderers manage their own CullFace) but - // would break the moment any future caller assumes back-face - // culling stays on across the sky pass. - bool wasCullFace = _gl.IsEnabled(EnableCap.CullFace); _gl.Disable(EnableCap.CullFace); _gl.Enable(EnableCap.Blend); // Default blend — overridden per-submesh inside the inner loop. @@ -212,51 +149,20 @@ 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; - // 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; + float luminosity = 1f; 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) 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 (rep.Luminosity > 0f) luminosity = rep.Luminosity; + if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright); } if (gfxObjId == 0) continue; @@ -271,26 +177,6 @@ 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] @@ -300,6 +186,7 @@ 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; @@ -318,85 +205,34 @@ public sealed unsafe class SkyRenderer : IDisposable else _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - // 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. + // 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. // - // 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; + // 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; _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 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 + // 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 ? (int)TextureWrapMode.Repeat : (int)TextureWrapMode.ClampToEdge; _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode); @@ -414,7 +250,6 @@ public sealed unsafe class SkyRenderer : IDisposable _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); _gl.Enable(EnableCap.DepthTest); - if (wasCullFace) _gl.Enable(EnableCap.CullFace); _gl.BindVertexArray(0); } @@ -448,53 +283,14 @@ public sealed unsafe class SkyRenderer : IDisposable } /// - /// 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. - /// + /// 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. /// 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 @@ -535,71 +331,6 @@ 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 @@ -692,8 +423,6 @@ public sealed unsafe class SkyRenderer : IDisposable SurfaceId = sm.SurfaceId, IsAdditive = isAdditive, SurfLuminosity = sm.Luminosity, - NeedsUvRepeat = sm.NeedsUvRepeat, - SurfTranslucency = sm.SurfTranslucency, }; } @@ -733,28 +462,5 @@ 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 and used - /// DIRECTLY as opacity (NOT 1 - x). Retail's - /// D3DPolyRender::SetSurface at 0x59c7a6 - /// (decomp lines 425255-425260) computes - /// curr_alpha = _ftol2(translucency × 255) and writes that - /// as vertex.color.alpha — i.e. translucency is opacity directly. - /// For non-Translucent surfaces the GfxObjMesh.Build() path keeps - /// this at 1.0 so they stay fully opaque. - /// - public float SurfTranslucency; } } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 47f43685..f468dae6 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -200,21 +200,6 @@ 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); @@ -222,33 +207,9 @@ 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(), @@ -256,8 +217,6 @@ 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 31542a60..d6a9cd08 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -39,41 +39,4 @@ 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 07aaa290..9d0ab7b0 100644 --- a/src/AcDream.Core/Meshing/TranslucencyKind.cs +++ b/src/AcDream.Core/Meshing/TranslucencyKind.cs @@ -40,38 +40,17 @@ public enum TranslucencyKind public static class TranslucencyKindExtensions { - // 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 + // 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 // - // 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. + // 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. /// /// Maps a flags value to the correct @@ -79,19 +58,6 @@ 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 a2e843d0..592b8683 100644 --- a/src/AcDream.Core/World/DerethDateTime.cs +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -90,30 +90,21 @@ public static class DerethDateTime GloamingAndHalf, } - /// - /// 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. - /// + /// Derethian months (Snowreap..Frostfell, 12 total). public enum MonthName { - Morningthaw = 0, - Solclaim, - Seedsow, + Snowreap = 0, + ColdMeet, Leafdawning, - Verdantine, + Seedsow, + Rosetide, + Solclaim, Thistledown, Harvestgain, - Leafcull, + Leaftrue, + Reaptide, + Morningthaw, Frostfell, - Snowreap, - Coldeve, - Wintersebb, } /// @@ -136,15 +127,12 @@ public static class DerethDateTime /// for the boot window before the dat parses. /// /// - /// 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.) + /// 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. /// /// public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks; @@ -198,10 +186,7 @@ public static class DerethDateTime /// /// Derethian calendar breakdown: (year, month, day, hour). - /// 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). + /// Year starts at PY 0. Day is 1-based within the month (1..30). /// public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour); @@ -209,19 +194,15 @@ public static class DerethDateTime { if (ticks < 0) ticks = 0; double shifted = ticks + OriginOffsetTicks; - int relativeYear = (int)(shifted / YearTicks); - double tYear = shifted - relativeYear * YearTicks; + int year = (int)(shifted / YearTicks); + double tYear = shifted - year * 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; - // 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)); + return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks)); } /// diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index ada27534..dda09ca0 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -36,45 +36,6 @@ 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: /// @@ -573,23 +534,12 @@ 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, - DirColor: ColorToVec3(s.DirColor), - DirBright: s.DirBright, - AmbColor: ColorToVec3(s.AmbColor), - AmbBright: s.AmbBright, + SunColor: ColorToVec3(s.DirColor) * s.DirBright, + AmbientColor: ColorToVec3(s.AmbColor) * 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 5acf2d39..94e1ab5d 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -34,82 +34,24 @@ public enum FogMode /// /// /// -/// 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. +/// 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. /// /// 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 DirColor, // RGB linear, RAW (NOT × DirBright) - float DirBright, // sun brightness multiplier - Vector3 AmbColor, // RGB linear, RAW (NOT × AmbBright) - float AmbBright, // ambient brightness multiplier + Vector3 SunColor, // RGB linear, post-brightness multiply + Vector3 AmbientColor, // RGB linear, post-brightness multiply 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) -{ - /// - /// 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()); -} + FogMode FogMode = FogMode.Linear); /// /// Sky keyframe interpolator — given a day fraction in [0, 1), returns @@ -169,18 +111,12 @@ 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, - 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, + SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue + AmbientColor: new Vector3(0.05f, 0.05f, 0.12f), FogColor: new Vector3(0.02f, 0.02f, 0.05f), FogDensity: 0.004f, FogStart: 30f, @@ -190,10 +126,8 @@ public sealed class SkyStateProvider Begin: 0.25f, SunHeadingDeg: 90f, // east at dawn SunPitchDeg: 0f, - 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, + SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm + AmbientColor: new Vector3(0.4f, 0.35f, 0.3f), FogColor: new Vector3(0.8f, 0.55f, 0.4f), FogDensity: 0.002f, FogStart: 60f, @@ -203,10 +137,8 @@ public sealed class SkyStateProvider Begin: 0.5f, SunHeadingDeg: 180f, // south at noon SunPitchDeg: 70f, - 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, + SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish + AmbientColor: new Vector3(0.5f, 0.5f, 0.55f), FogColor: new Vector3(0.7f, 0.75f, 0.85f), FogDensity: 0.0008f, FogStart: 120f, @@ -216,10 +148,8 @@ public sealed class SkyStateProvider Begin: 0.75f, SunHeadingDeg: 270f, // west at dusk SunPitchDeg: 0f, - 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, + SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red + AmbientColor: new Vector3(0.35f, 0.25f, 0.25f), FogColor: new Vector3(0.85f, 0.45f, 0.35f), FogDensity: 0.002f, FogStart: 60f, @@ -264,25 +194,17 @@ public sealed class SkyStateProvider // Angular lerp for sun heading: pick shortest arc. float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u); - // 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). + // 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), - 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), + SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u), + AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, 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); } @@ -300,52 +222,22 @@ 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 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. + /// 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. /// public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf) { - var v = RetailSunVector(kf); - float len = v.Length(); - return len > 1e-6f ? v / len : Vector3.UnitZ; + 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)); } } diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs index 86fb5a9f..19d44efe 100644 --- a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs +++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs @@ -75,56 +75,26 @@ public sealed class DerethDateTimeTests } [Fact] - public void ToCalendar_PY10Day1_Morningthaw() + public void ToCalendar_PY0Day1_Snowreap() { - // 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(DerethDateTime.ZeroYear, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); + Assert.Equal(0, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); Assert.Equal(1, cal.Day); } [Fact] public void ToCalendar_AdvancesCorrectly() { - // One year from start → PY (10 + 1) = 11, Morningthaw 1. + // One year from start → PY 1, Snowreap 1. var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks); - Assert.Equal(DerethDateTime.ZeroYear + 1, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); + Assert.Equal(1, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); Assert.Equal(1, cal.Day); - // One month into year 11 → Solclaim (next month after Morningthaw). + // One month into year 1. var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks); - 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); - } + Assert.Equal(1, cal2.Year); + Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month); } } diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index 3331e85a..bbb619d8 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -73,26 +73,15 @@ public sealed class SkyDescLoaderTests } [Fact] - public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude() + public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness() { - // 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; - Assert.InRange(kf.SunColor.X, 1.13f, 1.15f); + // R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176 + Assert.InRange(kf.SunColor.X, 1.17f, 1.19f); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index bd3bc73f..272bdc50 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -25,105 +25,17 @@ public sealed class SkyStateTests } [Fact] - public void Interpolate_BetweenKeyframes_LerpsRawInputs() + public void Interpolate_BetweenKeyframes_LerpsColors() { var sky = SkyStateProvider.Default(); var dawn = sky.Interpolate(0.25f); var noon = sky.Interpolate(0.5f); var midPt = sky.Interpolate(0.375f); - // 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); + // 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); } [Fact] @@ -144,10 +56,8 @@ public sealed class SkyStateTests Begin: 0.5f, SunHeadingDeg: 180f, // south SunPitchDeg: 70f, - DirColor: Vector3.One, - DirBright: 1f, - AmbColor: Vector3.One, - AmbBright: 1f, + SunColor: Vector3.One, + AmbientColor: Vector3.One, 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 7acf0d13..b1d6c24b 100644 --- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs +++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs @@ -58,10 +58,8 @@ public sealed class WorldTimeDebugTests Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 90f, - DirColor: System.Numerics.Vector3.One, - DirBright: 1f, - AmbColor: System.Numerics.Vector3.One, - AmbBright: 1f, + SunColor: System.Numerics.Vector3.One, + AmbientColor: System.Numerics.Vector3.One, FogColor: System.Numerics.Vector3.Zero, FogDensity: 0f), }); diff --git a/tools/RainMeshProbe/Program.cs b/tools/RainMeshProbe/Program.cs deleted file mode 100644 index 0839f3d9..00000000 --- a/tools/RainMeshProbe/Program.cs +++ /dev/null @@ -1,196 +0,0 @@ -// 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 deleted file mode 100644 index 7e499da4..00000000 --- a/tools/RainMeshProbe/RainMeshProbe.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net10.0 - enable - enable - RainMeshProbe - - - - - - - diff --git a/tools/StarsProbe/Program.cs b/tools/StarsProbe/Program.cs deleted file mode 100644 index 2902a0a2..00000000 --- a/tools/StarsProbe/Program.cs +++ /dev/null @@ -1,153 +0,0 @@ -// 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 deleted file mode 100644 index a70fd013..00000000 --- a/tools/StarsProbe/StarsProbe.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net10.0 - enable - enable - StarsProbe - - - - - - -