feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -0,0 +1,210 @@
# Retail building and environment detail texturing — #226 port note
**Date:** 2026-08-21
**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED
This note is the implementation handoff requested by #226. The measurements
below come from the already-completed
[`2026-08-21 terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md),
especially §§12. They are cited here rather than re-derived. The reachable
preference/caller chain is also recorded in
[`2026-07-10 detail texturing`](2026-07-10-detail-texturing.md).
The A2 terrain-normal verdict and A3 subdivision disposition are recorded in
the companion [`Terrain fidelity Track A report`](2026-08-21-terrain-fidelity-track-a-report.md).
## User-visible target and reachable caller trace
The issue title used to say “landscape,” but the Sept-2013 retail client does
not expose live landscape detail through this option:
1. The Options checkbox writes `RenderPrefs.EnvironmentDetailTextures`.
2. `Render::UpdateFromPreferences` (`0x0054d850`) explicitly changes
`Current_Render_LandscapeDetailTextures` to `0` and calls
`SmartBox::SetDetailTexturing(smartbox, 0, environmentEnabled)` at
`0x0054d9f3`.
3. `SmartBox::SetDetailTexturing` (`0x00451df0`) forwards
`LScape::SetDetailTexturing(lscape, landscape, enabled, enabled, 0)`.
4. `LScape::ChangeRegion` (`0x00506cb0`) independently installs the same
category state: `(0, EnvDetail, EnvDetail, 0)`.
The four positions are landscape (0), building (1), environment/EnvCell (2),
and ordinary object (3). The only reachable named-retail preference caller
forces categories 0 and 3 off. `DrawPartCell` also clears ordinary-object
detail. Therefore #226's scene target is **building shells and interior
EnvCell geometry**, not outdoor terrain, scenery, creatures, or players. This
also explains why acdream's existing checkbox is labelled “Building Detail
Textures.”
## Authored source, size, and sampling
Detail data is reached through
`Region(0x13000000).TerrainInfo.LandSurfaces.TexMerge.TerrainDesc[category]`:
```text
SurfaceTextureId = TerrainDesc[category].TerrainTex.DetailTextureId
tiling = TerrainDesc[category].TerrainTex.DetailTexTiling
RenderSurfaceId = SurfaceTexture(SurfaceTextureId).Textures[0]
rgba = decode(RenderSurface(RenderSurfaceId), level 0)
```
For Dereth, enabled categories 1 and 2 both resolve
`0x05001787 -> 0x06006D58`, a **256 x 256 A8R8G8B8** texture, with tiling
**4**. The complete measured Dereth population is three textures across 33
entries: `0x050012AF -> 0x060037D2` (64 x 64, 29 entries),
`0x05001786 -> 0x06006D57` (256 x 256, two), and the enabled-category texture
above (256 x 256, two). See the findings §2 table.
Retail uses wrap addressing in U and V and linear minification,
magnification, and mip filtering. Detail UV is `baseUv * tiling`. The port
therefore uploads each live category as a one-layer RGBA8 texture array with a
full mip chain and the existing repeat/linear world sampler.
## Exact two-pass pseudocode
Retail has both a single-pass multitexture route and a two-pass fallback. The
Vulkan port uses the fallback because it preserves the already-accepted base
pass byte-for-byte and expresses the retail framebuffer blend directly.
```text
enabled = DisplaySettings.BuildingDetailTextures // existing setting; no new option
buildingDetail = load_category(TerrainDesc[1])
environmentDetail = load_category(TerrainDesc[2])
for each retail built-mesh material subset:
draw_existing_base_subset_unchanged()
if enabled and subset belongs to a building or EnvCell:
draw the same subset with its category detail texture
// transparent/additive/inverse-alpha: detail follows its base
// immediately, before the next delayed-alpha subset
for each replayed fragment:
reject ordinary objects / landscape / scenery
accept opaque, ClipMap, alpha, additive and inverse-alpha subsets
zMetres = positive_view_space_depth_in_metres
fade = clamp((50 m - zMetres) / (50 m - 10 m), 0, 1)
// full through 10 m; linear 1050 m; exactly zero at/after 50 m
detail = sample(categoryTexture, baseUv * categoryTiling)
src.rgb = detail.rgb * fade
src.a = detail.a * fade
depth test = EQUAL opaque; LESS_OR_EQUAL transparent
depth write = preserve base class // ON opaque; OFF transparent
alpha-to-coverage = OFF // detail alpha is blend input
blend op = ADD
source = DEST_COLOR
destination = ONE_MINUS_SRC_ALPHA
```
Scaling **both** RGB and alpha by the fade is load-bearing. The resulting
framebuffer multiplier is:
```text
factor = 1 + fade * (detail.rgb - detail.a)
```
Thus fade zero is an exact no-op and the full-strength neutral point is
`detail.rgb == detail.a` channel-by-channel. It is not 0.5 gray.
### Built-mesh material coverage and order
The land-polygon `SurfaceType & 4` exclusion does **not** narrow this built-mesh
port. Named-retail `RenderDeviceD3D::DrawEnvCell` (`0x0059f170`) and
`DrawBuilding` (`0x0059f2a0`) install `curr_detail_surface` before calling
`D3DPolyRender::DrawMesh`. `DrawMesh` (`0x0059d4a0`) bypasses delayed-alpha
queuing while that surface is installed and passes detail enabled to
`RenderMeshSubset` (`0x0059ca10`) for each material subset. The fallback then
redraws that exact subset with the detail surface before proceeding. Therefore
ClipMap, straight-alpha, additive, and inverse-alpha built-mesh subsets are
included alongside plain opaque ones.
The Vulkan port first filters the opaque object command stream to coalesced
runs containing at least one category-1 building instance; nonbuilding-only
commands never reach the detail pipeline. A mixed instanced command remains in
the replay and `mesh_detail` rejects its ordinary instances individually. The
accepted opaque path stays batched, while transparent subsets preserve
immediate base/detail adjacency. Their separate detail pipeline
keeps depth writes disabled, matching the base subset's accepted depth
contract. This prevents another shell/object contribution from being
composited between the base and its detail contribution.
Opaque detail uses depth compare **EQUAL** against the exact geometry just
written by the base pass. Vulkan depth is per sample, so on MSAA ClipMap edges
the detail affects only samples whose base alpha-to-coverage mask wrote depth.
The detail pipeline itself deliberately keeps alpha-to-coverage off: detail
alpha controls `ONE_MINUS_SRC_ALPHA` in the retail blend and is not the base
coverage mask. Transparent bases do not write depth, so their adjacent detail
uses `LESS_OR_EQUAL` with depth writes still off.
One bounded ordering seam is explicit: retail bypasses its delayed-alpha queue
while `curr_detail_surface` is installed, whereas acdream retains its already-
authoritative shared alpha-queue order and inserts the detail draw immediately
after the corresponding base draw. This does not narrow material coverage or
change base coverage/blend/depth behavior; it avoids making the checkbox
reorder the default transparent scene. The connected acceptance matrix must
still exercise overlapping transparent building/EnvCell surfaces.
## Brightening decision
The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` verbatim. The
findings measured factors **1.177**, **1.204**, and **1.033** for the three
Dereth textures; the live Dereth building/environment category uses the
1.033-factor texture. That slight brightening is intentional retail parity,
not an acceptance failure.
Changing the destination factor to `ZERO` would be a visual correction rather
than a port. Exposing both behaviors behind one retail checkbox would also
make the option ambiguous. If a roughening-corrected material is wanted later,
it belongs as an explicitly named opt-in enhancement/shader-pack policy with a
registered divergence. It is not part of #226.
## What the reverted experiment got wrong
The experiment described by `c25d6186` was never committed as renderer code;
it was reverted from the worktree with `git checkout`. Its useful failure
record remains in that issue commit. It differed from the verified contract in
five material ways:
- It targeted outdoor landscape, while the live setting enables building and
environment categories and forces landscape off.
- It built a per-terrain-type texture array, while retail selects one
category-scoped surface and scalar tiling for each draw path.
- It used `base * detail * 2` (`MODULATE2X`) instead of retail's framebuffer
blend.
- It assumed 128 gray was neutral; retail neutral is RGB equal to alpha.
- Its acceptance prohibited an overall brightness change, although retail's
measured blend intentionally brightens these textures.
The old OpenGL-specific array/bindless wiring is also not reusable in the
current Vulkan-only RHI.
## Corrected acceptance
- With `BuildingDetailTextures=false`, no detail replay is submitted and the
current base rendering remains unchanged.
- With it `true`, toggling the existing Options checkbox **visibly changes
buildings and interior/EnvCell surfaces** without a restart. The connected
2026-08-21 Facility Hub A/B/A gate applied the real Config checkbox on ->
off -> restored-on and captured the same nearby walls/floor after each
transition. Static right-wall mean absolute RGB error was 2.132 for on/off
versus 0.007 for original-on/restored-on; the floor row was 3.385 versus
0.013. The persisted setting was observed false during B, restored true,
and the session ended with ACE-confirmed graceful logout.
- Outdoor terrain, ordinary scenery/objects, creatures, and players do not
gain this overlay.
- Every built building/EnvCell material subset is eligible: opaque, ClipMap,
straight alpha, additive, and inverse alpha. Transparent base/detail draws
remain adjacent in acdream's authoritative shared alpha order.
- Opaque object replay submits only command runs containing a building; mixed
commands are filtered per instance. Depth equality inherits the base pass's
per-sample ClipMap coverage without applying A2C to detail alpha.
- Detail is full through positive view depth 10 m, fades linearly over 1050
m, and is an exact no-op at and beyond 50 m.
- Category source, 256 x 256 size, tiling 4, repeat addressing, and linear mip
sampling match the measured Dereth data.
- The retail 1.033 live-category brightening is expected. There is no
`dst=ZERO` correction mode hidden behind the retail checkbox.
- Physics, collision, walkability, and geometry are untouched.

View file

@ -0,0 +1,75 @@
# Terrain fidelity Track A report
**Date:** 2026-08-21
**Status:** REPORT ACCEPTED BY OWNER DIRECTION; A1/A2 IMPLEMENTED; A3 REJECTED
This report answers Track A from the measured
[`terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md).
It cites that evidence rather than repeating its measurements, and it does not
reopen the findings' three refuted claims. The project owner subsequently
authorized implementation. No physics or collision behavior changed.
## A1 — #226 detail-texture overlay
The complete source/size/tiling, blend, neutral point, distance units, setting
gate, material-ordering contract, reverted-experiment analysis, and connected
A/B/A evidence are in the
[`#226 retail building/EnvCell detail-texturing port note`](2026-08-21-retail-building-detail-texturing-pseudocode.md).
The report conclusions are:
- The reachable user-visible target is **building shells and interior EnvCell
geometry**, not outdoor terrain. The Options preference caller and
`LScape::ChangeRegion` both install category state `(landscape=0,
building=enabled, environment=enabled, ordinary=0)` through
`SmartBox::SetDetailTexturing`.
- The existing **Building Detail Textures** checkbox is the sole setting gate.
No second option was added. Toggling it now visibly changes the connected
Facility Hub scene without a restart.
- The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` blend verbatim,
including the measured slight brightening. `dst=ZERO` would be an opt-in
visual correction, not parity; exposing both meanings behind the one retail
checkbox would make that preference ambiguous.
- The reverted experiment targeted landscape, built the wrong texture-array
shape, used `base * detail * 2`, assumed 128 gray was neutral, and rejected
the brightness change that the measured retail blend actually produces.
## A2 — terrain vertex normals
**Verdict: parity gap. Retail smooths shared terrain vertices.**
The decisive named-retail function is
`CLandBlockStruct::calc_lighting` at `0x00531700` in
[`acclient_2013_pseudo_c.txt`](named-retail/acclient_2013_pseudo_c.txt):
1. It zeroes one three-float accumulator for every shared landblock vertex.
2. From `0x00531774` through `0x005317F6`, it walks every terrain polygon and
adds that polygon's plane normal (`CPolygon + 0x20..0x28`) to the
accumulator of each of its three vertex IDs.
3. From `0x00531817` through `0x00531886`, it normalizes every accumulated
vector, falling back to `(0, 0, 1)` only for a degenerate sum.
4. The following sunlight/ambient loop dots those normalized shared-vertex
normals with `LScape::sunlight` and writes per-vertex lighting.
That is incident-face normal averaging, not flat per-face shading. The
WorldBuilder-derived `TerrainUtils.GetNormal` identified in the findings §4
is therefore a simplified tool path and not the retail oracle.
The approved port is in `LandblockMesh.BuildRetailVertexNormals`. It uses the
same split hash and exact emitted triangle topology, accumulates each
normalized incident face normal at the shared 9 x 9 height-sample vertex, and
normalizes the sum. Tests independently reconstruct the average from emitted
positions/indices and prove every position and index is unchanged.
This is lighting-only parity: the 81 height samples, 128 triangles, split
directions, terrain surface, collision triangles, walkability, and physics
owners are byte-for-byte/topology-equivalent to the prior path.
## A3 — subdivision
**Agree: the standing “not worth doing” recommendation survives.** The
findings §4 already establishes that the 9 x 9 samples are height-table
quantized, so subdivision cannot recover missing terrain detail; changing the
surface would create physics divergence, while coplanar subdivision would only
interpolate a surface whose retail-correct shared-vertex smoothing is now
already present. No subdivision work is scheduled.

View file

@ -0,0 +1,95 @@
# Campaign AR Stage 1 automated gate
**Date:** 2026-08-22
**Verdict:** PASS — every Stage 1 gate that does not require physical visual or
desktop-performance judgment is complete. Those judgments were deliberately
outside this automated report and subsequently passed in the
[Stage 1 live-gate report](2026-08-22-atmospheric-stage1-live-gate.md).
## Scope
This report covers the current authored-celestial implementation: the visible
above-horizon sun, dominant haloed moon, secondary moon, and no-source states;
the selected source's direction-versus-energy handoff; the 336-byte render-pack
shadow ABI; the unchanged authoritative retail path; and local deterministic
performance and lifetime contracts.
It does not itself claim that a physical display proves shadow alignment,
source-transition continuity, temporal pixelation/shimmer quality, or desktop
frame pacing. It did not itself begin Campaign AR Stage 2; the subsequent
project-owner live approval did.
## Results
| Gate | Result |
|---|---|
| Shader compilation | 24/24 Vulkan shader pairs ready |
| Retail shader preservation | all 18 pre-campaign SPIR-V SHA-256 oracles exact; no tracked retail SPIR-V change |
| Focused App renderer validation | 344/344 passed |
| Core sky loader | 14/14 passed |
| SDK and standalone pack validator | 30/30 passed |
| MossTank plugin regression | 48/48 passed |
| Forced locked restore | passed for the complete solution graph |
| Complete Release build after locked restore | passed, 0 warnings, 0 errors |
| Fresh-process hermetic Release gate | 14,928/14,928 passed, 0 skipped, 0 failed, 14 assemblies |
| App assembly inside the complete gate | 5,823/5,823 passed |
The release evidence bundle is
[`artifacts/atmospheric-rendering/stage1-moon-release-gate/`](../../artifacts/atmospheric-rendering/stage1-moon-release-gate/).
Its `release-gate-summary.json`, TRX files, logs, environment inventory, and
`SHA256SUMS.txt` are the machine-readable authority for the fresh-process total.
## Performance and lifetime coverage
The complete App gate includes these deterministic contracts:
- `DirectionalShadowCasterFrameTests.WarmStableFrame_AllocatesZero` builds a
9,500-static-caster scene, warms it, then performs 256 stable frames with
zero managed bytes, no additional scene-index copy, no classification, and
no topology rebuild.
- `DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords`
proves dense animated-transform refresh stays allocation-free and does not
reread scene records.
- `AtmosphericCpuStageProfilerTests.WarmedObservationAllocatesNothing` and
`AtmosphericGpuTimerSamplingTests.WarmSamplingDecisionsAllocateZero` keep the
measurement path allocation-free after warmup.
- `RenderPackLongCycleConvergenceTests.RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly`
repeatedly crosses Low, Medium, High, retail selection, resize, injected
failure/recovery, both frame-flight slots, render-generation replacement,
and terminal disposal for 12 cycles. Every pack resource, registration,
receiver candidate, transform owner, texture slot, and pipeline-format lease
returns to its exact baseline.
- `RenderPackLongCycleConvergenceTests.DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice`
proves recreation is complete old-renderer/context/device teardown followed
by an independent activation generation on a fresh device.
These are CPU-side and recording-RHI gates. The historical physical AMD rows
remain valid for their exact pre-moon binaries and stated scope, but they are
not reused as current sun-and-moon image-quality or desktop-performance proof.
## Authoritative-path and scope audit
- Shader regeneration expands includes and injects pack-only definitions only
for pack shaders. Unchanged retail sources retain their existing committed
binaries; the source manifest still forces a recompile after a real source
edit.
- The exact pre-campaign retail SPIR-V oracle passes after ordinary shader
regeneration.
- No source file under `src/AcDream.Runtime` changed for this campaign gate.
- No physics or collision source changed.
- No retail GLSL source changed.
- Pack-off production integration continues to require zero enhancement passes,
resources, casters, cascades, draws, or dispatches and its pinned framebuffer
and resource ledger remain exact.
## Pending project-owner gate
When the desktop is healthy, launch the corrected Release client against ACE
and stop for the project owner to judge:
- sun, dominant-moon, and secondary-moon shadow alignment;
- sun-to-moon, moon-to-moon, and no-source transitions;
- temporal pixelation/shimmer during camera and celestial motion; and
- desktop smoothness, frame pacing, and FPS behavior.
Stage 2 and campaign closeout remain gated on that explicit approval.

View file

@ -0,0 +1,46 @@
# Campaign AR Stage 1 live gate
**Date:** 2026-08-22
**Verdict:** PASS — project-owner accepted; Stage 2 authorized
## Scope
This is the physical-display and desktop-performance stop that followed the
[Stage 1 automated gate](2026-08-22-atmospheric-stage1-automated-gate.md). It
records the project owner's live acceptance of the opt-in Atmospheric pack; it
is not final Campaign AR acceptance.
The owner exercised the Vulkan client against the local ACE server through the
Stage 1 correction rounds: visible authored sun and moon shadows, selection and
configuration persistence, temporal texture/shadow shimmer, frame pacing and
desktop responsiveness, world selection, fullscreen, and final exposure. After
the exposure correction the owner reported **“Looks good!”** and directed the
campaign to synchronize with main and proceed autonomously through Stage 2.
The opt-in Atmospheric exposure changed from `1.00` to `0.80`. The retail
renderer remains the default and authoritative path. Physics, collision,
gameplay, and network behavior are unchanged.
## Matched exposure evidence
The final comparison pinned time, day group, sky, weather, MSAA, route, and
camera framing. Its five screenshots and machine-readable metadata are under
[`artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/`](../../artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/).
| Scene/preset | Retail mean luminance | Atmospheric mean luminance | Delta | Atmospheric p95 delta | Saturation delta | Clipped pixels |
|---|---:|---:|---:|---:|---:|---:|
| Outdoor / High | 0.1090 | 0.1003 | -8.0% | +6.8% | +29.8% | 0% |
| Interior / High | 0.2711 | 0.2813 | +3.8% | -3.5% | +5.2% | 0% |
| Outdoor / Low | 0.1090 | 0.1011 | -7.2% | +8.4% | +30.1% | 0% |
Before the correction, Atmospheric High measured +19.6% outdoors and +24.5%
indoors. The `0.80` correction removes that overexposure without clipping.
The final outdoor Atmospheric High capture reports 6,613 shadow casters, four
cascades, 95,260,912 resident GPU bytes, and 285 performance samples.
## Acceptance boundary
This gate does not fabricate evidence for a second connected remote player,
portal/reconnect or device-recreation lifecycle, external package flows,
long-run convergence, or unavailable physical GPU classes. Those remain Stage
2/closeout rows, followed by the final project-owner acceptance gate.

View file

@ -0,0 +1,200 @@
# Dereth celestial shadow sources
**Date:** 2026-08-22
**Status:** measured retail-DAT and named-retail finding; implementation input
for Campaign AR
**Scope:** identify the Dereth sun/moons and define the opt-in pack's dominant
directional-shadow source. This note does not change the retail rendering path.
## Conclusion
Dereth's Region `0x13000000` consistently authors three moving celestial
meshes across all 20 day groups:
1. `0x01001348` is the sun disk.
2. `0x01001F6A` is the large, haloed moon and is the dominant lunar source.
3. `0x01001F67` is the smaller secondary moon.
Retail does **not** provide a separate lighting colour or intensity for each
mesh. `SkyDesc::GetLighting` produces one interpolated directional vector,
colour, and brightness from `SkyTimeOfDay.DirHeading`, `DirPitch`, `DirColor`,
and `DirBright`. The opt-in atmospheric pack therefore uses the selected
visible celestial mesh only for shadow **direction**. Colour and energy remain
the single AC-authored directional-light values.
The deterministic priority is:
1. visible sun whose transformed centre is above the horizon;
2. visible large/haloed moon whose transformed centre is above the horizon;
3. visible secondary moon whose transformed centre is above the horizon;
4. no directional shadow source.
This is a pack enhancement, not a claim that retail cast real-time moon
shadows.
## Evidence and provenance
The investigation followed the project rendering inventory and used the
already-loaded retail structures rather than inventing another sky model.
Evidence came from:
- `artifacts/atmospheric-rendering/sky-heading-dump/client.log`, especially
lines 40-88 for Sunny day group 0 and the corresponding repeated entries for
all later day groups. The dump records the three IDs, visibility windows,
angular sweeps, keyframe directional lighting, and the sun surface.
- A read-only `DatCollection.Get<GfxObj>`/`Get<Surface>` probe against the
installed Asheron's Call DATs, using the same inspection path implemented by
`tools/SkyObjectInspect/Program.cs`, for all three `GfxObj` sort centres,
polygon geometry, surfaces, and texture chains.
- `tools/RainMeshProbe/Program.cs` lines 37-49, which names and audits the
celestial surface set independently of the shadow implementation.
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
`SkyDesc::GetLighting` at `0x00500a80` (around line 261291),
`SkyDesc::GetSky` at `0x00501ec0` (around line 262761),
`GameSky::CalcFrame` at `0x00506f80` (around line 268650), and
`GameSky::UseTime` at `0x005075b0` (around line 269090).
- `docs/research/2026-04-23-sky-retail-verbatim.md`, especially its recorded
directional-light interpolation and `GameSky::UseTime` material updates.
No fresh decompilation was required. The named-retail corpus already answered
the only question the current code and DAT dump could not answer on their own:
whether a moon mesh contributes a second retail world light. It does not.
## Installed-DAT characterization
The following values were read from the installed Dereth Region and the three
referenced `GfxObj`/surface/texture chains. The same three object IDs, windows,
and sweeps occur in every one of the 20 day groups; only their object index
changes between seven-object and weather-heavy groups.
| Role | GfxObj | Day window | Angular sweep | Authored `SortCenter` |
|---|---:|---:|---:|---:|
| Sun disk | `0x01001348` | `0.1600..0.9400` | `-23 deg..203 deg` | `(1050, 0, 0)` |
| Secondary moon | `0x01001F67` | `0.0400..0.2100` | `-20 deg..190 deg` | `(1909.46, 1874.78, -0.0000157485)` |
| Dominant moon + halo | `0x01001F6A` | `0.0000..0.2300` | `-20 deg..190 deg` | `(2066.82, 552.99, 0)` |
The asset chain establishes the visual identities and the dominant-moon
choice:
| GfxObj | Surface | Surface flags | SurfaceTexture | RenderSurface | Image |
|---:|---:|---|---:|---:|---|
| `0x01001348` | `0x080000D1` | Base1Image, Alpha, Additive | `0x050014CD` | `0x0600388D` | 128x128 `PFID_R8G8B8` sun disk |
| `0x01001F67` | `0x080000D2` | Base1ClipMap | `0x05001A6C` | `0x06003894` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
| `0x01001F6A` | `0x080000D6` | Base1ClipMap | `0x05001A6D` | `0x06003898` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
| `0x01001F6A` | `0x080000D7` | Base1Image, Alpha, Additive | `0x05001A6E` | `0x06003899` | 128x128 `PFID_R8G8B8` halo |
Every listed surface has authored `Luminosity=1`, `Diffuse=1`, and
`Translucency=0`. The large moon's primary quad has roughly 2.3 times the
polygon area of the secondary moon before its still larger additive halo is
counted. That makes `0x01001F6A` the unambiguous dominant lunar visual when
both moons are above the horizon.
These installed-DAT facts are characterization evidence, not an ordinary test
dependency. Unit tests use hand-built `DayGroupData` so clean CI and machines
without retail DATs remain deterministic.
## Direction and visibility contract
`SkyObjectData.IsVisible(dayFraction)` owns the normal, always-visible, and
midnight-wrapping window cases. `CurrentAngle(dayFraction)` owns the authored
arc interpolation, including progress through a wrapping window.
The selected direction must match the sky renderer exactly:
```text
heading = active SkyObjectReplace.Rotate
arc = SkyObjectData.CurrentAngle(dayFraction)
model = RotationZ(-heading) * RotationY(-arc)
anchor = effective GfxObj.SortCenter
direction = normalize(TransformNormal(anchor, model))
```
“Effective” means that an active non-zero replacement `GfxObjId` also supplies
its own `SortCenter`. A replacement with `Transparent >= 1` makes the object
ineligible. The replacement lookup follows the renderer's discrete active
keyframe rule; it does not interpolate replacement fields. A zero, non-finite,
or below/on-horizon transformed direction is ineligible.
This deliberately does not substitute `SkyTimeOfDay.DirHeading/DirPitch` for
moon direction. Those values are the one retail world-light direction. The
moon meshes have separate authored arcs, and the enhancement is specifically
intended to align moon shadows with the moon the player can see.
## Authored light contribution
Named retail `SkyDesc::GetLighting` interpolates the two surrounding
`SkyTimeOfDay` records and produces:
```text
sunVector = DirBright * (
cos(DirPitch) * sin(DirHeading),
cos(DirPitch) * cos(DirHeading),
sin(DirPitch))
directionalColor = DirColor * length(sunVector)
```
`length(sunVector)` is `DirBright`. acdream exposes the resulting colour as
`SkyKeyframe.SunColor`. The pack's scalar authored energy is therefore
`clamp(max(SunColor.r, SunColor.g, SunColor.b), 0, 1)`.
By contrast, named retail `GameSky::UseTime` sends a celestial replacement's
`Luminosity`, `MaxBright`, and `Transparent` to the mesh material through
`SetLuminosity`, `SetDiffusion`, and `SetTranslucency`. It does not install a
second directional light. Texture brightness and moon surface luminosity must
not manufacture extra world-light energy.
Weather/day-group reductions, softness, and elevation ramps remain explicit
render-pack policy. They are not mislabelled as measured retail intensities.
## Parity and safety registration
### Retail behavior
- One interpolated directional world-light channel comes from
`SkyTimeOfDay.Dir*`.
- Celestial meshes follow their own visibility windows and transformed arcs.
- Replacement luminosity/diffusion/transparency changes mesh material state,
not the number of world-directional lights.
- Retail does not render the Campaign AR cascaded real-time object shadows.
### Opt-in pack enhancement
- The pack chooses the visible sun or dominant visible moon direction for its
directional shadow map.
- Moon direction follows the rendered moon; energy remains the single
AC-authored directional channel.
- Sun wins any overlap when its transformed centre is above the horizon;
otherwise the haloed moon wins before the secondary moon.
- This deviation belongs in the atmospheric render-pack entry of
`docs/architecture/retail-divergence-register.md`.
### Unchanged boundaries
- The retail rendering path remains the default and authoritative output.
- Pack-off frames do not resolve or render celestial shadow work.
- Existing retail scene lighting remains driven by `SkyStateProvider`; this
policy does not replace it.
- Physics, collision, containment, selection, movement, and DAT geometry are
untouched. The selected source is an immutable one-frame rendering fact.
## Deterministic acceptance coverage
`tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`
locks:
- the three verified IDs and priority independent of object-list order;
- sun overlap, dominant-moon fallback, and secondary-moon fallback;
- fully transparent and effective replacement behavior;
- replacement rotation and the exact renderer transform direction;
- no-visible/no-above-horizon suppression;
- midnight-wrapping visibility and angle progress; and
- directional colour-times-brightness energy, including preservation when no
celestial source is available.
The test fixture is entirely hand-built. It neither requires nor silently
substitutes installed retail DAT content.
The complete non-physical verification result, including shader ABI, exact
retail-binary preservation, performance/lifetime fixtures, locked restore,
Release build, and fresh-process totals, is recorded in the
[Campaign AR Stage 1 automated gate report](2026-08-22-atmospheric-stage1-automated-gate.md).