# Terrain detail, terrain normals, and atmospheric rendering — verified findings **Date:** 2026-08-21 · **Status:** RESEARCH / HANDOFF — no code written **Tree:** `main` @ `255b0aae` (branch `claude/git-sync-status-5fb1d2`, level with main) Everything below was **measured** against the installed DATs, the current source tree, or the named-retail decomp. It exists so the next session does not re-derive it. Where a claim was tested and **refuted**, that is recorded too — those are the expensive ones to rediscover. --- ## 1. The detail-texture overlay is NOT implemented (and the setting lies) **Renderer:** `terrain_modern.frag` / `.vert` contain **zero** detail references — no second sampler, no detail UV, no distance-fade term. Tree-wide there is no `DetailTex` / detail-surface concept in `src/AcDream.App/Rendering/`. Verified at `255b0aae`, not only at the older `bb1640f7`. **But the SETTING exists and is DEAD:** | Piece | Location | |---|---| | `bool BuildingDetailTextures = true` | `AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs:77` | | persisted as `"buildingDetailTextures"` | `SettingsStore.cs:99` (read), `:677` (write) | | Options checkbox, retail string `ID_Graphics_BuildingDetailTextures` | `ConfigOptionsPageController.cs:772-774` | | **consumers in `Rendering/`** | **none** | The Options window shows "Building Detail Textures", checked by default, persisting across sessions — wired to nothing. This is worse than not having the setting: the UI implies a feature that does not exist. **#226's port must consume this existing setting, not invent a new one**, and its acceptance test must include "toggling it visibly changes the scene". Already shipped and NOT to be confused with this: **#155 base tiling** (`bb5acab9`, ported `TexMerge::CopyAndTile`/`Merge`) — that fixed the *stretched* look. #155 and #226 were conflated once already. --- ## 2. How retail's detail pass actually works ### Authored data (measured from the installed DATs) Detail textures are authored **per terrain entry**: `TerrainTex.DetailTextureId` + `TerrainTex.DetailTexTiling`, reached via `LandSurf::GetDetailTex` / `GetDetailTiling`. Region `0x13000000` "Dereth": **33 terrain entries, only 3 distinct detail textures.** | Detail texture | RenderSurface | Size | Used by | |---|---|---|---| | `0x050012AF` | `0x060037D2` | **64x64** A8R8G8B8 | **29 of 33 (88%)** | | `0x05001786` | `0x06006D57` | 256x256 A8R8G8B8 | 2 — BarrenRock, LushGrass | | `0x05001787` | `0x06006D58` | 256x256 A8R8G8B8 | 2 — Grassland, Ice | `DetailTexTiling` is mostly **1** (4 for grass/rock types, 8 for FauxWaterRunning, 2 for RoadType) against a base `TexTiling` of 2. This confirms the community observation that the client "uses the same noise texture for everything" — one 64x64 texture backs 88% of Dereth's terrain. ### Four categories, and which the setting actually gates `LScape::SetDetailTexturing` (0x00506b40) manages **four** independent detail surfaces + tilings: `0 = landscape`, `1 = building`, `2 = environment`, `3 = object`. Also `GenerateDetailSurface` (0x00506230), `CleanupDetailSurfaces` (0x00504ae0). `LScape::ChangeRegion` (0x00506cb0) calls: ```c SetDetailTexturing(this, 0, EnvDetail, EnvDetail, 0); // ^landscape=OFF ^building ^environment ^object=OFF ``` gated on the single `Render::m_RenderPrefs.EnvironmentDetailTextures`. **So retail's setting is a BUILDINGS-AND-INTERIORS setting, not a terrain setting** — which explains why its UI label is "Building Detail Textures". `SmartBox::SetDetailTexturing` (0x00451df0) *can* pass landscape through. **ANSWERED 2026-08-22 (VM2 cdb read):** `Render::m_RenderPrefs.LandscapeDetailTextures` is a real, separate preference and reads **0** on the live client (`EnvironmentDetailTextures = 1`); `landscape_detail_surface` is null while `building_detail_surface`/`environment_detail_surface` are set. Terrain detail is off by preference, and there is no Options row for it. ### The blend — the FALLBACK path (see the correction below) `ACRender::SetDetailSurfaceInternal` (0x006b6280): ```c SetStageTexture(stage, detailTex); SetSamplerAddressMode(stage, TEXADDRESS_WRAP, TEXADDRESS_WRAP); SetSamplerFilterMode(stage, TEXFILTER_LINEAR x3); if (stage == 0) { SetBlendFunction(curr_detail_src_blend, curr_detail_dst_blend, BLENDOP_ADD); SetAlphaBlendEnable(1); SetDepthBufferMode(DEPTHTEST_LESSEQUAL, 1); } ``` It is a **framebuffer blend, not a DOT3/normal-map path.** Factors differ per category: | Path | site | src | dst | |---|---|---|---| | environment / interiors | `0059f1c2` | `9` = `BLEND_DSTCOLOR` | `6` = `BLEND_INVSRCALPHA` | | landscape | `005a19a1` | `5` = `BLEND_SRCALPHA` | `6` = `BLEND_INVSRCALPHA` | | static default (`.data`) | `0081eca0` | 5 | 6 | `DSTCOLOR + INVSRCALPHA` under `BLENDOP_ADD` evaluates to `dest x (detailLum + 1 - alpha)`. Decoded means of the three real textures: | texture | lum | alpha | factor | effect | |---|---|---|---|---| | `0x060037D2` | 0.459 | 0.282 | **1.177** | **+18% brighter** | | `0x06006D57` | 0.414 | 0.210 | **1.204** | **+20% brighter** | | `0x06006D58` | 0.165 | 0.132 | **1.033** | **+3% brighter** | **The FALLBACK detail pass brightens rather than roughens** — and, **CORRECTED 2026-08-22 (VM2/VM4)**, the fallback is not what real hardware runs. The framebuffer blend above is taken only when `stage == 0`, i.e. when the adapter cannot advertise `D3DTEXOPCAPS_PREMODULATE`. A live cdb read on the owner's AMD GPU gave `m_caps.bCanDoSinglePassDetailing = 1` and `trysinglepass = 1`, so retail uses the single-pass texture-stage path in `D3DPolyRender::SetSurface` (0x0059c4d0): `lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)` — a mild blend toward the detail colour (about −10 % on mid-tones with the live category texture). The community's "reflect more instead of being rougher" describes the fallback only. Full evidence: [`2026-08-22-vm2-retail-detail-path-cdb.md`](2026-08-22-vm2-retail-detail-path-cdb.md). The #226 port was re-done to the single-pass math at Campaign VM VM1. ### Terrain draw path `ACRender::landPolyDraw` — **two overloads**, `0x006b6320` (single-poly) and `0x006b6760` (two-poly). Detail is gated on `trysinglepass && m_caps.bCanDoSinglePassDetailing && curr_detail_surface != 0`, then `SetDetailSurfaceInternal(1)`. Single-pass multitexture, with a non-single-pass fallback that is **untraced**. Vertex lighting comes from `ACRender::curLandBlockVertexLighting`. --- ## 3. REFUTED claims — do not re-chase these - **"client_highres.dat is an override dat and we pick the low-res copy."** FALSE. Measured twice, including at `255b0aae`: portal **20,684** RenderSurfaces, highres **2,294**, **overlap 0** — fully disjoint IDs. `TextureCache` (`Portal.TryGet || HighRes.TryGet`) and `DatCollectionAdapter.TryResolvePreferred` (Portal then HighRes) both reach them, so every high-res surface resolves. **We already use them.** The "Portal precedence is load-bearing" comment in `DatCollectionAdapter` is vestigial given zero overlap. QUESTION CLOSED. - **"The detail textures are normal maps."** FALSE. The 64x64 one is blue-dominant (RGB 0.303/0.469/0.824), which *looks* like a tangent-space normal map, but unpacking RGB as a vector and measuring length gives **24.3% / 7.7% / 14.9%** unit-length pixels (a real normal map is ~100%). They are colour+alpha detail textures. - **"AC has no normal-map capability at all."** ALSO FALSE — the engine has a `BumpMap` parameter, the `DotProduct3` texture op, and a hardware capability probe `m_caps.bTexOpDotProduct3` (read at `0x0059f5c7`). The machinery exists; the detail textures simply are not normals. What *uses* the bump path is untraced. --- ## 4. Terrain geometry and normals - Landblock = **192 m**; 8x8 cells @ **24 m**; 9x9 = **81 height samples**; 2 triangles per cell => **128 triangles per landblock**. - Heights are **quantised**: each sample indexes a **256-entry** `region.LandDefs.LandHeightTable`. This caps what smoothing or subdivision can achieve — they smooth quantisation, they do not recover unsampled detail. - Diagonal split direction comes from the `FSplitNESW` hash of world cell coords (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, `0x519B8F25`). - `src/AcDream.Core/Rendering/Wb/TerrainUtils.cs` `GetNormal` returns the **flat per-face normal**. **CORRECTED 2026-08-22 (VM4):** that function only orients procedural scenery (`SceneryGenerator.cs:166`); it never fed the render normal, and the claim that it produced a faceted look was wrong. The rendered mesh already used smooth central-difference normals (Phase 3b, `LandblockMesh`). Retail (`CLandBlockStruct::calc_lighting` 0x00531700) averages the unit plane normals of each vertex's incident polygons; Campaign AR's A2 ported that, replacing central differences — a real parity port with a subtle visual delta (VM0: 11–14 % of pixels at mean |Δ| ≈ 1–1.5). **Open question, must be answered from the decomp, no guessing:** does retail smooth terrain vertex normals? Entry points: `ACRender::landPolyDraw` (both overloads, note `ACRender::curLandBlockVertexLighting`), `LScape::calc_object_light` (0x00455730), the LScape sunlight/ambient block ~`0x00455b51`, and how a terrain `CPolygon`'s vertex normals/colours are built. `TerrainUtils` is WorldBuilder-derived, so WB may have simplified — **check retail, not WB.** - retail smooths => **parity gap**, schedule like #226. - retail is faceted => opt-in enhancement, belongs with the shader-pack work. Smoothing costs **zero geometry change** and therefore **zero physics risk**: collision, walkability, slope tests and the 4M-cell conformance sweep all see identical triangles. **Subdivision is NOT recommended** and the next session should try to refute rather than confirm that: perceived softness is texture-frequency (§1-2) plus flat shading (this section), not silhouette resolution; the 256-step quantisation caps the gain; and it is the only one of the three that touches the physics contract — those triangles ARE the collision surface (FloorZ / ValidateWalkable, walkable-polygon tracking, precipice/cliff slide, the 4M-cell conformance sweep, and the triangle-boundary Z bug that cost five failed fix attempts). If ever wanted, the only defensible forms are coplanar surface-preserving subdivision (identical surface, better Gouraud gradients) or an interpolating spline through the original 81 samples, render-only, with a registered bounded divergence. --- ## 5. Renderer state relevant to atmospheric work - **9 shader pairs** (`debug_line`, `mesh_modern`, `particle`, `particle_mesh`, `portal_depth`, `sky`, `terrain_modern`, `ui_text`, `vk_probe`) plus a compiled `spv/` directory. - `mesh_modern` uses **per-vertex Gouraud** lighting deliberately — the A7 comment records that a per-pixel evaluation produced a hard "spotlight pool" unlike retail's fixed-function T&L. 8-light `SceneLighting` UBO carrying `uFogParams` / `uFogColor` / `uCameraAndTime`. Two-pass alpha (opaque discard `<0.95`, translucent discard `>=0.95` and `<0.05`). - **Sun direction already exists and already tracks the day/night cycle:** `WorldRenderFrameBuilder.cs:526` — `-SkyStateProvider.SunDirectionFromKeyframe(keyframe)`, supplied as `LightKind.Directional` (`:531`, `:546`). The sun is a **real drawn object** (`dayGroup.SkyObjects` via `SkyPesFrameController`). - **A depth-only pipeline shape already exists:** `portal_depth.vert/.frag` with `GpuPipelineDescription.ColorWrite = false` (`Gpu/GpuPipelineDescription.cs:276`, honoured in `Gpu/Vk/VulkanGpuPipeline.cs:205`) and an empty fragment `main()`. **A shadow map is that pipeline aimed at the sun.** - `activeDayGroup` (weather: Clear / Cloudy / Overcast / Rainy) reaches the frame builder. - Campaign V (OpenGL -> Vulkan) closed 2026-07-29: pass-based RHI (`IGpuDevice` / `IGpuFrame` / `IGpuPassEncoder`, explicit `GpuPassDescription` + `GpuPipelineDescription`) over bindless + MDI. ### Performance baseline and the binding constraint **519.7 FPS; CPU/GPU p50 1.869 / 1.096 ms** (CLAUDE.md), and the dense-town profile is **CPU-SUBMISSION-BOUND** (memory: `feedback_render_perf_measurement`). **VM0 baseline (2026-08-22, connected, uncapped Release, no automation observer, `ACDREAM_FRAME_PROF=1`; `2026-08-22-vm0-default-path-invariance.md`):** | Spot | Binary | CPU p50 / p95 | GPU p50 | alloc KB/frame | |---|---|---|---|---| | Holtburg | `6c79d35c` | 4.7 / 5.1 ms | 0.4 | 574 | | Holtburg | Campaign AR, pack off | 4.1 / 4.4 ms | 0.4 | 21 | | Arwic (dense) | `6c79d35c` | 6.0 / 6.5 ms | 0.7 | 582 | | Arwic (dense) | Campaign AR, pack off | 5.2 / 5.6 ms | 0.7 | 29 | Any figure captured under `ACDREAM_AUTOMATION_ARTIFACT_DIR` carries the render-scene observer's allocation and is NOT comparable with this table. Consequences: - GPU-side **fullscreen** work is nearly free in observed FPS — it fills GPU idle time. Tier-1 post-processing costs little. - Anything that adds **CPU submissions** is expensive. **Shadow cascades must not re-run visibility culling per cascade on the CPU** — draw the full resident set per cascade, or move culling to a GPU compute pass. - `glFinish`-style profilers **inflate** GPU timings. Measure with the existing gates (capped/uncapped, p50/p99 CPU+GPU, pinned dense Arwic). --- ## 6. Wanted work — atmospheric rendering (user-stated) Opt-in **shader packs**, modelled on Minecraft's Iris/OptiFine: the retail-faithful path stays the **default and authoritative**; enhanced rendering is a toggle, exposed through the **plugin API**. This framing is what resolves the parity tension — the divergence register is untouched because the faithful path still exists and is still what we test. | Tier | Contents | Prerequisite | |---|---|---| | 1 | bloom, ACES filmic tonemap, colour grade, vignette | none — fullscreen, scales with pixels | | 1 | screen-space sun rays (crepuscular) | sun screen-pos + occlusion mask; **no shadow maps** | | 2 | **cascaded directional shadows** — trees, monsters, houses | second scene pass | | 2+ | volumetric light shafts | reuses the shadow map — nearly free after shadows | | later | SSAO, water reflections | depth + normals | | out | true PBR | AC has no per-texture normal/roughness maps | **Dynamic shadows are an explicit user want** — trees, monsters and buildings casting real sun shadows — not merely a design exercise. Design points the user asked for: - Ray/shaft intensity driven by **AC's own authored weather** (`activeDayGroup`) rather than invented constants — strong shafts at clear dawn, muted under Overcast. Combined with the already-authored moving sun this yields rays that rake low at dawn, vanish at noon, return at dusk. Retail never had this. - Composite sun rays **before** tonemapping so bloom picks them up and they roll off the filmic curve instead of clipping. ### Shadow-specific constraints 1. **Alpha-tested casters.** Foliage is cutout geometry (two-pass alpha), so the shadow pass **cannot** use `portal_depth`'s empty fragment shader — it must sample and discard, or every tree casts a solid rectangle. 2. **Animated casters.** Monsters need the same per-part transforms as the main pass; those already live in the N.5 SSBO, so the shadow vertex shader can read the same buffer. 3. **Indoors has no sun.** Gate to outdoor cells; dungeon cells use authored per-cell ambient and must not be fought. 4. **Cascades must be camera-relative**, bounded by the two-tier streaming window (memory: `reference_two_tier_streaming`), not a fixed world extent. 5. **Depth bias will hit issue #129's bug class** — an NDC-space bias constant spans `~ b*d^2/near` **metres** of eye depth at distance; #129 leaked door-shaped holes through hills. Memory: `feedback_ndc_constants_eye_space_meaning`. 6. **NAMING TRAP:** in AC's codebase "shadow" means the per-cell **physics** registration list (`CPhysicsObj::add_shadows_to_cells`, `shadow_objects`) — nothing to do with lighting. Grepping "shadow" drowns in collision hits. 7. Quality scaling for weak hardware: half/quarter-res bloom and rays, fewer cascades, lower shadow resolution. 4K pays ~4x the tier-1 pixel cost. --- ## 7. Open questions carried forward 1. Does retail smooth terrain vertex normals? (§4) — decides parity-gap vs enhancement. 2. Who calls `SmartBox::SetDetailTexturing`, and is landscape detail ever enabled in practice? (§2) 3. What is the non-single-pass detail fallback in `landPolyDraw`? (§2) 4. What uses the `BumpMap` / `DotProduct3` path, if not the detail textures? (§3) 5. ~~For #226: port retail's brightening blend verbatim, fix it, or expose both?~~ **ANSWERED 2026-08-22:** neither — the brightening blend was the fallback; VM1 ported the single-pass lerp that real hardware runs (§2). ## 8. Where this sits M4 is the active milestone; the next planned implementation work is the **#268 + TS-8 stat-chain package**, and none of the above should displace it without the user's say-so. #226 and the terrain-normals question are **parity gaps** (schedulable now). The shader-pack tiers are **post-M7**, since rendering phases are frozen until the polish pass — but they are wanted, so the design should be captured rather than rediscovered.