fix(render): #226 detail overlay uses retail's single-pass combine; drop the dead distance fade (Campaign VM VM1)

VM2's live cdb read against the PDB-paired retail client (GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) proved
m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware,
so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the
two-pass framebuffer blend the earlier #226 port reproduced. Every loaded
CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings
and EnvCells always take the single-pass texture-stage combine set up in
D3DPolyRender::SetSurface (0x0059c4d0):

    result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)

RenderMeshSubset lights opaque built-mesh subsets with
tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture
0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to
~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback
DstColor blend's brightening.

Also removes the invented 10 m / 50 m distance fade. Retail's
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 sampler's linear mip chain
converging to the texture mean, not a scripted ramp.

Changes:
- mesh_detail.vert/.frag: drop vDetailFade and its distance term; add
  vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7)
  read, and output detail.rgb with alpha = detail.a * vDetailOpacity under
  the corrected pipeline blend.
- VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail:
  SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha.
- RetailDetailTextureContract: replaced the distance-fade constants and
  FramebufferFactor with Expected(base, detail, opacity) and IsNeutral,
  matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops,
  the measured darkening on the live category texture, and full-alpha
  replacement.
- Regenerated mesh_detail's committed SPIR-V and the shader manifest
  (tools/compile-shaders.ps1); no other shader pair changed.
- Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the
  retired TS-52 divergence-register row corrected from the two-pass
  DESTCOLOR description to the single-pass path and the darkening
  expectation, each citing the VM2 cdb note.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic
lanes) both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-22 21:58:48 +02:00
parent 30e72a2af8
commit 059703066f
13 changed files with 266 additions and 128 deletions

View file

@ -9207,7 +9207,11 @@ the full 6,558-pass / 5-skip suite remains green.
## #226 — Retail building/EnvCell detail-texture overlay is not rendered ## #226 — Retail building/EnvCell detail-texture overlay is not rendered
**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED 2026-08-21 **Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED 2026-08-21; re-ported to
the single-pass path 2026-08-22 by Campaign VM VM1 after VM2's live cdb read
found retail hardware never takes the two-pass fallback this issue originally
ported (see
[`docs/research/2026-08-22-vm2-retail-detail-path-cdb.md`](research/2026-08-22-vm2-retail-detail-path-cdb.md)).
**Severity:** LOW **Severity:** LOW
**Filed:** 2026-07-20 **Filed:** 2026-07-20
**Component:** rendering / building and environment materials **Component:** rendering / building and environment materials
@ -9224,8 +9228,12 @@ mechanisms. `bb5acab9` ported the behavior that produced the observed mismatch:
`TexMerge::CopyAndTile`/`Merge` pass each source's authored base tiling into the `TexMerge::CopyAndTile`/`Merge` pass each source's authored base tiling into the
terrain composition. #226 now resolves Dereth category 1/2 detail surfaces, terrain composition. #226 now resolves Dereth category 1/2 detail surfaces,
uploads their authored texture/tiling with retail wrap/linear sampling, and uploads their authored texture/tiling with retail wrap/linear sampling, and
replays building and EnvCell built-mesh subsets with the exact replays building and EnvCell built-mesh subsets with retail's single-pass
`DESTCOLOR + INVSRCALPHA` blend. This includes opaque, ClipMap, straight-alpha, detail combine (VM1/VM2, 2026-08-22): `SRCALPHA + INVSRCALPHA` compositing
`lerp(base * diffuse, detail.rgb, detail.a * diffuseAlpha)`, matching the
`bCanDoSinglePassDetailing = 1` path real hardware runs — not the two-pass
`DESTCOLOR + INVSRCALPHA` fallback the port originally reproduced. This
includes opaque, ClipMap, straight-alpha,
additive, and inverse-alpha material subsets; transparent base/detail commands additive, and inverse-alpha material subsets; transparent base/detail commands
remain adjacent in acdream's authoritative shared alpha order with depth writes remain adjacent in acdream's authoritative shared alpha order with depth writes
disabled (retail bypasses delayed alpha while detail is installed; retaining disabled (retail bypasses delayed alpha while detail is installed; retaining
@ -9253,10 +9261,12 @@ its evidence source is
**Acceptance:** Toggling the existing “Building Detail Textures” checkbox **Acceptance:** Toggling the existing “Building Detail Textures” checkbox
visibly changes nearby building and interior surfaces without a restart. visibly changes nearby building and interior surfaces without a restart.
Enabled detail is full through 10 m positive view depth, fades linearly to an There is no distance fade (VM1/VM2: retail's `get_alpha_for_z` is unreachable
exact no-op at 50 m, and preserves retail's measured slight brightening. for built meshes; attenuation is the sampler's linear mip chain), and the
Disabling it submits no detail replay and preserves the already-accepted base single-pass combine darkens the live category texture by roughly 10% on
render. Landscape, ordinary objects, physics, and collision remain unchanged. mid-tones rather than brightening it. Disabling it submits no detail replay
and preserves the already-accepted base render. Landscape, ordinary objects,
physics, and collision remain unchanged.
The automated gates cover the setting gate, data/blend/fade contract, The automated gates cover the setting gate, data/blend/fade contract,
built-mesh subset eligibility, opaque command filtering and A2C coverage, built-mesh subset eligibility, opaque command filtering and A2C coverage,
transparent depth/order seam, Vulkan descriptor transparent depth/order seam, Vulkan descriptor

View file

@ -457,7 +457,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | | TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order |
| ~~TS-52~~ | **RETIRED 2026-08-21 (#226).** The row's landscape premise was wrong: the reachable Sept-2013 `ChangeRegion` caller passes zero LANDSCAPE detail surfaces and enables the building/environment categories. acdream now resolves those authored category detail textures and tiling, replays eligible building-shell and EnvCell subsets with retail's `DESTCOLOR + INVSRCALPHA` blend and 1050 m viewer-depth fade, and consumes the existing Building Detail Textures preference. The earlier experimental landscape array remains correctly reverted; there is no missing user-visible landscape pass to track. | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`; `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs`; `src/AcDream.App/Rendering/Shaders/mesh_detail.vert`; `src/AcDream.App/Rendering/Shaders/mesh_detail.frag` | Retired on measured caller/category evidence and the connected on/off/restored visual gate. | None; disabling the preference submits no detail replay, while enabling it visibly changes nearby building/EnvCell surfaces. Landscape remains unchanged, matching the reachable retail caller. | `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; `LScape::ChangeRegion`; `SmartBox::SetDetailTexturing`; issue #226 | | ~~TS-52~~ | **RETIRED 2026-08-21 (#226); re-ported 2026-08-22 (Campaign VM VM1/VM2).** The row's landscape premise was wrong: the reachable Sept-2013 `ChangeRegion` caller passes zero LANDSCAPE detail surfaces and enables the building/environment categories. acdream now resolves those authored category detail textures and tiling, and replays eligible building-shell and EnvCell subsets with retail's **single-pass** detail combine — `SRCALPHA + INVSRCALPHA` compositing `lerp(base·diffuse, detail.rgb, detail.a·diffuseAlpha)` — which VM2's live cdb read proved is the path real hardware runs (`m_caps.bCanDoSinglePassDetailing = 1`), not the two-pass `DESTCOLOR + INVSRCALPHA` fallback this row originally described. There is no viewer-depth fade: retail's `get_alpha_for_z` is unreachable for built meshes (`noFadeDetail = 1`); attenuation is the sampler's linear mip chain. acdream consumes the existing Building Detail Textures preference. The earlier experimental landscape array remains correctly reverted; there is no missing user-visible landscape pass to track. | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`; `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs`; `src/AcDream.App/Rendering/Shaders/mesh_detail.vert`; `src/AcDream.App/Rendering/Shaders/mesh_detail.frag` | Retired on measured caller/category evidence and the connected on/off/restored visual gate; blend/fade re-ported on VM2's live cdb evidence. | None; disabling the preference submits no detail replay, while enabling it visibly changes nearby building/EnvCell surfaces (a mild darkening, not the earlier-assumed brightening). Landscape remains unchanged, matching the reachable retail caller. | `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md`; `LScape::ChangeRegion`; `SmartBox::SetDetailTexturing`; issue #226 |
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 67 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | | TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 67 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
| ~~TS-54~~ | **RETIRED 2026-08-08 (Campaign A slice A4).** The AdminEnvirons stingers now play. `UiSoundController.PlayEnvironCue` maps the change type through `EnvironSoundCueMap` — an EXPLICIT table read case-by-case out of `CPlayerSystem::Handle_Admin__Environs` @ `0x0055DE20` (`0x0055E0C6..0x0055E2C7`), not an offset: codes `0x65..0x72` sit 0x11 below their SoundType but `0x73`/`0x74` have no case at all, so `0x75` lands on `UI_Squeal` (0x84) where arithmetic would give 0x86, and the switch ends at `0x7B`/`UI_Thunder6` with no `0x7C` case. All 21 cases are pinned by conformance tests. The bank itself is no longer a blocker either: the UI sound table's DID is resolved by walking the dats' EnumIDMap chain (`UiSoundTableResolver`, master → slot-7 map → `0x2000004B`), which is how retail finds it — `GetUISoundTable` holds no literal. | retired | — | — | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; `SoundManager::PlaySoundFromCenter @ 0x00550950`; `ClientUISystem::GetUISoundTable @ 0x00563FB0`; `docs/research/2026-08-08-audio-retail-music-absence.md` §5 | | ~~TS-54~~ | **RETIRED 2026-08-08 (Campaign A slice A4).** The AdminEnvirons stingers now play. `UiSoundController.PlayEnvironCue` maps the change type through `EnvironSoundCueMap` — an EXPLICIT table read case-by-case out of `CPlayerSystem::Handle_Admin__Environs` @ `0x0055DE20` (`0x0055E0C6..0x0055E2C7`), not an offset: codes `0x65..0x72` sit 0x11 below their SoundType but `0x73`/`0x74` have no case at all, so `0x75` lands on `UI_Squeal` (0x84) where arithmetic would give 0x86, and the switch ends at `0x7B`/`UI_Thunder6` with no `0x7C` case. All 21 cases are pinned by conformance tests. The bank itself is no longer a blocker either: the UI sound table's DID is resolved by walking the dats' EnumIDMap chain (`UiSoundTableResolver`, master → slot-7 map → `0x2000004B`), which is how retail finds it — `GetUISoundTable` holds no literal. | retired | — | — | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; `SoundManager::PlaySoundFromCenter @ 0x00550950`; `ClientUISystem::GetUISoundTable @ 0x00563FB0`; `docs/research/2026-08-08-audio-retail-music-absence.md` §5 |
| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) | | TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) |

View file

@ -1,7 +1,14 @@
# Retail building and environment detail texturing — #226 port note # Retail building and environment detail texturing — #226 port note
**Date:** 2026-08-21 **Date:** 2026-08-21 · **Amended:** 2026-08-22 (Campaign VM, VM1)
**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED **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 This note is the implementation handoff requested by #226. The measurements
below come from the already-completed below come from the already-completed
@ -59,11 +66,17 @@ 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 therefore uploads each live category as a one-layer RGBA8 texture array with a
full mip chain and the existing repeat/linear world sampler. full mip chain and the existing repeat/linear world sampler.
## Exact two-pass pseudocode ## Exact single-pass pseudocode
Retail has both a single-pass multitexture route and a two-pass fallback. The Retail has both a single-pass multitexture route and a two-pass framebuffer
Vulkan port uses the fallback because it preserves the already-accepted base fallback for adapters that cannot advertise `D3DTEXOPCAPS_PREMODULATE`.
pass byte-for-byte and expresses the retail framebuffer blend directly. [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 ```text
enabled = DisplaySettings.BuildingDetailTextures // existing setting; no new option enabled = DisplaySettings.BuildingDetailTextures // existing setting; no new option
@ -83,31 +96,47 @@ for each replayed fragment:
reject ordinary objects / landscape / scenery reject ordinary objects / landscape / scenery
accept opaque, ClipMap, alpha, additive and inverse-alpha subsets accept opaque, ClipMap, alpha, additive and inverse-alpha subsets
zMetres = positive_view_space_depth_in_metres // No distance term. ACRender::get_alpha_for_z (0x006b6230) is only
fade = clamp((50 m - zMetres) / (50 m - 10 m), 0, 1) // evaluated in D3DPolyRender::DrawPolyInternal (0x0059d7c0, the
// full through 10 m; linear 1050 m; exactly zero at/after 50 m // 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) detail = sample(categoryTexture, baseUv * categoryTiling)
src.rgb = detail.rgb * fade diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the
src.a = detail.a * fade // translucency-fade multiplier
// for a fading subset
// (tmpmaterial.Diffuse.a in
// RenderMeshSubset)
// 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 test = EQUAL opaque; LESS_OR_EQUAL transparent
depth write = preserve base class // ON opaque; OFF transparent depth write = preserve base class // ON opaque; OFF transparent
alpha-to-coverage = OFF // detail alpha is blend input alpha-to-coverage = OFF // detail alpha is blend input
blend op = ADD blend op = ADD
source = DEST_COLOR source = SRC_ALPHA
destination = ONE_MINUS_SRC_ALPHA destination = ONE_MINUS_SRC_ALPHA
``` ```
Scaling **both** RGB and alpha by the fade is load-bearing. The resulting The pixel this produces is `lerp(base * diffuse, detail.rgb, detail.a *
framebuffer multiplier is: diffuseAlpha)` — a blend **toward** the detail colour by
`detail.a * diffuseAlpha`:
```text ```text
factor = 1 + fade * (detail.rgb - detail.a) result = base * (1 - detail.a * diffuseAlpha) + detail.rgb * (detail.a * diffuseAlpha)
``` ```
Thus fade zero is an exact no-op and the full-strength neutral point is `detail.a * diffuseAlpha == 0` is an exact no-op (fully-transparent detail
`detail.rgb == detail.a` channel-by-channel. It is not 0.5 gray. 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 ### Built-mesh material coverage and order
@ -147,19 +176,22 @@ change base coverage/blend/depth behavior; it avoids making the checkbox
reorder the default transparent scene. The connected acceptance matrix must reorder the default transparent scene. The connected acceptance matrix must
still exercise overlapping transparent building/EnvCell surfaces. still exercise overlapping transparent building/EnvCell surfaces.
## Brightening decision ## Darkening, not brightening
The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` verbatim. The The earlier version of this note read the two-pass fallback's `DEST_COLOR +
findings measured factors **1.177**, **1.204**, and **1.033** for the three ONE_MINUS_SRC_ALPHA` as retail's blend and reported the findings doc's
Dereth textures; the live Dereth building/environment category uses the **1.177**, **1.204**, and **1.033** framebuffer-multiplier measurements for
1.033-factor texture. That slight brightening is intentional retail parity, the three Dereth textures as intentional retail brightening. VM2 showed that
not an acceptance failure. 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
Changing the destination factor to `ZERO` would be a visual correction rather Dereth building/environment category texture (mean rgb 0.165, mean alpha
than a port. Exposing both behaviors behind one retail checkbox would also 0.132) and opaque diffuse (`diffuseAlpha = 1`), the combine is
make the option ambiguous. If a roughening-corrected material is wanted later, `≈ 0.868 × base + 0.022` — a mild **darkening** of roughly 10% on mid-tones,
it belongs as an explicitly named opt-in enhancement/shader-pack policy with a not a brightening. This is retail's actual on-screen behavior on the
registered divergence. It is not part of #226. 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 ## What the reverted experiment got wrong
@ -201,10 +233,15 @@ current Vulkan-only RHI.
- Opaque object replay submits only command runs containing a building; mixed - Opaque object replay submits only command runs containing a building; mixed
commands are filtered per instance. Depth equality inherits the base pass's commands are filtered per instance. Depth equality inherits the base pass's
per-sample ClipMap coverage without applying A2C to detail alpha. per-sample ClipMap coverage without applying A2C to detail alpha.
- Detail is full through positive view depth 10 m, fades linearly over 1050 - There is no distance fade (VM1, VM2): `noFadeDetail` gates
m, and is an exact no-op at and beyond 50 m. `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 - Category source, 256 x 256 size, tiling 4, repeat addressing, and linear mip
sampling match the measured Dereth data. sampling match the measured Dereth data.
- The retail 1.033 live-category brightening is expected. There is no - The retail single-pass combine darkens the live category texture by
`dst=ZERO` correction mode hidden behind the retail checkbox. 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. - Physics, collision, walkability, and geometry are untouched.

View file

@ -137,10 +137,13 @@ internal enum GpuBlendMode
InverseAlpha, InverseAlpha,
/// <summary> /// <summary>
/// Retail building/EnvCell detail overlay: /// Retail building/EnvCell detail overlay: <c>SrcAlpha,
/// <c>DstColor, OneMinusSrcAlpha</c>. This intentionally preserves the /// OneMinusSrcAlpha</c>. VM2's live cdb read proved retail hardware runs
/// retail client's measured brightening; it is not a conventional /// the single-pass texture-stage combine (<c>D3DPolyRender::SetSurface</c>
/// modulate/roughening blend. /// 0x0059c4d0), a lerp toward the detail colour by
/// <c>detail.a * diffuse.a</c> — a mild darkening on the live Dereth
/// category texture, not the two-pass <c>DstColor</c> fallback's
/// brightening.
/// </summary> /// </summary>
RetailDetail, RetailDetail,
} }

View file

@ -175,9 +175,12 @@ internal static class VulkanViewportMapping
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
// ACRender::SetDetailSurfaceInternal with DrawBuilding/DrawEnvCell's // VM2 (live cdb read, 2026-08-22): real hardware runs the single-pass
// category state: D3DBLEND_DESTCOLOR + D3DBLEND_INVSRCALPHA. // texture-stage detail combine, not the two-pass DESTCOLOR fallback.
GpuBlendMode.RetailDetail => (BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha), // D3DPolyRender::SetSurface's stage-1 BLENDCURRENTALPHA is a lerp
// toward the detail colour by detail.a * diffuse.a, which is the
// ordinary straight-alpha-over factor pair.
GpuBlendMode.RetailDetail => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero), GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero),
_ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."), _ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."),
}; };

View file

@ -5,15 +5,39 @@ namespace AcDream.App.Rendering;
/// <summary> /// <summary>
/// Testable CPU statement of retail's detail-pass gate and pixel math. The /// Testable CPU statement of retail's detail-pass gate and pixel math. The
/// production pixels are produced by <c>mesh_detail</c>; keeping these facts in /// production pixels are produced by <c>mesh_detail</c>; keeping these facts
/// one small contract makes the setting, distance units, neutral point, and /// in one small contract makes the setting and the combine independently
/// intentional brightening independently assertable without a GPU. /// assertable without a GPU.
///
/// <para>VM2 (2026-08-22, live cdb read on the PDB-paired retail client,
/// <c>docs/research/2026-08-22-vm2-retail-detail-path-cdb.md</c>) settled
/// which of retail's two detail paths real hardware runs. Retail's
/// <c>RenderDevice::render_device.m_caps.bCanDoSinglePassDetailing</c> reads
/// 1 and the file-static <c>trysinglepass</c> reads 1, so
/// <c>D3DPolyRender::RenderMeshSubset</c> (0x0059ca10) never falls back to
/// the two-pass framebuffer blend the earlier #226 port reproduced; it takes
/// the single-pass texture-stage combine set up in
/// <c>D3DPolyRender::SetSurface</c> (0x0059c4d0):
/// <c>lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)</c> — a blend
/// TOWARD the detail colour by <c>detail.a * diffuse.a</c>, not the
/// fallback's <c>dest * (detail.rgb + 1 - detail.a)</c>. Built meshes light
/// with <c>tmpmaterial.Diffuse.a = 1</c> for opaque subsets
/// (<c>RenderMeshSubset</c>), so on the live Dereth category texture (mean
/// rgb 0.165, mean alpha 0.132) the combine is a mild darkening
/// (&#8776; 0.868 * base + 0.022), the opposite sign of the fallback's
/// brightening.</para>
///
/// <para>There is no distance fade on this path. Retail's
/// <c>ACRender::get_alpha_for_z</c> (0x006b6230) is only evaluated in
/// <c>D3DPolyRender::DrawPolyInternal</c> (0x0059d7c0, the immediate-polygon
/// path) and only when the static <c>noFadeDetail</c> (0x00820e38,
/// initialised to 1) is 0. Every loaded <c>CGfxObj</c> sets
/// <c>use_built_mesh=1</c> (<c>CGfxObj::InitLoad</c> 0x005346b0), so buildings
/// and EnvCells never reach that function — their attenuation is the LINEAR
/// mip chain converging to the texture mean, not a scripted ramp.</para>
/// </summary> /// </summary>
internal static class RetailDetailTextureContract internal static class RetailDetailTextureContract
{ {
internal const float FullDetailDistanceMetres = 10f;
internal const float ZeroDetailDistanceMetres = 50f;
internal static bool ShouldRender( internal static bool ShouldRender(
bool settingEnabled, bool settingEnabled,
TerrainAtlas.RetailDetailTextureBinding binding) => TerrainAtlas.RetailDetailTextureBinding binding) =>
@ -29,19 +53,28 @@ internal static class RetailDetailTextureContract
internal static GpuCompareOp DetailDepthCompare(bool transparent) => internal static GpuCompareOp DetailDepthCompare(bool transparent) =>
transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal; transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal;
internal static float FadeForPositiveViewDepthMetres(float depthMetres) => /// <summary>
Math.Clamp( /// The exact pixel <c>mesh_detail</c> composites onto the existing
(ZeroDetailDistanceMetres - depthMetres) /// framebuffer colour: retail's single-pass stage-1
/ (ZeroDetailDistanceMetres - FullDetailDistanceMetres), /// <c>BLENDCURRENTALPHA(TEXTURE, CURRENT)</c>, a lerp from
0f, /// <paramref name="baseColour"/> toward <paramref name="detail"/>'s RGB by
1f); /// <c>detail.a * opacity</c>. <paramref name="opacity"/> is the base
/// subset's diffuse alpha — 1 for an opaque subset, the translucency-fade
/// multiplier for a fading one — mirrored from the shader's
/// <c>instanceAlpha[instanceIndex]</c> read.
/// </summary>
internal static Vector3 Expected(Vector3 baseColour, Vector4 detail, float opacity) =>
Vector3.Lerp(
baseColour,
new Vector3(detail.X, detail.Y, detail.Z),
detail.W * opacity);
/// <summary> /// <summary>
/// Effective multiplier on the existing framebuffer after the shader /// True when the combine above is an exact no-op — either the detail
/// scales both detail RGB and alpha by fade and the pipeline applies /// texel is fully transparent or the base subset's own diffuse alpha (the
/// <c>DstColor + OneMinusSrcAlpha</c>. /// translucency fade) has reached zero. Neutral is <c>detail.a * opacity
/// == 0</c>, not any particular colour equality.
/// </summary> /// </summary>
internal static Vector3 FramebufferFactor(Vector4 detail, float fade) => internal static bool IsNeutral(Vector4 detail, float opacity) =>
Vector3.One + fade * (new Vector3(detail.X, detail.Y, detail.Z) detail.W * opacity == 0f;
- new Vector3(detail.W));
} }

View file

@ -3,7 +3,7 @@
in vec2 vBaseUv; in vec2 vBaseUv;
in vec2 vDetailUv; in vec2 vDetailUv;
in float vDetailFade; in float vDetailOpacity;
in flat uint vBaseTextureIndex; in flat uint vBaseTextureIndex;
in flat uint vBaseTextureLayer; in flat uint vBaseTextureLayer;
in flat uint vBatchFlags; in flat uint vBatchFlags;
@ -13,6 +13,31 @@ uniform uint uTextureIndexA; // category detail texture, layer 0
out vec4 FragColor; out vec4 FragColor;
// VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID
// 9e847e2f-777c-4bd9-886c-22256bb87f32): retail's RenderDevice reports
// m_caps.bCanDoSinglePassDetailing = 1 and the file-static trysinglepass = 1,
// so real hardware never takes the two-pass framebuffer fallback this shader
// used to reproduce. It takes the single-pass texture-stage combine set up
// in D3DPolyRender::SetSurface (0x0059c4d0) and consumed per built-mesh
// material subset by D3DPolyRender::RenderMeshSubset (0x0059ca10):
//
// stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
// stage 0 alpha = PREMODULATE(DIFFUSE, DIFFUSE) = diffuse.a * detail.a
// stage 1 colour = BLENDCURRENTALPHA(TEXTURE, CURRENT) = lerp(current.rgb, detail.rgb, stage0.a)
//
// i.e. the pixel retail draws is lerp(base * diffuse, detail.rgb, detail.a *
// diffuse.a) — a blend TOWARD the detail colour by detail.a * diffuse.a, not
// a "dest * (detail + 1 - alpha)" brightening. RenderMeshSubset lights every
// built mesh with tmpmaterial.Diffuse.a = 1 (opaque subsets), so
// vDetailOpacity below is exactly that diffuse.a — 1 for opaque, the
// translucency-fade multiplier for a fading subset. The pipeline blend
// (VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)) expresses
// the same lerp as SrcAlpha + OneMinusSrcAlpha over the existing base pixel,
// so this fragment outputs the raw (non-premultiplied) detail colour with
// that combined alpha and lets the fixed-function blend unit do the
// base*(1-a) + detail*a lerp. See
// docs/research/2026-08-22-vm2-retail-detail-path-cdb.md.
void main() { void main() {
// Object command replays may contain ordinary instances; only building // Object command replays may contain ordinary instances; only building
// shells survive. Bit 0 means this command came through retail's built-mesh // shells survive. Bit 0 means this command came through retail's built-mesh
@ -20,8 +45,6 @@ void main() {
// curr_detail_surface for every built-mesh material subset. // curr_detail_surface for every built-mesh material subset.
if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u) if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u)
discard; discard;
if (vDetailFade <= 0.0)
discard;
vec4 base = ACDREAM_SAMPLE_ARRAY( vec4 base = ACDREAM_SAMPLE_ARRAY(
vBaseTextureIndex, vBaseTextureIndex,
@ -33,10 +56,5 @@ void main() {
uTextureIndexA, uTextureIndexA,
vec3(vDetailUv, 0.0)); vec3(vDetailUv, 0.0));
// Pipeline blend is retail's DstColor + OneMinusSrcAlpha. Scaling both FragColor = vec4(detail.rgb, detail.a * vDetailOpacity);
// source colour and alpha makes fade=0 exactly neutral while fade=1 keeps
// retail's measured factor: dest * (detail.rgb + 1 - detail.a).
FragColor = vec4(
detail.rgb * vDetailFade,
detail.a * vDetailFade);
} }

View file

@ -44,6 +44,18 @@ layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[]; uint instanceDetailCategory[];
}; };
// #188 per-instance opacity multiplier, identical binding and indexing to
// mesh_modern.vert's InstanceAlphaBuf (binding 7). VM2's cdb read proved
// retail's built-mesh detail combine is a single-pass texture-stage blend
// whose stage-0 alpha is PREMODULATE(DIFFUSE, DIFFUSE) = diffuse.a *
// detail.a (D3DPolyRender::SetSurface 0x0059c4d0) — the base subset's own
// diffuse alpha gates how much detail shows through, exactly like the base
// pass's translucency-fade multiplier already does for mesh_modern. 1.0 for
// every opaque subset; <1.0 while a TransparentPartHook fade is in flight.
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
float instanceAlpha[];
};
out gl_PerVertex { out gl_PerVertex {
vec4 gl_Position; vec4 gl_Position;
float gl_ClipDistance[8]; float gl_ClipDistance[8];
@ -57,7 +69,7 @@ uniform float uParamB; // 1 = require building instance, 0 = EnvCell category
out vec2 vBaseUv; out vec2 vBaseUv;
out vec2 vDetailUv; out vec2 vDetailUv;
out float vDetailFade; out float vDetailOpacity;
out flat uint vBaseTextureIndex; out flat uint vBaseTextureIndex;
out flat uint vBaseTextureLayer; out flat uint vBaseTextureLayer;
out flat uint vBatchFlags; out flat uint vBatchFlags;
@ -76,11 +88,14 @@ void main() {
for (uint i = clip.count; i < 8u; ++i) for (uint i = clip.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0; gl_ClipDistance[i] = 1.0;
// System.Numerics' perspective projection used by every gameplay camera // No distance term: VM2 found the fade only exists in
// makes clip.w the positive view-space depth. Retail get_alpha_for_z uses // D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon
// that same metric in metres: 255 through 10 m, linearly to 0 at 50 m. // path) and only when the static noFadeDetail (0x00820e38, initialised
float positiveViewDepthMetres = gl_Position.w; // to 1) is 0. Every loaded CGfxObj sets use_built_mesh=1
vDetailFade = clamp((50.0 - positiveViewDepthMetres) / 40.0, 0.0, 1.0); // (CGfxObj::InitLoad 0x005346b0), so buildings/EnvCells never reach that
// function; their attenuation is the LINEAR mip chain converging to the
// texture mean, which the existing sampler already provides.
vDetailOpacity = instanceAlpha[instanceIndex];
vBaseUv = aTexCoord; vBaseUv = aTexCoord;
vDetailUv = aTexCoord * uParamA; vDetailUv = aTexCoord * uParamA;

View file

@ -231,12 +231,12 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "0273312da9fefb5084d3aee120f33c542e1adcfb846e6c7b3fc2b068c1348fb3", "sourceSha256": "c955bdce56199ef2057dbaf0dc1d1f75ececac49047f57685b09b63117e921da",
"compiled": true "compiled": true
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "1fbc3ffb12d260cbe0d111551f28c69f15754ba5e5e11fe3ec43747bebef4883", "sourceSha256": "c02dd48647352a87c183fe925a9590b70731c677dd942bcea71ac6ad63ff486d",
"compiled": true "compiled": true
} }
] ]

View file

@ -146,8 +146,11 @@ public sealed class VulkanViewportMappingTests
Assert.Equal( Assert.Equal(
(BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha)); VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha));
// VM2 (2026-08-22): real hardware runs the single-pass detail combine,
// a lerp expressed as the ordinary straight-alpha-over factor pair —
// not the two-pass DstColor fallback.
Assert.Equal( Assert.Equal(
(BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha), (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)); VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail));
} }

View file

@ -34,57 +34,73 @@ public sealed class RetailDetailTextureContractTests
} }
[Theory] [Theory]
[InlineData(0f, 1f)] [InlineData(0.1f, 0.2f, 0.3f)]
[InlineData(10f, 1f)] [InlineData(0.0f, 0.0f, 0.0f)]
[InlineData(30f, 0.5f)] [InlineData(1.0f, 1.0f, 1.0f)]
[InlineData(50f, 0f)] [InlineData(0.5f, 0.9f, 0.05f)]
[InlineData(80f, 0f)] public void ZeroDetailAlphaIsExactNoOp(float r, float g, float b)
public void DistanceFadeUsesPositiveViewDepthInMetres(
float depthMetres,
float expected)
{ {
var baseColour = new Vector3(r, g, b);
var transparentDetail = new Vector4(0.9f, 0.1f, 0.7f, 0f);
Assert.Equal( Assert.Equal(
expected, baseColour,
RetailDetailTextureContract.FadeForPositiveViewDepthMetres(depthMetres), RetailDetailTextureContract.Expected(baseColour, transparentDetail, opacity: 1f));
precision: 5); Assert.True(RetailDetailTextureContract.IsNeutral(transparentDetail, opacity: 1f));
} }
[Fact] [Fact]
public void FadeZeroIsExactNoOpAndNeutralRgbEqualsAlpha() public void ZeroOpacityIsExactNoOp()
{
var brighteningSample = new Vector4(0.459f, 0.459f, 0.459f, 0.282f);
Assert.Equal(
Vector3.One,
RetailDetailTextureContract.FramebufferFactor(brighteningSample, fade: 0f));
var neutral = new Vector4(0.4f, 0.4f, 0.4f, 0.4f);
Vector3 neutralFactor = RetailDetailTextureContract.FramebufferFactor(neutral, fade: 1f);
Assert.Equal(1f, neutralFactor.X, precision: 5);
Assert.Equal(1f, neutralFactor.Y, precision: 5);
Assert.Equal(1f, neutralFactor.Z, precision: 5);
}
[Fact]
public void RetailBlendPreservesMeasuredBrightening()
{
var measured = new Vector4(0.459f, 0.459f, 0.459f, 0.282f);
Vector3 factor = RetailDetailTextureContract.FramebufferFactor(measured, fade: 1f);
Assert.Equal(1.177f, factor.X, precision: 5);
Assert.Equal(1.177f, factor.Y, precision: 5);
Assert.Equal(1.177f, factor.Z, precision: 5);
}
[Fact]
public void EnabledDerethCategoryTextureKeepsItsMeasuredBrightening()
{ {
var baseColour = new Vector3(0.5f, 0.5f, 0.5f);
var derethCategory = new Vector4(0.165f, 0.165f, 0.165f, 0.132f); var derethCategory = new Vector4(0.165f, 0.165f, 0.165f, 0.132f);
Vector3 factor = RetailDetailTextureContract.FramebufferFactor(
derethCategory,
fade: 1f);
Assert.Equal(1.033f, factor.X, precision: 5); // opacity 0 models a translucency fade that has finished disappearing:
Assert.Equal(1.033f, factor.Y, precision: 5); // even a fully-opaque detail texel contributes nothing.
Assert.Equal(1.033f, factor.Z, precision: 5); Assert.Equal(
baseColour,
RetailDetailTextureContract.Expected(baseColour, derethCategory, opacity: 0f));
Assert.True(RetailDetailTextureContract.IsNeutral(derethCategory, opacity: 0f));
}
[Fact]
public void LiveDerethCategoryTextureDarkensMidtones()
{
// Live category texture 0x06006D58 (VM2), opaque diffuse (opacity 1).
var detail = new Vector4(0.165f, 0.165f, 0.161f, 0.132f);
var baseColour = new Vector3(0.5f, 0.5f, 0.5f);
Vector3 result = RetailDetailTextureContract.Expected(baseColour, detail, opacity: 1f);
Assert.True(result.X < baseColour.X);
Assert.True(result.Y < baseColour.Y);
Assert.True(result.Z < baseColour.Z);
// lerp(base, detail.rgb, 0.132) ~= 0.868 * base + 0.0218 per channel —
// the mild darkening VM2 measured, not the two-pass fallback's
// brightening. Compared as an absolute difference (not xunit's
// decimal-rounding `precision` parameter, which would flip a channel
// sitting on a rounding boundary) against the stated 1e-3 tolerance.
float approx = 0.868f * baseColour.X + 0.0218f; // same for every channel: baseColour is uniform
Assert.True(MathF.Abs(result.X - approx) < 1e-3f, $"R off by {result.X - approx}");
Assert.True(MathF.Abs(result.Y - approx) < 1e-3f, $"G off by {result.Y - approx}");
Assert.True(MathF.Abs(result.Z - approx) < 1e-3f, $"B off by {result.Z - approx}");
Assert.False(RetailDetailTextureContract.IsNeutral(detail, opacity: 1f));
}
[Fact]
public void FullAlphaReplacesBaseWithDetailColour()
{
var baseColour = new Vector3(0.9f, 0.1f, 0.4f);
var detail = new Vector4(0.2f, 0.3f, 0.4f, 1f);
Vector3 result = RetailDetailTextureContract.Expected(baseColour, detail, opacity: 1f);
// Tolerance rather than bit-exact equality: Lerp's `a + (b - a) * t`
// form is not guaranteed to cancel `a` perfectly at t=1 in floating
// point for arbitrary inputs.
Assert.True(MathF.Abs(result.X - detail.X) < 1e-6f);
Assert.True(MathF.Abs(result.Y - detail.Y) < 1e-6f);
Assert.True(MathF.Abs(result.Z - detail.Z) < 1e-6f);
} }
} }