Dated 'VM4 correction' notes, not silent rewrites: - AR plan: NoOpRenderPackProductionIntegrationTests is a 2x2 recording- device fixture, not a production pin; the real invariance is VM0's. Both '14,928/14,928 zero skips' totals qualified: hermetic lane filter. - Stage-2 connected report: the dense-town CPU figures ran under the automation observer (9.8 MB/frame alloc) and say nothing about the owner's hitch on the product; clean numbers are VM0's. - Track A report + findings doc: TerrainUtils.GetNormal only orients scenery; the render normals were already smooth; A2 replaced central differences with retail's incident-face average (the reviewer's own premise error). - Findings doc: 'retail brightens' is the fallback path only; real hardware runs the single-pass lerp (VM2). LandscapeDetailTextures = 0 answers the landscape open question. VM0 production table added as the baseline. - Completion audit retail-path row and the review's F1/F2/F3/F5 headers annotated with their closures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
17 KiB
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:
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):
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.
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) andDatCollectionAdapter.TryResolvePreferred(Portal then HighRes) both reach them, so every high-res surface resolves. We already use them. The "Portal precedence is load-bearing" comment inDatCollectionAdapteris 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
BumpMapparameter, theDotProduct3texture op, and a hardware capability probem_caps.bTexOpDotProduct3(read at0x0059f5c7). 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
FSplitNESWhash of world cell coords (constants0x0CCAC033,0x421BE3BD,0x6C1AC587,0x519B8F25). src/AcDream.Core/Rendering/Wb/TerrainUtils.csGetNormalreturns 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_lighting0x00531700) 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 compiledspv/directory. mesh_modernuses 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-lightSceneLightingUBO carryinguFogParams/uFogColor/uCameraAndTime. Two-pass alpha (opaque discard<0.95, translucent discard>=0.95and<0.05).- Sun direction already exists and already tracks the day/night cycle:
WorldRenderFrameBuilder.cs:526—-SkyStateProvider.SunDirectionFromKeyframe(keyframe), supplied asLightKind.Directional(:531,:546). The sun is a real drawn object (dayGroup.SkyObjectsviaSkyPesFrameController). - A depth-only pipeline shape already exists:
portal_depth.vert/.fragwithGpuPipelineDescription.ColorWrite = false(Gpu/GpuPipelineDescription.cs:276, honoured inGpu/Vk/VulkanGpuPipeline.cs:205) and an empty fragmentmain(). 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, explicitGpuPassDescription+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
- 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. - 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.
- Indoors has no sun. Gate to outdoor cells; dungeon cells use authored per-cell ambient and must not be fought.
- Cascades must be camera-relative, bounded by the two-tier streaming
window (memory:
reference_two_tier_streaming), not a fixed world extent. - Depth bias will hit issue #129's bug class — an NDC-space bias constant
spans
~ b*d^2/nearmetres of eye depth at distance; #129 leaked door-shaped holes through hills. Memory:feedback_ndc_constants_eye_space_meaning. - 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. - 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
- Does retail smooth terrain vertex normals? (§4) — decides parity-gap vs enhancement.
- Who calls
SmartBox::SetDetailTexturing, and is landscape detail ever enabled in practice? (§2) - What is the non-single-pass detail fallback in
landPolyDraw? (§2) - What uses the
BumpMap/DotProduct3path, if not the detail textures? (§3) 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.