Opus narrow re-review ofae651312: APPROVE. Closes its three residuals: - AP-232 filed: retail's single-pass stage-1 OUTPUT alpha (MODULATE(TEXTURE, CURRENT) @0x0059c549) is the blend weight for a translucent subset; acdream's two-draw model is exact for opaque subsets (fog identity pinned) and a bounded weight difference on translucent ones. Distinct from AP-34 (queue order). Owed since05970306. - TerrainAtlas.DetailSamplerDescription names the production sampler (WRAP/LINEAR x3 per ACRender::SetDetailSurfaceInternal @0x006b6280); the test now asserts that constant's properties instead of a test-local copy. - Plan VM1 section: fragment now described as fogged; VM1 marked CLOSED with the Holtburg measurement (+2.17/+0.57/+0.16 vs predicted +2.2/+0.66/+0.16) and the detail-on cost (+0.3-0.5 ms CPU at Arwic). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
267 lines
14 KiB
Markdown
267 lines
14 KiB
Markdown
# Retail building and environment detail texturing — #226 port note
|
||
|
||
**Date:** 2026-08-21 · **Amended:** 2026-08-22 (Campaign VM, VM1)
|
||
**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED; re-ported to retail's
|
||
single-pass detail combine by VM1 after
|
||
[VM2's live cdb read](2026-08-22-vm2-retail-detail-path-cdb.md) proved real
|
||
hardware never takes the two-pass framebuffer fallback this note originally
|
||
described. The "Exact two-pass pseudocode" and "Brightening decision"
|
||
sections below are corrected in place; everything about the reachable
|
||
setting/caller chain, the authored source data, and material coverage is
|
||
unchanged and still applies.
|
||
|
||
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 §§1–2. 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 single-pass pseudocode
|
||
|
||
Retail has both a single-pass multitexture route and a two-pass framebuffer
|
||
fallback for adapters that cannot advertise `D3DTEXOPCAPS_PREMODULATE`.
|
||
[VM2's live cdb read](2026-08-22-vm2-retail-detail-path-cdb.md) on the
|
||
PDB-paired retail client found `m_caps.bCanDoSinglePassDetailing = 1` and the
|
||
file-static `trysinglepass = 1` on real (AMD) hardware, so
|
||
`D3DPolyRender::RenderMeshSubset` (`0x0059ca10`) never falls back for built
|
||
meshes — every loaded `CGfxObj` sets `use_built_mesh = 1`
|
||
(`CGfxObj::InitLoad` `0x005346b0`). The port below matches the path players
|
||
actually saw.
|
||
|
||
```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
|
||
|
||
// No distance term. ACRender::get_alpha_for_z (0x006b6230) is only
|
||
// evaluated in D3DPolyRender::DrawPolyInternal (0x0059d7c0, the
|
||
// immediate-polygon path) and only when the static noFadeDetail
|
||
// (0x00820e38, initialised to 1) is 0 — unreachable for built meshes.
|
||
// Attenuation is the LINEAR mip chain converging to the texture mean.
|
||
|
||
detail = sample(categoryTexture, baseUv * categoryTiling)
|
||
diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the
|
||
// translucency-fade multiplier
|
||
// for a fading subset.
|
||
// tmpmaterial.Diffuse.a = 1f
|
||
// (0x0059cb99) is the
|
||
// burnedInStaticLights < 0 &&
|
||
// *(render_device+0x7e4) == 0
|
||
// branch in RenderMeshSubset;
|
||
// the other branch leaves
|
||
// diffuse FromVertex. Either
|
||
// way the opaque->1 /
|
||
// fading->opacity mapping
|
||
// still holds.
|
||
|
||
// D3DPolyRender::SetSurface (0x0059c4d0) texture-stage setup:
|
||
// stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
|
||
// stage 0 alpha = PREMODULATE(DIFFUSE, DIFFUSE) = diffuseAlpha * detail.a
|
||
// stage 1 colour = BLENDCURRENTALPHA(TEXTURE, CURRENT) = lerp(current.rgb, detail.rgb, stage0.a)
|
||
src.rgb = detail.rgb
|
||
src.a = detail.a * diffuseAlpha
|
||
|
||
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 = SRC_ALPHA
|
||
destination = ONE_MINUS_SRC_ALPHA
|
||
```
|
||
|
||
The pixel this produces is `lerp(base * diffuse, detail.rgb, detail.a *
|
||
diffuseAlpha)` — a blend **toward** the detail colour by
|
||
`detail.a * diffuseAlpha`:
|
||
|
||
```text
|
||
result = base * (1 - detail.a * diffuseAlpha) + detail.rgb * (detail.a * diffuseAlpha)
|
||
```
|
||
|
||
`detail.a * diffuseAlpha == 0` is an exact no-op (fully-transparent detail
|
||
texel, or a translucency fade that has reached zero). At `detail.a *
|
||
diffuseAlpha == 1` the result is exactly the detail colour. There is no
|
||
"neutral gray" point — this is a lerp, not the fallback's multiplicative
|
||
`1 + fade * (detail.rgb - detail.a)` factor.
|
||
|
||
### 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.
|
||
|
||
Also unmodelled: retail's stage-1 OUTPUT alpha — `MODULATE(TEXTURE, CURRENT)`
|
||
(`0x0059c549`) — which for a delayed-alpha subset becomes the framebuffer
|
||
blend weight the alpha queue composites that subset with. acdream instead
|
||
draws the base subset with its own alpha and a second, separately blended
|
||
draw weighted by `detail.a * diffuseAlpha` (the pipeline in
|
||
`VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)`). For
|
||
opaque subsets this is identical (both reduce to the base's own alpha
|
||
gating nothing else downstream); on translucent building/EnvCell subsets it
|
||
is a bounded difference in how much the SUBSEQUENT alpha-queue compositing
|
||
sees, registered as its own row AP-232 (blend WEIGHT on translucent subsets), distinct from AP-34 (queue ORDER)
|
||
row rather than a new one.
|
||
|
||
## Darkening, not brightening
|
||
|
||
The earlier version of this note read the two-pass fallback's `DEST_COLOR +
|
||
ONE_MINUS_SRC_ALPHA` as retail's blend and reported the findings doc's
|
||
**1.177**, **1.204**, and **1.033** framebuffer-multiplier measurements for
|
||
the three Dereth textures as intentional retail brightening. VM2 showed that
|
||
factor formula belongs to the fallback only, which real hardware does not
|
||
run. The single-pass lerp above has the opposite sign: with the live
|
||
Dereth building/environment category texture (mean rgb 0.165, mean alpha
|
||
0.132) and opaque diffuse (`diffuseAlpha = 1`), the combine is
|
||
`≈ 0.868 × base + 0.022` — a mild **darkening** of roughly 10% on mid-tones,
|
||
not a brightening. This is retail's actual on-screen behavior on the
|
||
hardware the game shipped on; it is not a visual correction and carries no
|
||
opt-out. See
|
||
[`RetailDetailTextureContract`](../../src/AcDream.App/Rendering/RetailDetailTextureContract.cs)
|
||
and the VM2 note for the exact numbers.
|
||
|
||
## 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.
|
||
- There is no distance fade (VM1, VM2): `noFadeDetail` gates
|
||
`get_alpha_for_z` to the immediate-polygon path only, which built meshes
|
||
never reach. Attenuation is the sampler's linear mip chain converging to
|
||
the texture mean; a building reads the same well past 50 m as it does at
|
||
10 m, not a hard step.
|
||
- Category source, 256 x 256 size, tiling 4, repeat addressing, and linear mip
|
||
sampling match the measured Dereth data.
|
||
- The retail single-pass combine darkens the live category texture by
|
||
roughly 10% on mid-tones (VM2); this is expected. There is no
|
||
`dst=ZERO` correction mode and no brightening two-pass fallback hidden
|
||
behind the retail checkbox.
|
||
- Physics, collision, walkability, and geometry are untouched.
|