Compare commits
No commits in common. "e4bc6de7babeaf2bf96e2aff96f28b82be145fb0" and "a2e0bb5e2f1d2ce6b8430714662cc28a53f04428" have entirely different histories.
e4bc6de7ba
...
a2e0bb5e2f
18 changed files with 290 additions and 1452 deletions
121
docs/ISSUES.md
121
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)
|
## #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
|
# 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
|
## #25 — [DONE 2026-04-26] Phase K.3 — Settings panel + click-to-rebind UI
|
||||||
|
|
||||||
**Closed:** 2026-04-26
|
**Closed:** 2026-04-26
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,12 @@ public sealed class GameWindow : IDisposable
|
||||||
private long _loadedSkyDayIndex = long.MinValue;
|
private long _loadedSkyDayIndex = long.MinValue;
|
||||||
private AcDream.Core.World.DayGroupData? _activeDayGroup;
|
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;
|
private double _weatherAccum;
|
||||||
|
|
||||||
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
|
// 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);
|
Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
|
||||||
_weatherAccum += deltaSeconds;
|
_weatherAccum += deltaSeconds;
|
||||||
|
|
||||||
// (Pre-Bug-A code spawned camera-attached rain/snow particle
|
// Update the rain/snow particle emitters when the weather kind
|
||||||
// emitters here as a workaround for missing weather-mesh
|
// changes. Keep the emitters fed by the ParticleSystem tick so
|
||||||
// rendering. Deleted 2026-04-26 once the retail-faithful world-
|
// visuals stay alive frame-over-frame.
|
||||||
// space mesh path landed in SkyRenderer.RenderWeather. Retail
|
UpdateWeatherParticles(atmo);
|
||||||
// rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal
|
|
||||||
// cylinder anchored at player_pos + (0, 0, -120m) per
|
|
||||||
// GameSky::UpdatePosition at 0x00506dd0 — drawn after the
|
|
||||||
// landblock pass per LScape::draw at 0x00506330. There is no
|
|
||||||
// server-driven weather event and no camera-attached emitter
|
|
||||||
// in retail. Snow renders identically when a Snowy DayGroup is
|
|
||||||
// active in some other Region; the partition by Properties&0x04
|
|
||||||
// and the SkyRenderer.RenderWeather pass both pick up snow
|
|
||||||
// weather meshes for free.)
|
|
||||||
|
|
||||||
// Phase E.3: advance live particle emitters AFTER animation tick
|
// Phase E.3: advance live particle emitters AFTER animation tick
|
||||||
// so emitters spawned by hooks fired this frame get integrated.
|
// 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
|
// celestial meshes FIRST so the rest of the scene z-tests
|
||||||
// on top of them (depth mask off, no depth writes). Skipped
|
// on top of them (depth mask off, no depth writes). Skipped
|
||||||
// when indoors; dungeons fully block sky visibility.
|
// 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)
|
if (!cameraInsideCell)
|
||||||
{
|
{
|
||||||
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
|
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
|
||||||
_activeDayGroup, kf);
|
_activeDayGroup, kf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4525,20 +4514,6 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_particleSystem is not null && _particleRenderer is not null)
|
if (_particleSystem is not null && _particleRenderer is not null)
|
||||||
_particleRenderer.Draw(_particleSystem, camera, camPos);
|
_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
|
// Debug: draw collision shapes as wireframe cylinders around the
|
||||||
// player so we can visually verify alignment with scenery meshes.
|
// player so we can visually verify alignment with scenery meshes.
|
||||||
if (_debugCollisionVisible && _debugLines is not null)
|
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);
|
// title bar. Default is true (matches pre-L.0 behaviour);
|
||||||
// unchecking the toggle in Display tab collapses the title
|
// unchecking the toggle in Display tab collapses the title
|
||||||
// to just "acdream" for a cleaner alt-tab experience.
|
// to just "acdream" for a cleaner alt-tab experience.
|
||||||
//
|
|
||||||
// When perf is shown, also include the in-game calendar/time —
|
|
||||||
// matches retail's @timestamp output ("Date: <Month> <Day>,
|
|
||||||
// PY <Year> Time: <HourName>"). Uses NowTicks (server-synced
|
|
||||||
// + wall-clock interpolation) so the user can read the same
|
|
||||||
// fields off both acdream and retail and confirm clock parity
|
|
||||||
// directly. Drift > 1 hour = real bug.
|
|
||||||
bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true;
|
bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true;
|
||||||
if (showFps)
|
_window!.Title = showFps
|
||||||
{
|
? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
|
||||||
double tNow = WorldTime.NowTicks;
|
+ $"lb {visibleLandblocks}/{totalLandblocks} visible | "
|
||||||
var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow);
|
+ $"ent {entityCount} | anim {animatedCount}"
|
||||||
double df = WorldTime.DayFraction;
|
: "acdream";
|
||||||
_window!.Title =
|
|
||||||
$"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
|
|
||||||
+ $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | "
|
|
||||||
+ $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_window!.Title = "acdream";
|
|
||||||
}
|
|
||||||
_lastFps = fps;
|
_lastFps = fps;
|
||||||
_lastFrameMs = avgFrameTime;
|
_lastFrameMs = avgFrameTime;
|
||||||
_perfAccum = 0;
|
_perfAccum = 0;
|
||||||
|
|
@ -5434,11 +5393,9 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Outdoor: full keyframe sun + ambient. The SkyKeyframe stores
|
// Outdoor: full keyframe sun + ambient; colors are already
|
||||||
// raw DirColor + DirBright (and AmbColor + AmbBright) for
|
// pre-multiplied by DirBright / AmbBright inside
|
||||||
// retail-faithful per-channel keyframe interpolation; the
|
// SkyDescLoader so we feed them straight into the UBO.
|
||||||
// computed `kf.SunColor` / `kf.AmbientColor` properties return
|
|
||||||
// the post-multiplied product the shader expects.
|
|
||||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||||
{
|
{
|
||||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||||
|
|
@ -5454,6 +5411,114 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keep the rain/snow camera-anchored emitters aligned with the
|
||||||
|
/// current weather state. Spawns on entry, stops on exit, with no
|
||||||
|
/// per-frame churn while the state is stable. Emitters are camera-
|
||||||
|
/// local (<see cref="AcDream.Core.Vfx.EmitterFlags.AttachLocal"/>)
|
||||||
|
/// so walking never leaves the rain volume (r12 §7).
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo)
|
||||||
|
{
|
||||||
|
if (_particleSystem is null) return;
|
||||||
|
|
||||||
|
if (atmo.Kind == _lastWeatherKind) return; // no change
|
||||||
|
|
||||||
|
// Stop any existing emitters first.
|
||||||
|
if (_rainEmitterHandle != 0)
|
||||||
|
{
|
||||||
|
_particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true);
|
||||||
|
_rainEmitterHandle = 0;
|
||||||
|
}
|
||||||
|
if (_snowEmitterHandle != 0)
|
||||||
|
{
|
||||||
|
_particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true);
|
||||||
|
_snowEmitterHandle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor at camera world position; AttachLocal keeps it moving.
|
||||||
|
var anchor = System.Numerics.Vector3.Zero;
|
||||||
|
if (_cameraController is not null)
|
||||||
|
{
|
||||||
|
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv);
|
||||||
|
anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (atmo.Kind)
|
||||||
|
{
|
||||||
|
case AcDream.Core.World.WeatherKind.Rain:
|
||||||
|
case AcDream.Core.World.WeatherKind.Storm:
|
||||||
|
_rainEmitterHandle = _particleSystem.SpawnEmitter(
|
||||||
|
BuildRainDesc(), anchor);
|
||||||
|
break;
|
||||||
|
case AcDream.Core.World.WeatherKind.Snow:
|
||||||
|
_snowEmitterHandle = _particleSystem.SpawnEmitter(
|
||||||
|
BuildSnowDesc(), anchor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastWeatherKind = atmo.Kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with
|
||||||
|
/// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so
|
||||||
|
/// drops cover the ~60m fall at terminal velocity.
|
||||||
|
/// </summary>
|
||||||
|
private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new()
|
||||||
|
{
|
||||||
|
DatId = 0xFFFF_0001u, // synthetic id
|
||||||
|
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||||
|
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||||
|
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||||
|
EmitRate = 500f,
|
||||||
|
MaxParticles = 2000,
|
||||||
|
LifetimeMin = 1.0f,
|
||||||
|
LifetimeMax = 1.4f,
|
||||||
|
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||||
|
MinOffset = 0f,
|
||||||
|
MaxOffset = 50f,
|
||||||
|
SpawnDiskRadius = 15f,
|
||||||
|
InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f),
|
||||||
|
VelocityJitter = 2f,
|
||||||
|
Gravity = System.Numerics.Vector3.Zero,
|
||||||
|
StartColorArgb = 0x40B0C0E0u,
|
||||||
|
EndColorArgb = 0x20B0C0E0u,
|
||||||
|
StartAlpha = 0.3f,
|
||||||
|
EndAlpha = 0f,
|
||||||
|
StartSize = 0.05f,
|
||||||
|
EndSize = 0.05f,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
|
||||||
|
/// sideways drift, small billboards, 100 flakes/sec, long lifespan.
|
||||||
|
/// </summary>
|
||||||
|
private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new()
|
||||||
|
{
|
||||||
|
DatId = 0xFFFF_0002u,
|
||||||
|
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||||
|
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||||
|
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||||
|
EmitRate = 100f,
|
||||||
|
MaxParticles = 1000,
|
||||||
|
LifetimeMin = 4f,
|
||||||
|
LifetimeMax = 8f,
|
||||||
|
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||||
|
MinOffset = 0f,
|
||||||
|
MaxOffset = 30f,
|
||||||
|
SpawnDiskRadius = 15f,
|
||||||
|
InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f),
|
||||||
|
VelocityJitter = 0.8f,
|
||||||
|
Gravity = System.Numerics.Vector3.Zero,
|
||||||
|
StartColorArgb = 0xE0FFFFFFu,
|
||||||
|
EndColorArgb = 0x80FFFFFFu,
|
||||||
|
StartAlpha = 0.85f,
|
||||||
|
EndAlpha = 0.3f,
|
||||||
|
StartSize = 0.08f,
|
||||||
|
EndSize = 0.06f,
|
||||||
|
};
|
||||||
|
|
||||||
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
||||||
//
|
//
|
||||||
// The ImGui DebugPanel reads through DebugVM closures that ask
|
// The ImGui DebugPanel reads through DebugVM closures that ask
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,17 @@
|
||||||
// Sky mesh fragment shader — final composite matching retail's
|
// Sky mesh fragment shader — final composite matching retail's
|
||||||
// D3D fixed-function:
|
// D3D fixed-function:
|
||||||
//
|
//
|
||||||
// fragment.rgb = texture.rgb × vTint + lightning_flash
|
// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash
|
||||||
// fragment.a = texture.a × (1 - uTransparency) × uSurfTranslucency
|
// fragment.a = texture.a × (1 - uTransparency)
|
||||||
// (uSurfTranslucency is OPACITY directly per retail's
|
|
||||||
// D3DPolyRender::SetSurface at 0x59c7a6, NOT 1-x)
|
|
||||||
//
|
//
|
||||||
// vTint arrives from the vertex shader with retail's per-vertex
|
// vTint arrives from the vertex shader with retail's per-vertex
|
||||||
// lighting formula baked in (Emissive + lightAmbient + lightDiffuse ×
|
// lighting formula baked in (Emissive + lightAmbient + lightDiffuse ×
|
||||||
// max(N·L, 0)) — see sky.vert for the decompile citation. The keyframe
|
// max(N·L, 0)) — see sky.vert for the decompile citation.
|
||||||
// SkyObjectReplace.Luminosity override is folded into uEmissive on the
|
//
|
||||||
// CPU side (SkyRenderer.cs) so vTint already saturates properly for
|
// uLuminosity is the per-keyframe SkyObjectReplace.Luminosity override
|
||||||
// bright keyframes; the previous shader had a redundant uLuminosity
|
// (0..1, /100 in SkyDescLoader). It's a SEPARATE field from the
|
||||||
// multiply that was double-dimming clouds, removed 2026-04-26.
|
// 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`.
|
// 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;
|
out vec4 fragColor;
|
||||||
|
|
||||||
uniform sampler2D uDiffuse;
|
uniform sampler2D uDiffuse;
|
||||||
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
|
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
|
||||||
// 1.0 = apply fog mix to this submesh; 0.0 = skip fog (Additive sky
|
uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1)
|
||||||
// surfaces — sun/moon/stars per retail SetFFFogAlphaDisabled(1) at
|
|
||||||
// D3DPolyRender::SetSurface 0x59c882). Set per-submesh on the CPU side.
|
|
||||||
uniform float uApplyFog;
|
|
||||||
// Surface.Translucency float (0..1) used DIRECTLY as opacity (NOT 1-x).
|
|
||||||
// Distinct from uTransparency (per-keyframe Replace override). Retail
|
|
||||||
// D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) reads
|
|
||||||
// Surface.Translucency when the Translucent (0x10) bit is set and feeds
|
|
||||||
// _ftol2(translucency × 255) directly as vertex alpha. ACViewer
|
|
||||||
// (TextureCache.cs:142) + WorldBuilder (ObjectMeshManager.cs:1115) both
|
|
||||||
// invert it (1-x) and are wrong. For non-Translucent surfaces the CPU
|
|
||||||
// side (GfxObjMesh.Build) sets uSurfTranslucency = 1.0 ⇒ no effect.
|
|
||||||
uniform float uSurfTranslucency;
|
|
||||||
|
|
||||||
// Shared SceneLighting UBO — fog params drive the mix, flash channel
|
// Shared SceneLighting UBO — fog params drive the mix, flash channel
|
||||||
// bumps sky brightness during lightning strikes. Matches sky.vert's
|
// bumps sky brightness during lightning strikes. Matches sky.vert's
|
||||||
|
|
@ -58,45 +45,24 @@ layout(std140, binding = 1) uniform SceneLighting {
|
||||||
void main() {
|
void main() {
|
||||||
vec4 sampled = texture(uDiffuse, vTex);
|
vec4 sampled = texture(uDiffuse, vTex);
|
||||||
|
|
||||||
// Composite: texture × per-vertex lit. Replace.Luminosity (per
|
// Composite: texture × per-vertex lit.
|
||||||
// keyframe) and Surface.Luminosity are both folded into uEmissive
|
// `rep.Luminosity` is now pushed into `uEmissive` on the CPU side
|
||||||
// on the CPU side (SkyRenderer.cs) so vTint already carries the
|
// (SkyRenderer.cs) so `vTint` already saturates properly for bright
|
||||||
// right tint for the time-of-day. Retail's fragment formula
|
// keyframes. Multiplying by uLuminosity again here would dim the
|
||||||
// (FUN_0059da60 non-luminous branch) is texture × litColor ×
|
// result — a BUG that was making clouds render as grey instead of
|
||||||
// vertex.color(=white), so `texture × vTint` is the retail-faithful
|
// white. Retail's fragment formula (FUN_0059da60 non-luminous
|
||||||
// composite.
|
// branch) is texture × litColor × vertex.color(=white), so just
|
||||||
|
// `texture × vTint` is the retail-faithful composite.
|
||||||
vec3 rgb = sampled.rgb * vTint;
|
vec3 rgb = sampled.rgb * vTint;
|
||||||
|
|
||||||
// Retail-faithful sky fog mix with a "fog floor" mitigation:
|
// Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED
|
||||||
//
|
// 2026-04-24 — Dereth sky meshes are authored at radii 1050–1820m
|
||||||
// Dereth sky meshes are authored at radii 1050–1820m. At midnight
|
// while the midnight keyframe's FogEnd is only 400m. Every sky
|
||||||
// (storm keyframes FogEnd ~400m) the raw vFogFactor saturates to 0
|
// pixel was getting swamped to `uFogColor` (dark navy) — which
|
||||||
// for every dome pixel — `mix(fogColor, rgb, 0)` would render the
|
// destroyed stars, moon, and the dome's night texture. Retail's
|
||||||
// entire dome as flat fogColor, destroying stars / moon / texture.
|
// render path must use a different fog range for sky vs terrain;
|
||||||
// That was the reason fog was disabled on sky 2026-04-24 (issue #4).
|
// until that's pinned, skip the fog mix on sky entirely.
|
||||||
//
|
// rgb = mix(uFogColor.rgb, rgb, vFogFactor);
|
||||||
// Retail clearly DOES apply fog to its sky meshes — distant horizon
|
|
||||||
// mountains and the dome itself fade toward the fog color in retail
|
|
||||||
// screenshots. Mechanism unknown (sky-specific FogEnd? elevation-
|
|
||||||
// weighted? different formula?). Until pinned, the workaround is
|
|
||||||
// a clamp on the minimum fog factor so the dome NEVER mixes more
|
|
||||||
// than (1 - SKY_FOG_FLOOR) toward fogColor — preserves stars/moon
|
|
||||||
// while still letting the horizon haze visibly in low-FogEnd
|
|
||||||
// keyframes.
|
|
||||||
//
|
|
||||||
// SKY_FOG_FLOOR=0.2 means dome shows AT LEAST 20% raw texture, AT
|
|
||||||
// MOST 80% fog color even at extreme distances. Tuned via dual-
|
|
||||||
// client visual comparison 2026-04-27 — adjust if night sky goes
|
|
||||||
// back to flat-fog or stays too vivid vs retail.
|
|
||||||
// Skip fog mix entirely on Additive surfaces (sun, moon, stars,
|
|
||||||
// additive cloud sheets) — retail's SetFFFogAlphaDisabled(1) at
|
|
||||||
// D3DPolyRender::SetSurface 0x59c882. Without this gate the sun
|
|
||||||
// dims to fog color at horizon, which doesn't match retail.
|
|
||||||
if (uApplyFog > 0.5) {
|
|
||||||
const float SKY_FOG_FLOOR = 0.2;
|
|
||||||
float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR);
|
|
||||||
rgb = mix(uFogColor.rgb, rgb, skyFogFactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lightning additive bump — client-driven during storm flashes.
|
// Lightning additive bump — client-driven during storm flashes.
|
||||||
// NOTE: the exact retail mechanism for lightning visual is still
|
// 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));
|
float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0));
|
||||||
rgb = min(rgb, vec3(cap));
|
rgb = min(rgb, vec3(cap));
|
||||||
|
|
||||||
// Final fragment alpha:
|
float a = sampled.a * (1.0 - uTransparency);
|
||||||
// uTransparency — keyframe-replace transparency override (0..1).
|
|
||||||
// 0 = fully visible, 1 = fully transparent.
|
|
||||||
// Applied as (1 - x).
|
|
||||||
// uSurfTranslucency — the dat's Surface.Translucency value when the
|
|
||||||
// Translucent flag is set, else 1.0. Despite the
|
|
||||||
// name, retail uses this as OPACITY directly (per
|
|
||||||
// D3DPolyRender::SetSurface at 0x59c7a6 which
|
|
||||||
// writes _ftol2(translucency × 255) into vertex
|
|
||||||
// alpha). Multiply directly — NOT (1 - x).
|
|
||||||
//
|
|
||||||
// For the rain mesh 0x01004C42/4C44 (translucency=0.5): a = 1*1*0.5 = 0.5
|
|
||||||
// matches retail curr_alpha=127, halves the additive streak.
|
|
||||||
// For cloud surface 0x08000023 (translucency=0.25): a = 1*1*0.25 = 0.25
|
|
||||||
// matches retail curr_alpha=63, dim cloud (was 3× too bright with
|
|
||||||
// the previous 1-x formula).
|
|
||||||
// For non-Translucent surfaces uSurfTranslucency = 1.0, no effect.
|
|
||||||
float a = sampled.a * (1.0 - uTransparency) * uSurfTranslucency;
|
|
||||||
if (a < 0.01) discard;
|
if (a < 0.01) discard;
|
||||||
fragColor = vec4(rgb, a);
|
fragColor = vec4(rgb, a);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,18 +70,8 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
|
/// Draw the sky for this frame. Called FIRST in the render loop —
|
||||||
/// every <c>SkyObject</c> with <c>Properties & 0x04 == 0</c>).
|
/// terrain / meshes / debug lines / overlay land on top.
|
||||||
/// Called BEFORE the scene; terrain / meshes / debug lines / overlay
|
|
||||||
/// land on top via depth-test.
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Mirrors the first half of retail's <c>LScape::draw</c> at
|
|
||||||
/// <c>0x00506330</c>: that function calls <c>GameSky::Draw(0)</c>
|
|
||||||
/// (sky pass) before the landblock loop, then <c>GameSky::Draw(1)</c>
|
|
||||||
/// (weather pass) after. acdream splits the same way — see
|
|
||||||
/// <see cref="RenderWeather"/> for the post-scene companion.
|
|
||||||
/// </para>
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Each submesh renders with retail's per-vertex lighting formula:
|
/// Each submesh renders with retail's per-vertex lighting formula:
|
||||||
|
|
@ -101,57 +91,12 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
/// field.
|
/// field.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RenderSky(
|
public void Render(
|
||||||
ICamera camera,
|
ICamera camera,
|
||||||
Vector3 cameraWorldPos,
|
Vector3 cameraWorldPos,
|
||||||
float dayFraction,
|
float dayFraction,
|
||||||
DayGroupData? group,
|
DayGroupData? group,
|
||||||
SkyKeyframe keyframe)
|
SkyKeyframe keyframe)
|
||||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Draw the POST-SCENE sky objects (the foreground rain mesh
|
|
||||||
/// <c>0x01004C44</c> on Rainy DayGroups, plus any other SkyObject with
|
|
||||||
/// <c>Properties & 0x01 != 0</c>). Called AFTER the scene so these
|
|
||||||
/// meshes paint on top of terrain and entities — retail-faithful order
|
|
||||||
/// from <c>LScape::draw</c> at <c>0x00506330</c>, where
|
|
||||||
/// <c>GameSky::Draw(1)</c> fires after the <c>DrawBlock</c> loop and
|
|
||||||
/// renders the <c>after_sky_cell</c> contents. With depth-test
|
|
||||||
/// disabled and additive blend (the rain Surface flag includes
|
|
||||||
/// Additive), the 815m-tall rain cylinder's bright streak texels add
|
|
||||||
/// over the scene — making rain appear in the air between camera and
|
|
||||||
/// character instead of only at the horizon.
|
|
||||||
/// <para>
|
|
||||||
/// Method name kept as <c>RenderWeather</c> for API stability; the
|
|
||||||
/// pass actually partitions on <see cref="SkyObjectData.IsPostScene"/>
|
|
||||||
/// (Properties bit <c>0x01</c>), not <see cref="SkyObjectData.IsWeather"/>
|
|
||||||
/// (bit <c>0x04</c>). The two bits are independent in retail per
|
|
||||||
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public void RenderWeather(
|
|
||||||
ICamera camera,
|
|
||||||
Vector3 cameraWorldPos,
|
|
||||||
float dayFraction,
|
|
||||||
DayGroupData? group,
|
|
||||||
SkyKeyframe keyframe)
|
|
||||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
|
|
||||||
/// Sets up the same GL state for both (depth-test off, additive +
|
|
||||||
/// alpha-blend per submesh, camera-anchored translation) and iterates
|
|
||||||
/// only the SkyObjects matching the requested partition by
|
|
||||||
/// <see cref="SkyObjectData.IsPostScene"/> — bit <c>0x01</c> per the
|
|
||||||
/// retail decomp at <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>).
|
|
||||||
/// </summary>
|
|
||||||
private void RenderPass(
|
|
||||||
ICamera camera,
|
|
||||||
Vector3 cameraWorldPos,
|
|
||||||
float dayFraction,
|
|
||||||
DayGroupData? group,
|
|
||||||
SkyKeyframe keyframe,
|
|
||||||
bool postScenePass)
|
|
||||||
{
|
{
|
||||||
if (group is null || group.SkyObjects.Count == 0) return;
|
if (group is null || group.SkyObjects.Count == 0) return;
|
||||||
|
|
||||||
|
|
@ -186,14 +131,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
// Save + override GL state.
|
// Save + override GL state.
|
||||||
_gl.DepthMask(false);
|
_gl.DepthMask(false);
|
||||||
_gl.Disable(EnableCap.DepthTest);
|
_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.Disable(EnableCap.CullFace);
|
||||||
_gl.Enable(EnableCap.Blend);
|
_gl.Enable(EnableCap.Blend);
|
||||||
// Default blend — overridden per-submesh inside the inner loop.
|
// 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++)
|
for (int i = 0; i < group.SkyObjects.Count; i++)
|
||||||
{
|
{
|
||||||
var obj = group.SkyObjects[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;
|
if (!obj.IsVisible(dayFraction)) continue;
|
||||||
|
|
||||||
// Apply per-keyframe replace overrides.
|
// Apply per-keyframe replace overrides.
|
||||||
uint gfxObjId = obj.GfxObjId;
|
uint gfxObjId = obj.GfxObjId;
|
||||||
float headingDeg = 0f;
|
float headingDeg = 0f;
|
||||||
float transparent = 0f;
|
float transparent = 0f;
|
||||||
// Replace-override luminosity. Stays NaN when there is no
|
float luminosity = 1f;
|
||||||
// replace entry or none of the keyframe's overrides are set,
|
|
||||||
// and that NaN is the signal to fall back to the surface's
|
|
||||||
// authored Luminosity at draw time. This replaces the previous
|
|
||||||
// `luminosity = 1f` default which masked the surface value
|
|
||||||
// because the `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
|
|
||||||
// fallback at the inner loop never fired (1f is always > 0).
|
|
||||||
// RainMeshProbe (committed b8e0857) confirmed empirically that
|
|
||||||
// NO Dereth sky surface carries the SurfaceType.Luminous flag
|
|
||||||
// bit (0x40) — the differentiator is purely the float field.
|
|
||||||
float replaceLuminosity = float.NaN;
|
|
||||||
if (replaces.TryGetValue((uint)i, out var rep))
|
if (replaces.TryGetValue((uint)i, out var rep))
|
||||||
{
|
{
|
||||||
if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId;
|
if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId;
|
||||||
if (rep.Rotate != 0f) headingDeg = rep.Rotate;
|
if (rep.Rotate != 0f) headingDeg = rep.Rotate;
|
||||||
transparent = Math.Clamp(rep.Transparent, 0f, 1f);
|
transparent = Math.Clamp(rep.Transparent, 0f, 1f);
|
||||||
if (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity;
|
if (rep.Luminosity > 0f) luminosity = rep.Luminosity;
|
||||||
// MaxBright is a CAP: even if the surface authored Lum=1.0,
|
if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright);
|
||||||
// a per-keyframe MaxBright trims it. When no explicit
|
|
||||||
// Luminosity replace exists, MaxBright still acts as the
|
|
||||||
// ceiling (applied against sub.SurfLuminosity at draw time).
|
|
||||||
if (rep.MaxBright > 0f)
|
|
||||||
replaceLuminosity = float.IsNaN(replaceLuminosity)
|
|
||||||
? rep.MaxBright
|
|
||||||
: MathF.Min(replaceLuminosity, rep.MaxBright);
|
|
||||||
}
|
}
|
||||||
if (gfxObjId == 0) continue;
|
if (gfxObjId == 0) continue;
|
||||||
|
|
||||||
|
|
@ -271,26 +177,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
* Matrix4x4.CreateRotationZ(-headingRad)
|
* Matrix4x4.CreateRotationZ(-headingRad)
|
||||||
* Matrix4x4.CreateRotationY(-rotationRad);
|
* 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);
|
_shader.SetMatrix4("uModel", model);
|
||||||
|
|
||||||
// UV scroll accumulates real-time × velocity. Wrap to [0, 1]
|
// 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;
|
float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f;
|
||||||
_shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset));
|
_shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset));
|
||||||
_shader.SetFloat("uTransparency", transparent);
|
_shader.SetFloat("uTransparency", transparent);
|
||||||
|
_shader.SetFloat("uLuminosity", luminosity);
|
||||||
|
|
||||||
EnsureMeshUploaded(gfxObjId);
|
EnsureMeshUploaded(gfxObjId);
|
||||||
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
|
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
|
||||||
|
|
@ -318,85 +205,34 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
else
|
else
|
||||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||||
|
|
||||||
// Emissive source picks the surface's authored Luminosity by
|
// Emissive source: retail's FUN_0059da60 for non-luminous
|
||||||
// default; the per-keyframe replace data can OVERRIDE
|
// surfaces writes rep.Luminosity into D3DMATERIAL9.Emissive
|
||||||
// (rep.Luminosity > 0) or CAP (rep.MaxBright). This matches
|
// (via material cache +0x3c). This PROMOTES bright-keyframe
|
||||||
// retail's FUN_0059da60: surface.Luminosity → D3DMATERIAL.Emissive
|
// clouds into the self-lit term so the litColor saturates
|
||||||
// (via material cache +0x3c), with the keyframe replace
|
// and the texture renders at full brightness rather than
|
||||||
// promoting bright-keyframe clouds when the keyframe asks.
|
// being dimmed by a per-fragment multiply.
|
||||||
//
|
//
|
||||||
// Empirical Dereth sky surfaces (RainMeshProbe, b8e0857):
|
// If no rep.Luminosity override: fall back to the Surface's
|
||||||
// dome/sun/moon → Lum=1.0 → vTint saturates → texture
|
// static Luminosity (1.0 for dome/sun/moon → saturates;
|
||||||
// passthrough (correct retail look);
|
// 0.0 for stars → stays ambient-lit, correct retail look).
|
||||||
// stars/clouds → Lum=0.0 → vTint = ambient + diffuse →
|
float effEmissive = (luminosity > 0f) ? luminosity : sub.SurfLuminosity;
|
||||||
// picks up the time-of-day tint;
|
|
||||||
// rain → Lum=0.1484 → faint emissive baseline,
|
|
||||||
// ambient+diffuse adds atmospheric tint.
|
|
||||||
//
|
|
||||||
// Pre-fix: the replace-override variable defaulted to 1f and
|
|
||||||
// the fallback `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
|
|
||||||
// never fired — every sky mesh got effEmissive=1.0,
|
|
||||||
// saturating vTint. That made stars/clouds look full-bright
|
|
||||||
// instead of time-of-day-tinted, and made rain streaks
|
|
||||||
// 6.7× too bright (one of two factors compounding the
|
|
||||||
// foreground-rim visibility bug).
|
|
||||||
float effEmissive = float.IsNaN(replaceLuminosity)
|
|
||||||
? sub.SurfLuminosity
|
|
||||||
: replaceLuminosity;
|
|
||||||
_shader.SetFloat("uEmissive", effEmissive);
|
_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);
|
uint tex = _textures.GetOrUpload(sub.SurfaceId);
|
||||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||||
|
|
||||||
// Sky meshes need per-object wrap mode driven by the
|
// Sky meshes need per-object wrap mode. The dome is 5 flat
|
||||||
// mesh's authored UV range, not by TexVelocity:
|
// walls meeting at edges — under GL_REPEAT any UV drift
|
||||||
// * The outer dome (0x010015EE/F0/F1/F2) authors UVs
|
// past [0,1] wraps to the opposite edge of the texture,
|
||||||
// strictly in [0,1]. Under GL_REPEAT the bilinear
|
// drawing a visible line along each wall seam. Static
|
||||||
// filter at wall-seam edges would average a texel
|
// sky GfxObjs (dome, sun, moon, stars) should use
|
||||||
// near the right edge with one near the left edge of
|
// CLAMP_TO_EDGE to avoid that bleed. Scrolling cloud
|
||||||
// the texture, drawing a visible "bleed line" along
|
// layers (TexVelocity != 0) still need REPEAT so the
|
||||||
// every dome seam. CLAMP_TO_EDGE avoids that.
|
// animated UV offset wraps correctly. Detection heuristic:
|
||||||
// * The inner sky/star layer (0x010015EF) and the
|
// non-zero TexVelocity on either axis ⇒ scrolling layer.
|
||||||
// cloud meshes (0x010015B6, 0x01004C36 etc) author
|
bool scrolling = obj.TexVelocityX != 0f || obj.TexVelocityY != 0f;
|
||||||
// UVs that deliberately exceed [0,1] (~0.4..4.6) so
|
int wrapMode = scrolling
|
||||||
// the texture tiles across the geometry. CLAMP_TO_EDGE
|
|
||||||
// would clamp ~99% of the surface to a single edge
|
|
||||||
// texel, leaving only a small "square" where UVs
|
|
||||||
// happen to fall in [0,1] (Bug B in
|
|
||||||
// docs/research/2026-04-26-sky-investigation-handoff.md).
|
|
||||||
// The mesh builder pre-computes NeedsUvRepeat from the
|
|
||||||
// actual UV range so the right answer is data-driven.
|
|
||||||
// Scrolling clouds are also forced to REPEAT (the running
|
|
||||||
// UV offset can drift outside [0,1] regardless of authored
|
|
||||||
// range, and they'd show their own seam bleed otherwise).
|
|
||||||
bool needsRepeat = sub.NeedsUvRepeat
|
|
||||||
|| obj.TexVelocityX != 0f
|
|
||||||
|| obj.TexVelocityY != 0f;
|
|
||||||
int wrapMode = needsRepeat
|
|
||||||
? (int)TextureWrapMode.Repeat
|
? (int)TextureWrapMode.Repeat
|
||||||
: (int)TextureWrapMode.ClampToEdge;
|
: (int)TextureWrapMode.ClampToEdge;
|
||||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
|
||||||
|
|
@ -414,7 +250,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
_gl.Disable(EnableCap.Blend);
|
_gl.Disable(EnableCap.Blend);
|
||||||
_gl.DepthMask(true);
|
_gl.DepthMask(true);
|
||||||
_gl.Enable(EnableCap.DepthTest);
|
_gl.Enable(EnableCap.DepthTest);
|
||||||
if (wasCullFace) _gl.Enable(EnableCap.CullFace);
|
|
||||||
_gl.BindVertexArray(0);
|
_gl.BindVertexArray(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,53 +283,14 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lazy mesh build for a sky object. Handles two cases:
|
/// Lazy GfxObj build — reuses <see cref="GfxObjMesh"/> so the
|
||||||
/// <list type="bullet">
|
/// pos/neg polygon splitting logic stays consistent with the main
|
||||||
/// <item><description>
|
/// static-mesh pipeline. Most sky meshes are single-surface.
|
||||||
/// <c>0x010xxxxx</c> — direct <see cref="GfxObj"/>. Reuses
|
|
||||||
/// <see cref="GfxObjMesh.Build"/> so the pos/neg polygon
|
|
||||||
/// splitting logic stays consistent with the main static-mesh
|
|
||||||
/// pipeline. Most sky meshes are single-surface.
|
|
||||||
/// </description></item>
|
|
||||||
/// <item><description>
|
|
||||||
/// <c>0x020xxxxx</c> — <see cref="Setup"/>. The agent at
|
|
||||||
/// 2026-04-27 found these Setup-backed sky objects (e.g.
|
|
||||||
/// <c>0x02000588</c>, <c>0x02000589</c>, <c>0x02000714</c>,
|
|
||||||
/// <c>0x02000BA6</c>) were silently dropped: every cache miss
|
|
||||||
/// fell into the GfxObj branch, returned null, and got cached
|
|
||||||
/// as an empty submesh list. Per the named retail decomp
|
|
||||||
/// <c>CPhysicsObj::InitPartArrayObject</c> at <c>0x0050ed40</c>
|
|
||||||
/// dispatches type 7 to <c>CPartArray::CreateSetup</c>
|
|
||||||
/// (decomp 280484) which loads the Setup and walks its parts.
|
|
||||||
/// We mirror that here: <see cref="SetupMesh.Flatten"/> walks
|
|
||||||
/// <c>Setup.Parts</c> at the default placement frame and
|
|
||||||
/// <see cref="GfxObjMesh.Build"/> produces submeshes for each
|
|
||||||
/// part. Per-part transforms are baked into vertex positions
|
|
||||||
/// (sky setups are static — no animation needed for the static
|
|
||||||
/// mesh half of the visual).
|
|
||||||
/// </description></item>
|
|
||||||
/// </list>
|
|
||||||
/// <para>
|
|
||||||
/// Even with this fix the visible aurora-style sheen most retail
|
|
||||||
/// rainy/cloudy setups produce comes from the <c>pes_id</c> field
|
|
||||||
/// on each <see cref="DatReaderWriter.Types.SkyObject"/> (a Particle
|
|
||||||
/// Effect Schedule) — that's a separate Phase-level feature.
|
|
||||||
/// Rendering the Setup's static parts here is the geometry half;
|
|
||||||
/// the dynamic particle half is deferred.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void EnsureMeshUploaded(uint gfxObjId)
|
private void EnsureMeshUploaded(uint gfxObjId)
|
||||||
{
|
{
|
||||||
if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
|
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
|
// DatCollection isn't thread-safe and the streaming loader can be
|
||||||
// actively reading a shared DatBinReader buffer; sky meshes are
|
// actively reading a shared DatBinReader buffer; sky meshes are
|
||||||
// loaded on the render thread but GfxObj.Unpack can race with the
|
// 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;
|
_gpuByGfxObj[gfxObjId] = gpuList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Setup-backed sky object loader. Walks <see cref="Setup.Parts"/> at
|
|
||||||
/// the default placement frame, builds submeshes via
|
|
||||||
/// <see cref="GfxObjMesh.Build"/>, and bakes the per-part transform
|
|
||||||
/// into the vertex positions before upload. Static-pose only — sky
|
|
||||||
/// setups don't animate in any meaningful way for the visual we care
|
|
||||||
/// about (the dynamic look comes from <c>pes_id</c> particles, not
|
|
||||||
/// the underlying mesh).
|
|
||||||
/// <para>
|
|
||||||
/// Mirrors retail's <see cref="CPhysicsObj.InitPartArrayObject"/> at
|
|
||||||
/// decomp <c>280484</c> dispatching type 7 → <c>CPartArray::CreateSetup</c>
|
|
||||||
/// → <c>CSetup::SetSetupID</c>, which loads the setup and instantiates
|
|
||||||
/// each part as a separate <c>CPhysicsObj</c> child. We collapse the
|
|
||||||
/// children into a flat submesh list because the sky pass renders
|
|
||||||
/// without per-part transforms anyway.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private void EnsureSetupUploaded(uint setupId)
|
|
||||||
{
|
|
||||||
Setup? setup = null;
|
|
||||||
try { setup = _dats.Get<Setup>(setupId); }
|
|
||||||
catch { setup = null; }
|
|
||||||
|
|
||||||
if (setup is null)
|
|
||||||
{
|
|
||||||
_gpuByGfxObj[setupId] = new List<SubMeshGpu>();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parts = SetupMesh.Flatten(setup);
|
|
||||||
var allSubs = new List<SubMeshGpu>(parts.Count);
|
|
||||||
foreach (var partRef in parts)
|
|
||||||
{
|
|
||||||
GfxObj? partGfx = null;
|
|
||||||
try { partGfx = _dats.Get<GfxObj>(partRef.GfxObjId); }
|
|
||||||
catch { partGfx = null; }
|
|
||||||
if (partGfx is null) continue;
|
|
||||||
|
|
||||||
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh>? partSubs = null;
|
|
||||||
try { partSubs = GfxObjMesh.Build(partGfx, _dats); }
|
|
||||||
catch { partSubs = null; }
|
|
||||||
if (partSubs is null) continue;
|
|
||||||
|
|
||||||
// Bake the part's local transform into the vertices. For sky
|
|
||||||
// setups we don't expect non-uniform scale, so transforming
|
|
||||||
// normals as directions is fine; if a future sky setup ever
|
|
||||||
// breaks that assumption we'd need an inverse-transpose here.
|
|
||||||
var partTx = partRef.PartTransform;
|
|
||||||
foreach (var sub in partSubs)
|
|
||||||
{
|
|
||||||
var transformed = new Vertex[sub.Vertices.Length];
|
|
||||||
for (int i = 0; i < sub.Vertices.Length; i++)
|
|
||||||
{
|
|
||||||
var v = sub.Vertices[i];
|
|
||||||
var p = Vector3.Transform(v.Position, partTx);
|
|
||||||
var n = Vector3.Normalize(Vector3.TransformNormal(v.Normal, partTx));
|
|
||||||
transformed[i] = v with { Position = p, Normal = n };
|
|
||||||
}
|
|
||||||
var rebuilt = sub with { Vertices = transformed };
|
|
||||||
allSubs.Add(UploadSubMesh(rebuilt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_gpuByGfxObj[setupId] = allSubs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Log each surface's raw flag bits and the derived
|
/// Log each surface's raw flag bits and the derived
|
||||||
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
|
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
|
||||||
|
|
@ -692,8 +423,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
SurfaceId = sm.SurfaceId,
|
SurfaceId = sm.SurfaceId,
|
||||||
IsAdditive = isAdditive,
|
IsAdditive = isAdditive,
|
||||||
SurfLuminosity = sm.Luminosity,
|
SurfLuminosity = sm.Luminosity,
|
||||||
NeedsUvRepeat = sm.NeedsUvRepeat,
|
|
||||||
SurfTranslucency = sm.SurfTranslucency,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -733,28 +462,5 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
|
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float SurfLuminosity;
|
public float SurfLuminosity;
|
||||||
/// <summary>
|
|
||||||
/// True when the source mesh's authored UVs exceed [0,1] (e.g.
|
|
||||||
/// the inner sky/star layer 0x010015EF and the cloud meshes —
|
|
||||||
/// they tile their texture across the geometry). The renderer
|
|
||||||
/// must use <c>GL_REPEAT</c> for these or only the small region
|
|
||||||
/// where UVs fall in [0,1] samples the actual texture; the rest
|
|
||||||
/// clamps to the edge texel ("square in one corner" symptom).
|
|
||||||
/// Computed once at mesh build from the actual UV range.
|
|
||||||
/// </summary>
|
|
||||||
public bool NeedsUvRepeat;
|
|
||||||
/// <summary>
|
|
||||||
/// <c>Surface.Translucency</c> float (0..1) carried through from
|
|
||||||
/// <see cref="GfxObjSubMesh.SurfTranslucency"/>. Passed to the
|
|
||||||
/// sky fragment shader as <c>uSurfTranslucency</c> and used
|
|
||||||
/// DIRECTLY as opacity (NOT <c>1 - x</c>). Retail's
|
|
||||||
/// <c>D3DPolyRender::SetSurface</c> at <c>0x59c7a6</c>
|
|
||||||
/// (decomp lines 425255-425260) computes
|
|
||||||
/// <c>curr_alpha = _ftol2(translucency × 255)</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
public float SurfTranslucency;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,21 +200,6 @@ public static class GfxObjMesh
|
||||||
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
|
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
|
||||||
var translucency = TranslucencyKind.Opaque;
|
var translucency = TranslucencyKind.Opaque;
|
||||||
var luminosity = 0f;
|
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)
|
if (dats is not null)
|
||||||
{
|
{
|
||||||
var surface = dats.Get<Surface>(surfaceId);
|
var surface = dats.Get<Surface>(surfaceId);
|
||||||
|
|
@ -222,33 +207,9 @@ public static class GfxObjMesh
|
||||||
{
|
{
|
||||||
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
|
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
|
||||||
luminosity = surface.Luminosity;
|
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(
|
result.Add(new GfxObjSubMesh(
|
||||||
SurfaceId: surfaceId,
|
SurfaceId: surfaceId,
|
||||||
Vertices: kvp.Value.Vertices.ToArray(),
|
Vertices: kvp.Value.Vertices.ToArray(),
|
||||||
|
|
@ -256,8 +217,6 @@ public static class GfxObjMesh
|
||||||
{
|
{
|
||||||
Translucency = translucency,
|
Translucency = translucency,
|
||||||
Luminosity = luminosity,
|
Luminosity = luminosity,
|
||||||
NeedsUvRepeat = needsUvRepeat,
|
|
||||||
SurfTranslucency = surfTranslucency,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -39,41 +39,4 @@ public sealed record GfxObjSubMesh(
|
||||||
/// normal lighting path without change.
|
/// normal lighting path without change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float Luminosity { get; init; } = 0f;
|
public float Luminosity { get; init; } = 0f;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when at least one vertex's UV component lies outside the
|
|
||||||
/// <c>[0, 1]</c> range, meaning the mesh was authored to have its
|
|
||||||
/// texture tile across the geometry (i.e. it expects
|
|
||||||
/// <c>GL_REPEAT</c>/<c>D3DTADDRESS_WRAP</c>). The sky renderer reads
|
|
||||||
/// this to decide between <c>GL_REPEAT</c> (this flag set, or any
|
|
||||||
/// scrolling layer) and <c>GL_CLAMP_TO_EDGE</c> (all UVs strictly
|
|
||||||
/// in <c>[0,1]</c>), which avoids wall-seam bleed on the dome
|
|
||||||
/// (UVs in <c>[0,1]</c>) while still tiling the inner star/cloud
|
|
||||||
/// layers (UVs in <c>[~0.4, ~4.6]</c>) correctly.
|
|
||||||
/// Defaults to false so non-sky consumers get the previous behavior.
|
|
||||||
/// </summary>
|
|
||||||
public bool NeedsUvRepeat { get; init; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>Surface.Translucency</c> float (0..1) treated as an OPACITY
|
|
||||||
/// multiplier on fragment alpha. 1.0 = fully opaque (default for
|
|
||||||
/// non-Translucent surfaces). Distinct from the
|
|
||||||
/// <see cref="TranslucencyKind"/> classifier above, which buckets the
|
|
||||||
/// flag bits. Retail's <c>D3DPolyRender::SetSurface</c> at
|
|
||||||
/// <c>0x59c7a6</c> (decomp lines 425255-425260) reads
|
|
||||||
/// <c>Surface.Translucency</c> when the <c>Translucent</c> (0x10) bit
|
|
||||||
/// is set, computes <c>curr_alpha = _ftol2(translucency × 255)</c>,
|
|
||||||
/// and writes that as vertex alpha — i.e. the dat's Translucency float
|
|
||||||
/// is used DIRECTLY as opacity, NOT inverted. ACViewer
|
|
||||||
/// (<c>TextureCache.cs:142</c>) and WorldBuilder
|
|
||||||
/// (<c>ObjectMeshManager.cs:1115</c>) both use <c>1 - translucency</c>
|
|
||||||
/// and are wrong by the same misread.
|
|
||||||
/// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5;
|
|
||||||
/// with the <c>(SrcAlpha, One)</c> additive blend the rain streaks
|
|
||||||
/// contribute at half intensity. For cloud surface 0x08000023
|
|
||||||
/// (translucency=0.25): opacity = 0.25 (matches retail's dim clouds).
|
|
||||||
/// Defaults to 1.0 (fully opaque) so non-Translucent surfaces render
|
|
||||||
/// at full opacity without change.
|
|
||||||
/// </summary>
|
|
||||||
public float SurfTranslucency { get; init; } = 1f;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,38 +40,17 @@ public enum TranslucencyKind
|
||||||
|
|
||||||
public static class TranslucencyKindExtensions
|
public static class TranslucencyKindExtensions
|
||||||
{
|
{
|
||||||
// Translucent override comes FIRST, then the existing priority chain:
|
// Priority order (highest wins):
|
||||||
// 1. Translucent override — Translucent (0x10) AND (ClipMap OR opaque-base)
|
// 1. Additive — SurfaceType.Additive (0x10000)
|
||||||
// → AlphaBlend (matches retail's blend forcing).
|
// 2. InvAlpha — SurfaceType.InvAlpha (0x200)
|
||||||
// 2. Additive — SurfaceType.Additive (0x10000)
|
// 3. AlphaBlend — SurfaceType.Alpha (0x100) OR SurfaceType.Translucent (0x10)
|
||||||
// 3. InvAlpha — SurfaceType.InvAlpha (0x200)
|
// 4. ClipMap — SurfaceType.Base1ClipMap (0x04)
|
||||||
// 4. AlphaBlend — SurfaceType.Alpha (0x100) OR SurfaceType.Translucent (0x10)
|
// 5. Opaque — everything else
|
||||||
// 5. ClipMap — SurfaceType.Base1ClipMap (0x04)
|
|
||||||
// 6. Opaque — everything else
|
|
||||||
//
|
//
|
||||||
// The Translucent override matches retail's D3DPolyRender::SetSurface
|
// Note: ACViewer groups Base1ClipMap with the alpha-draw bucket (AlphaSurfaceTypes),
|
||||||
// at 0x0059c4d0 (decomp lines 425083-425260). Verbatim from the
|
// but acdream keeps its existing alpha-discard approach for clip-map surfaces
|
||||||
// Translucent branch at 425246:
|
// (they render opaque with per-fragment discard) and introduces a separate
|
||||||
//
|
// translucent pass only for the genuinely blended surface types.
|
||||||
// if ((curr_surface_type & 0x10) != 0) {
|
|
||||||
// if (skipChk != 0 || ebx == 0 || arg3 == 1) {
|
|
||||||
// edi_2 = BLEND_SRCALPHA; // src
|
|
||||||
// ebp = BLEND_INVSRCALPHA; // dst ← alpha-blend
|
|
||||||
// ebx = 1; arg1 = 1; arg3 = 0;
|
|
||||||
// }
|
|
||||||
// curr_alpha = _ftol2(translucency * 255);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Where `arg3 = 1` after the ClipMap branch and `ebx == 0` happens
|
|
||||||
// in Branch 2 when the surface would otherwise be opaque (no Additive,
|
|
||||||
// Alpha, or InvAlpha bits). So Translucent + ClipMap (e.g. cloud
|
|
||||||
// surface 0x08000023, Type=0x10114) renders ALPHA-BLEND in retail
|
|
||||||
// even though the Additive flag is also set; previously acdream's
|
|
||||||
// priority-Additive-first classification mis-routed it as additive.
|
|
||||||
// Empirically: this is the surface for cloud GfxObj 0x01004C35 in
|
|
||||||
// every Cloudy/Rainy DayGroup. Misclassifying it as additive made
|
|
||||||
// acdream's clouds barely-visible "brightness adders" rather than
|
|
||||||
// the dense alpha-blended sheets retail shows.
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a <see cref="SurfaceType"/> flags value to the correct
|
/// Maps a <see cref="SurfaceType"/> flags value to the correct
|
||||||
|
|
@ -79,19 +58,6 @@ public static class TranslucencyKindExtensions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TranslucencyKind FromSurfaceType(SurfaceType type)
|
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)
|
if ((type & SurfaceType.Additive) != 0)
|
||||||
return TranslucencyKind.Additive;
|
return TranslucencyKind.Additive;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,30 +90,21 @@ public static class DerethDateTime
|
||||||
GloamingAndHalf,
|
GloamingAndHalf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Derethian months (Snowreap..Frostfell, 12 total).</summary>
|
||||||
/// Derethian months in chronological order. Year-0 begins at month 0
|
|
||||||
/// (<see cref="Morningthaw"/>) and progresses through the 12-month
|
|
||||||
/// cycle. Names + order match retail's calendar display
|
|
||||||
/// (<c>GameTime::CalcDayBegin</c> + <c>GetDateTimeString</c> at
|
|
||||||
/// <c>0x005a6530</c>) and ACE's <c>DerethDateTime.cs</c>. Verified
|
|
||||||
/// against retail's <c>@timestamp</c> output in 2026-04-27 dual-
|
|
||||||
/// client comparison: at day-of-year 83, retail shows
|
|
||||||
/// "Seedsow 24" — that fixes month index 2 = Seedsow.
|
|
||||||
/// </summary>
|
|
||||||
public enum MonthName
|
public enum MonthName
|
||||||
{
|
{
|
||||||
Morningthaw = 0,
|
Snowreap = 0,
|
||||||
Solclaim,
|
ColdMeet,
|
||||||
Seedsow,
|
|
||||||
Leafdawning,
|
Leafdawning,
|
||||||
Verdantine,
|
Seedsow,
|
||||||
|
Rosetide,
|
||||||
|
Solclaim,
|
||||||
Thistledown,
|
Thistledown,
|
||||||
Harvestgain,
|
Harvestgain,
|
||||||
Leafcull,
|
Leaftrue,
|
||||||
|
Reaptide,
|
||||||
|
Morningthaw,
|
||||||
Frostfell,
|
Frostfell,
|
||||||
Snowreap,
|
|
||||||
Coldeve,
|
|
||||||
Wintersebb,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -136,15 +127,12 @@ public static class DerethDateTime
|
||||||
/// for the boot window before the dat parses.
|
/// for the boot window before the dat parses.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Live Dereth dat value: <c>3600</c>. Retail's
|
/// Live Dereth dat value: <c>3600</c>. The +7/16 default is wrong
|
||||||
/// <c>GameTime::CalcDayBegin</c> at <c>0x005a6400</c> (decomp line
|
/// by 266.25 ticks (~33 Derethian minutes) and was the source of
|
||||||
/// 434549) computes <c>arg2 + zero_time_of_year</c> as the basis for
|
/// the "acdream time is behind retail" + "wrong DayGroup picked"
|
||||||
/// year/day-of-year extraction, then derives <c>time_of_day_begin</c>
|
/// observations in the 2026-04-23 live verification session — see
|
||||||
/// such that <c>(arg2 - time_of_day_begin) / day_length</c> in
|
/// <c>docs/research/2026-04-23-daygroup-selection.md</c> §4 and
|
||||||
/// <c>CalcTimeOfDay</c> gives <c>(arg2 + zero_time_of_year) mod day_length / day_length</c>.
|
/// the Phase 3f commit.
|
||||||
/// Net: the formula is ADD, not subtract — confirmed via the explicit
|
|
||||||
/// add at line 434549. (A 2026-04-26 attempt to flip the sign over-
|
|
||||||
/// corrected and broke DG selection; reverted in the same commit.)
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
|
public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
|
||||||
|
|
@ -198,10 +186,7 @@ public static class DerethDateTime
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derethian calendar breakdown: (year, month, day, hour).
|
/// Derethian calendar breakdown: (year, month, day, hour).
|
||||||
/// <see cref="Year"/> is the absolute Portal Year (= relative-year +
|
/// Year starts at PY 0. Day is 1-based within the month (1..30).
|
||||||
/// <see cref="ZeroYear"/>) so the value matches retail's
|
|
||||||
/// <c>@timestamp</c> output ("Date: <Month> <Day>,
|
|
||||||
/// <Year> P.Y."). Day is 1-based within the month (1..30).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour);
|
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;
|
if (ticks < 0) ticks = 0;
|
||||||
double shifted = ticks + OriginOffsetTicks;
|
double shifted = ticks + OriginOffsetTicks;
|
||||||
int relativeYear = (int)(shifted / YearTicks);
|
int year = (int)(shifted / YearTicks);
|
||||||
double tYear = shifted - relativeYear * YearTicks;
|
double tYear = shifted - year * YearTicks;
|
||||||
int monthIdx = (int)(tYear / MonthTicks);
|
int monthIdx = (int)(tYear / MonthTicks);
|
||||||
if (monthIdx > 11) monthIdx = 11;
|
if (monthIdx > 11) monthIdx = 11;
|
||||||
double tMonth = tYear - monthIdx * MonthTicks;
|
double tMonth = tYear - monthIdx * MonthTicks;
|
||||||
int day = (int)(tMonth / DayTicks) + 1;
|
int day = (int)(tMonth / DayTicks) + 1;
|
||||||
if (day > DaysInAMonth) day = DaysInAMonth;
|
if (day > DaysInAMonth) day = DaysInAMonth;
|
||||||
|
|
||||||
// Absolute Portal Year for display: retail's @timestamp shows
|
return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks));
|
||||||
// PY-with-base (10 P.Y. == year 0 of the calendar epoch), so add
|
|
||||||
// ZeroYear here. Matches AbsoluteYear() and the retail decomp at
|
|
||||||
// FUN_005a7510:5300.
|
|
||||||
return new Calendar(relativeYear + ZeroYear, (MonthName)monthIdx, day, CurrentHour(ticks));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -36,45 +36,6 @@ public sealed class SkyObjectData
|
||||||
public uint GfxObjId;
|
public uint GfxObjId;
|
||||||
public uint Properties;
|
public uint Properties;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when this SkyObject is gated on the weather system (Properties
|
|
||||||
/// bit <c>0x04</c>). Per the named retail decomp,
|
|
||||||
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>
|
|
||||||
/// passes <c>Properties & 4</c> as <c>arg5</c> of
|
|
||||||
/// <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>); the inner
|
|
||||||
/// <c>(arg5 == 0 || LScape::weather_enabled != 0)</c> guard at decomp
|
|
||||||
/// line 268630 means weather-flagged objects only get instantiated when
|
|
||||||
/// the global weather flag is on. This bit does <b>not</b> control
|
|
||||||
/// pre/post-scene placement — that's <see cref="IsPostScene"/>.
|
|
||||||
/// acdream currently always renders weather-flagged objects (we don't
|
|
||||||
/// honor a weather_enabled toggle yet); when we add one, this flag is
|
|
||||||
/// the gate.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsWeather => (Properties & 0x04u) != 0u;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when this SkyObject renders <i>after</i> the world scene
|
|
||||||
/// (Properties bit <c>0x01</c>) — i.e. as foreground over terrain and
|
|
||||||
/// entities. Per the named retail decomp,
|
|
||||||
/// <c>GameSky::CreateDeletePhysicsObjects</c> passes
|
|
||||||
/// <c>Properties & 1</c> as <c>arg4</c> of
|
|
||||||
/// <c>GameSky::MakeObject</c> (decomp line 269036); MakeObject at
|
|
||||||
/// decomp 268656 routes <c>arg4 != 0</c> objects into
|
|
||||||
/// <c>after_sky_cell</c> instead of <c>before_sky_cell</c>, and
|
|
||||||
/// <c>GameSky::Draw(arg2=1)</c> at <c>0x00506ff0</c> draws
|
|
||||||
/// <c>after_sky_cell</c> as a separate post-scene pass.
|
|
||||||
/// <para>
|
|
||||||
/// In Dereth's Rainy DayGroup this distinguishes the two rain
|
|
||||||
/// cylinders: <c>0x01004C44</c> (Props=0x05) is foreground rain
|
|
||||||
/// rendered after terrain; <c>0x01004C42</c> (Props=0x04 alone) is
|
|
||||||
/// background rain rendered <i>with</i> the sky dome. Earlier
|
|
||||||
/// versions of acdream incorrectly split on <see cref="IsWeather"/>
|
|
||||||
/// (bit 0x04) so both rain meshes ended up in the post-scene pass,
|
|
||||||
/// double-rendering rain in the foreground.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPostScene => (Properties & 0x01u) != 0u;
|
|
||||||
|
|
||||||
/// <summary>Object is visible at day-fraction <paramref name="t"/>
|
/// <summary>Object is visible at day-fraction <paramref name="t"/>
|
||||||
/// by retail's begin/end semantics (r12 §2). Three cases:
|
/// by retail's begin/end semantics (r12 §2). Three cases:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
|
|
@ -573,23 +534,12 @@ public static class SkyDescLoader
|
||||||
_ => FogMode.Off,
|
_ => 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(
|
var kf = new SkyKeyframe(
|
||||||
Begin: s.Begin,
|
Begin: s.Begin,
|
||||||
SunHeadingDeg: s.DirHeading,
|
SunHeadingDeg: s.DirHeading,
|
||||||
SunPitchDeg: s.DirPitch,
|
SunPitchDeg: s.DirPitch,
|
||||||
DirColor: ColorToVec3(s.DirColor),
|
SunColor: ColorToVec3(s.DirColor) * s.DirBright,
|
||||||
DirBright: s.DirBright,
|
AmbientColor: ColorToVec3(s.AmbColor) * s.AmbBright,
|
||||||
AmbColor: ColorToVec3(s.AmbColor),
|
|
||||||
AmbBright: s.AmbBright,
|
|
||||||
FogColor: ColorToVec3(s.WorldFogColor),
|
FogColor: ColorToVec3(s.WorldFogColor),
|
||||||
FogDensity: 0f,
|
FogDensity: 0f,
|
||||||
FogStart: s.MinWorldFog,
|
FogStart: s.MinWorldFog,
|
||||||
|
|
|
||||||
|
|
@ -34,82 +34,24 @@ public enum FogMode
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Colors are stored RAW (NOT pre-multiplied by brightness) in
|
/// Colors are in LINEAR RGB, already pre-multiplied by their brightness
|
||||||
/// <see cref="DirColor"/> / <see cref="AmbColor"/> with the brightness
|
/// scalar so the shader can plug them straight into the UBO without
|
||||||
/// scalars in <see cref="DirBright"/> / <see cref="AmbBright"/>. Retail's
|
/// knowing about <c>DirBright</c> / <c>AmbBright</c>. Range is loosely
|
||||||
/// <c>SkyDesc::GetLighting</c> at <c>0x00500ac9</c> (decomp lines
|
/// [0, N] — retail dusk tints have channels above 1.0 and the frag
|
||||||
/// 261317-261331) lerps each channel separately and lerps brightness
|
/// shader clamps after lighting math.
|
||||||
/// separately, then multiplies post-lerp. Lerping the pre-multiplied
|
|
||||||
/// product gives mathematically different results when both color and
|
|
||||||
/// brightness change between adjacent keyframes — the cause of subtle
|
|
||||||
/// brightness discrepancies vs retail observed in dual-client
|
|
||||||
/// comparisons (Issue #3 visual sub-bug, 2026-04-27).
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The computed properties <see cref="SunColor"/> and
|
|
||||||
/// <see cref="AmbientColor"/> return the post-multiplied product, so
|
|
||||||
/// downstream shader uniform plumbing (sky.vert / mesh.vert /
|
|
||||||
/// SceneLightingUbo) is unchanged.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct SkyKeyframe(
|
public readonly record struct SkyKeyframe(
|
||||||
float Begin, // [0, 1] day-fraction this keyframe kicks in
|
float Begin, // [0, 1] day-fraction this keyframe kicks in
|
||||||
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
|
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
|
||||||
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
|
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
|
||||||
Vector3 DirColor, // RGB linear, RAW (NOT × DirBright)
|
Vector3 SunColor, // RGB linear, post-brightness multiply
|
||||||
float DirBright, // sun brightness multiplier
|
Vector3 AmbientColor, // RGB linear, post-brightness multiply
|
||||||
Vector3 AmbColor, // RGB linear, RAW (NOT × AmbBright)
|
|
||||||
float AmbBright, // ambient brightness multiplier
|
|
||||||
Vector3 FogColor,
|
Vector3 FogColor,
|
||||||
float FogDensity, // retained for tests; derive from FogStart/End
|
float FogDensity, // retained for tests; derive from FogStart/End
|
||||||
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
|
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
|
||||||
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
|
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
|
||||||
FogMode FogMode = FogMode.Linear)
|
FogMode FogMode = FogMode.Linear);
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Final directional sun color the shader feeds into N·L lighting.
|
|
||||||
/// Retail-faithful magnitude formula:
|
|
||||||
/// <code>SunColor = DirColor × |sunVec|</code>
|
|
||||||
/// where <c>sunVec</c> is retail's heading+pitch+brightness vector
|
|
||||||
/// (see <see cref="SkyStateProvider.RetailSunVector"/>).
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Why <c>|sunVec|</c> instead of <c>DirBright</c> directly: retail's
|
|
||||||
/// <c>PrimD3DRender::UpdateLightsInternal</c> at <c>0x0059b57c</c>
|
|
||||||
/// (decomp line 424118-424119) computes
|
|
||||||
/// <code>D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)</code>
|
|
||||||
/// from the sun vector <c>SkyDesc::GetLighting</c> built at
|
|
||||||
/// <c>0x00500ac9</c> (decomp lines 261343-261353):
|
|
||||||
/// <code>
|
|
||||||
/// sunVec.x = sin(H) × DirBright × cos(P)
|
|
||||||
/// sunVec.y = cos(P) // NOT scaled by DirBright
|
|
||||||
/// sunVec.z = DirBright × sin(P)
|
|
||||||
/// </code>
|
|
||||||
/// Because Y is unscaled by <c>DirBright</c>, <c>|sunVec|</c> ≠
|
|
||||||
/// <c>DirBright</c> in general — it varies with sun pitch and heading.
|
|
||||||
/// Using <c>DirBright</c> alone underweighted the warm directional
|
|
||||||
/// term, letting the cool ambient/fog dominate ⇒ acdream rendered
|
|
||||||
/// blue-white at keyframes where retail looked warm-gray.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Final ambient color the shader feeds into the per-vertex tint.
|
|
||||||
/// Retail-faithful magnitude formula:
|
|
||||||
/// <code>AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)</code>
|
|
||||||
/// matching <c>SmartBox::SetWorldAmbientLight</c> as called at
|
|
||||||
/// <c>0x0050560b</c> (decomp line 267117):
|
|
||||||
/// <code>SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color)</code>
|
|
||||||
/// Retail boosts the ambient brightness by 20% of the sun-vector
|
|
||||||
/// magnitude — i.e. ambient feels warmer when the sun is up, cooler
|
|
||||||
/// at night. acdream previously used <c>AmbBright</c> alone, which
|
|
||||||
/// is roughly 44% too dim mid-day ⇒ contributed to the blue-white
|
|
||||||
/// bias because the warm fill was missing.
|
|
||||||
/// </summary>
|
|
||||||
public Vector3 AmbientColor =>
|
|
||||||
AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
|
/// 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.
|
// Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk.
|
||||||
return new SkyStateProvider(new[]
|
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(
|
new SkyKeyframe(
|
||||||
Begin: 0.0f,
|
Begin: 0.0f,
|
||||||
SunHeadingDeg: 0f, // below horizon (north)
|
SunHeadingDeg: 0f, // below horizon (north)
|
||||||
SunPitchDeg: -30f,
|
SunPitchDeg: -30f,
|
||||||
DirColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
|
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
|
||||||
DirBright: 1.0f,
|
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
|
||||||
AmbColor: new Vector3(0.05f, 0.05f, 0.12f),
|
|
||||||
AmbBright: 1.0f,
|
|
||||||
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
|
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
|
||||||
FogDensity: 0.004f,
|
FogDensity: 0.004f,
|
||||||
FogStart: 30f,
|
FogStart: 30f,
|
||||||
|
|
@ -190,10 +126,8 @@ public sealed class SkyStateProvider
|
||||||
Begin: 0.25f,
|
Begin: 0.25f,
|
||||||
SunHeadingDeg: 90f, // east at dawn
|
SunHeadingDeg: 90f, // east at dawn
|
||||||
SunPitchDeg: 0f,
|
SunPitchDeg: 0f,
|
||||||
DirColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
|
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
|
||||||
DirBright: 1.0f,
|
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
|
||||||
AmbColor: new Vector3(0.4f, 0.35f, 0.3f),
|
|
||||||
AmbBright: 1.0f,
|
|
||||||
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
|
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
|
||||||
FogDensity: 0.002f,
|
FogDensity: 0.002f,
|
||||||
FogStart: 60f,
|
FogStart: 60f,
|
||||||
|
|
@ -203,10 +137,8 @@ public sealed class SkyStateProvider
|
||||||
Begin: 0.5f,
|
Begin: 0.5f,
|
||||||
SunHeadingDeg: 180f, // south at noon
|
SunHeadingDeg: 180f, // south at noon
|
||||||
SunPitchDeg: 70f,
|
SunPitchDeg: 70f,
|
||||||
DirColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
|
SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
|
||||||
DirBright: 1.0f,
|
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
|
||||||
AmbColor: new Vector3(0.5f, 0.5f, 0.55f),
|
|
||||||
AmbBright: 1.0f,
|
|
||||||
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
|
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
|
||||||
FogDensity: 0.0008f,
|
FogDensity: 0.0008f,
|
||||||
FogStart: 120f,
|
FogStart: 120f,
|
||||||
|
|
@ -216,10 +148,8 @@ public sealed class SkyStateProvider
|
||||||
Begin: 0.75f,
|
Begin: 0.75f,
|
||||||
SunHeadingDeg: 270f, // west at dusk
|
SunHeadingDeg: 270f, // west at dusk
|
||||||
SunPitchDeg: 0f,
|
SunPitchDeg: 0f,
|
||||||
DirColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
|
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
|
||||||
DirBright: 1.0f,
|
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
|
||||||
AmbColor: new Vector3(0.35f, 0.25f, 0.25f),
|
|
||||||
AmbBright: 1.0f,
|
|
||||||
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
|
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
|
||||||
FogDensity: 0.002f,
|
FogDensity: 0.002f,
|
||||||
FogStart: 60f,
|
FogStart: 60f,
|
||||||
|
|
@ -264,25 +194,17 @@ public sealed class SkyStateProvider
|
||||||
// Angular lerp for sun heading: pick shortest arc.
|
// Angular lerp for sun heading: pick shortest arc.
|
||||||
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
|
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
|
||||||
|
|
||||||
// Retail-faithful interpolation: lerp DirColor / DirBright /
|
// Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
|
||||||
// AmbColor / AmbBright as SEPARATE CHANNELS, not as the
|
|
||||||
// pre-multiplied product. Mirrors SkyDesc::GetLighting at
|
|
||||||
// 0x00500ac9 (decomp lines 261317-261331). The post-multiplied
|
|
||||||
// SunColor / AmbientColor are computed properties on the result.
|
|
||||||
// Fog mode doesn't interpolate — pick k1's mode (retail uses
|
|
||||||
// Linear everywhere).
|
|
||||||
return new SkyKeyframe(
|
return new SkyKeyframe(
|
||||||
Begin: t,
|
Begin: t,
|
||||||
SunHeadingDeg: heading,
|
SunHeadingDeg: heading,
|
||||||
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
|
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
|
||||||
DirColor: Vector3.Lerp(k1.DirColor, k2.DirColor, u),
|
SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
|
||||||
DirBright: Lerp(k1.DirBright, k2.DirBright, u),
|
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
|
||||||
AmbColor: Vector3.Lerp(k1.AmbColor, k2.AmbColor, u),
|
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
|
||||||
AmbBright: Lerp(k1.AmbBright, k2.AmbBright, u),
|
|
||||||
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
|
|
||||||
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
|
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
|
||||||
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
|
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
|
||||||
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
|
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
|
||||||
FogMode: k1.FogMode);
|
FogMode: k1.FogMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,52 +222,22 @@ public sealed class SkyStateProvider
|
||||||
return aDeg + delta * u;
|
return aDeg + delta * u;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retail's raw sun vector (NOT normalized) — the same vector
|
|
||||||
/// <c>SkyDesc::GetLighting</c> writes at <c>0x00500ac9</c>
|
|
||||||
/// (decomp lines 261343, 261352, 261353):
|
|
||||||
/// <code>
|
|
||||||
/// sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
|
|
||||||
/// sunVec.y = cos(P_rad) // NOT scaled by DirBright
|
|
||||||
/// sunVec.z = DirBright × sin(P_rad)
|
|
||||||
/// </code>
|
|
||||||
/// Y is unscaled by brightness on purpose — that's what makes
|
|
||||||
/// <c>|sunVec|</c> ≠ <c>DirBright</c> in general (the magnitude varies
|
|
||||||
/// with pitch/heading, which is the basis for retail's "sun is brighter
|
|
||||||
/// in some configurations than others" lighting behavior). The shader's
|
|
||||||
/// <c>uSunDir</c> uniform uses the NORMALIZED vector for N·L; the
|
|
||||||
/// magnitude feeds <see cref="SkyKeyframe.SunColor"/> intensity and
|
|
||||||
/// the ambient brightness boost in <see cref="SkyKeyframe.AmbientColor"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static Vector3 RetailSunVector(SkyKeyframe kf)
|
|
||||||
{
|
|
||||||
float h = kf.SunHeadingDeg * (MathF.PI / 180f);
|
|
||||||
float p = kf.SunPitchDeg * (MathF.PI / 180f);
|
|
||||||
float cosP = MathF.Cos(p);
|
|
||||||
float sinP = MathF.Sin(p);
|
|
||||||
float B = kf.DirBright;
|
|
||||||
return new Vector3(
|
|
||||||
MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P)
|
|
||||||
cosP, // y = cos(P) ← unscaled by B
|
|
||||||
B * sinP); // z = B × sin(P)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// World-space sun direction unit vector pointing FROM the surface
|
/// World-space sun direction unit vector pointing FROM the surface
|
||||||
/// TOWARDS the sun, derived from <see cref="RetailSunVector"/> and
|
/// TOWARDS the sun. Derived from heading + pitch in the returned
|
||||||
/// normalized. The shader sunDir uniform should use this directly
|
/// keyframe — shader sunDir uniform should use -this so lighting
|
||||||
/// (or -this if the lighting math wants the L-vector pointing AT the
|
/// math (N·L) works correctly for the side facing the sun.
|
||||||
/// surface). The previous implementation used standard spherical
|
|
||||||
/// coordinates (sin(H)cos(P), cos(H)cos(P), sin(P)) which didn't match
|
|
||||||
/// retail's deliberate Y-decoupled-from-heading convention. Switching
|
|
||||||
/// to the retail vector subtly tilts the lighting on objects but
|
|
||||||
/// matches retail's screenshots when both clients view the same scene.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf)
|
public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf)
|
||||||
{
|
{
|
||||||
var v = RetailSunVector(kf);
|
float yaw = kf.SunHeadingDeg * (MathF.PI / 180f);
|
||||||
float len = v.Length();
|
float pit = kf.SunPitchDeg * (MathF.PI / 180f);
|
||||||
return len > 1e-6f ? v / len : Vector3.UnitZ;
|
// 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,56 +75,26 @@ public sealed class DerethDateTimeTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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);
|
var cal = DerethDateTime.ToCalendar(0);
|
||||||
Assert.Equal(DerethDateTime.ZeroYear, cal.Year);
|
Assert.Equal(0, cal.Year);
|
||||||
Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month);
|
Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month);
|
||||||
Assert.Equal(1, cal.Day);
|
Assert.Equal(1, cal.Day);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToCalendar_AdvancesCorrectly()
|
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);
|
var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks);
|
||||||
Assert.Equal(DerethDateTime.ZeroYear + 1, cal.Year);
|
Assert.Equal(1, cal.Year);
|
||||||
Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month);
|
Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month);
|
||||||
Assert.Equal(1, cal.Day);
|
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);
|
var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks);
|
||||||
Assert.Equal(DerethDateTime.ZeroYear + 1, cal2.Year);
|
Assert.Equal(1, cal2.Year);
|
||||||
Assert.Equal(DerethDateTime.MonthName.Solclaim, cal2.Month);
|
Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat()
|
|
||||||
{
|
|
||||||
// Regression guard for the 2026-04-27 dual-client comparison.
|
|
||||||
// Retail @timestamp output format is
|
|
||||||
// "Date: <Month> <Day>, <Year> 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,26 +73,15 @@ public sealed class SkyDescLoaderTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
|
||||||
var loaded = SkyDescLoader.LoadFromRegion(region);
|
var loaded = SkyDescLoader.LoadFromRegion(region);
|
||||||
Assert.NotNull(loaded);
|
Assert.NotNull(loaded);
|
||||||
|
|
||||||
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -25,105 +25,17 @@ public sealed class SkyStateTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Interpolate_BetweenKeyframes_LerpsRawInputs()
|
public void Interpolate_BetweenKeyframes_LerpsColors()
|
||||||
{
|
{
|
||||||
var sky = SkyStateProvider.Default();
|
var sky = SkyStateProvider.Default();
|
||||||
var dawn = sky.Interpolate(0.25f);
|
var dawn = sky.Interpolate(0.25f);
|
||||||
var noon = sky.Interpolate(0.5f);
|
var noon = sky.Interpolate(0.5f);
|
||||||
var midPt = sky.Interpolate(0.375f);
|
var midPt = sky.Interpolate(0.375f);
|
||||||
|
|
||||||
// The RAW per-channel inputs (DirColor, AmbColor, brightness scalars)
|
// Midpoint should fall between dawn & noon for sun color Y (green channel).
|
||||||
// lerp linearly between adjacent keyframes — that's the retail-faithful
|
float low = System.Math.Min(dawn.SunColor.Y, noon.SunColor.Y);
|
||||||
// separate-channel interpolation. The composite SunColor / AmbientColor
|
float high = System.Math.Max(dawn.SunColor.Y, noon.SunColor.Y);
|
||||||
// properties intentionally do NOT lerp linearly (their magnitude
|
Assert.InRange(midPt.SunColor.Y, low, high);
|
||||||
// depends nonlinearly on heading/pitch/brightness via the retail
|
|
||||||
// sun-vector formula), so we assert on the raw inputs here.
|
|
||||||
float low = System.Math.Min(dawn.DirColor.Y, noon.DirColor.Y);
|
|
||||||
float high = System.Math.Max(dawn.DirColor.Y, noon.DirColor.Y);
|
|
||||||
Assert.InRange(midPt.DirColor.Y, low, high);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RetailSunVector_AtZenith_HasMagnitudeEqualToBrightness()
|
|
||||||
{
|
|
||||||
// Sun straight up (P=90°): cos(P)=0, sin(P)=1.
|
|
||||||
// sunVec = (sin(H)×B×0, 0, B×1) = (0, 0, B)
|
|
||||||
// |sunVec| = B
|
|
||||||
var kf = new SkyKeyframe(
|
|
||||||
Begin: 0.5f,
|
|
||||||
SunHeadingDeg: 0f,
|
|
||||||
SunPitchDeg: 90f,
|
|
||||||
DirColor: Vector3.One,
|
|
||||||
DirBright: 1.5f,
|
|
||||||
AmbColor: Vector3.One,
|
|
||||||
AmbBright: 0.3f,
|
|
||||||
FogColor: Vector3.One,
|
|
||||||
FogDensity: 0f);
|
|
||||||
|
|
||||||
var v = SkyStateProvider.RetailSunVector(kf);
|
|
||||||
Assert.InRange(v.Length(), 1.49f, 1.51f);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne()
|
|
||||||
{
|
|
||||||
// Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0.
|
|
||||||
// sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0)
|
|
||||||
// |sunVec| = 1 regardless of B (because Y is unscaled by B)
|
|
||||||
var kf = new SkyKeyframe(
|
|
||||||
Begin: 0f,
|
|
||||||
SunHeadingDeg: 0f,
|
|
||||||
SunPitchDeg: 0f,
|
|
||||||
DirColor: Vector3.One,
|
|
||||||
DirBright: 2.0f, // anything
|
|
||||||
AmbColor: Vector3.One,
|
|
||||||
AmbBright: 1f,
|
|
||||||
FogColor: Vector3.One,
|
|
||||||
FogDensity: 0f);
|
|
||||||
|
|
||||||
var v = SkyStateProvider.RetailSunVector(kf);
|
|
||||||
Assert.InRange(v.Length(), 0.99f, 1.01f);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SunColor_UsesRetailMagnitudeNotDirBrightDirectly()
|
|
||||||
{
|
|
||||||
// At sun pitch 90° (zenith) with H=0, B=2: |sunVec| = 2.
|
|
||||||
// SunColor = DirColor × |sunVec| = (0.5, 0.5, 0.5) × 2 = (1, 1, 1).
|
|
||||||
var kf = new SkyKeyframe(
|
|
||||||
Begin: 0.5f,
|
|
||||||
SunHeadingDeg: 0f,
|
|
||||||
SunPitchDeg: 90f,
|
|
||||||
DirColor: new Vector3(0.5f, 0.5f, 0.5f),
|
|
||||||
DirBright: 2.0f,
|
|
||||||
AmbColor: Vector3.One,
|
|
||||||
AmbBright: 0.3f,
|
|
||||||
FogColor: Vector3.One,
|
|
||||||
FogDensity: 0f);
|
|
||||||
|
|
||||||
Assert.InRange(kf.SunColor.X, 0.99f, 1.01f);
|
|
||||||
Assert.InRange(kf.SunColor.Y, 0.99f, 1.01f);
|
|
||||||
Assert.InRange(kf.SunColor.Z, 0.99f, 1.01f);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AmbientColor_BoostsByTwentyPercentOfSunVectorLength()
|
|
||||||
{
|
|
||||||
// |sunVec| = 1 (horizon north), AmbBright = 0.4, AmbColor = (1,1,1).
|
|
||||||
// AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)
|
|
||||||
// = (1,1,1) × (0.4 + 0.2) = (0.6, 0.6, 0.6).
|
|
||||||
var kf = new SkyKeyframe(
|
|
||||||
Begin: 0f,
|
|
||||||
SunHeadingDeg: 0f,
|
|
||||||
SunPitchDeg: 0f,
|
|
||||||
DirColor: Vector3.One,
|
|
||||||
DirBright: 1f,
|
|
||||||
AmbColor: Vector3.One,
|
|
||||||
AmbBright: 0.4f,
|
|
||||||
FogColor: Vector3.One,
|
|
||||||
FogDensity: 0f);
|
|
||||||
|
|
||||||
Assert.InRange(kf.AmbientColor.X, 0.59f, 0.61f);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -144,10 +56,8 @@ public sealed class SkyStateTests
|
||||||
Begin: 0.5f,
|
Begin: 0.5f,
|
||||||
SunHeadingDeg: 180f, // south
|
SunHeadingDeg: 180f, // south
|
||||||
SunPitchDeg: 70f,
|
SunPitchDeg: 70f,
|
||||||
DirColor: Vector3.One,
|
SunColor: Vector3.One,
|
||||||
DirBright: 1f,
|
AmbientColor: Vector3.One,
|
||||||
AmbColor: Vector3.One,
|
|
||||||
AmbBright: 1f,
|
|
||||||
FogColor: Vector3.One,
|
FogColor: Vector3.One,
|
||||||
FogDensity: 0.001f);
|
FogDensity: 0.001f);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,8 @@ public sealed class WorldTimeDebugTests
|
||||||
Begin: 0f,
|
Begin: 0f,
|
||||||
SunHeadingDeg: 0f,
|
SunHeadingDeg: 0f,
|
||||||
SunPitchDeg: 90f,
|
SunPitchDeg: 90f,
|
||||||
DirColor: System.Numerics.Vector3.One,
|
SunColor: System.Numerics.Vector3.One,
|
||||||
DirBright: 1f,
|
AmbientColor: System.Numerics.Vector3.One,
|
||||||
AmbColor: System.Numerics.Vector3.One,
|
|
||||||
AmbBright: 1f,
|
|
||||||
FogColor: System.Numerics.Vector3.Zero,
|
FogColor: System.Numerics.Vector3.Zero,
|
||||||
FogDensity: 0f),
|
FogDensity: 0f),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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<Surface>(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<GfxObj>(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<Surface>(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})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>RainMeshProbe</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -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<Region>(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<uint>();
|
|
||||||
|
|
||||||
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<Setup>(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<GfxObj>(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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>StarsProbe</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="C:\Users\erikn\source\repos\acdream\references\DatReaderWriter\DatReaderWriter\DatReaderWriter.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue