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:
parent
30e72a2af8
commit
059703066f
13 changed files with 266 additions and 128 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 10–50 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 6–7 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 6–7 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`) |
|
||||||
|
|
|
||||||
|
|
@ -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 10–50 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 10–50
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
/// (≈ 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));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue