diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 70ac1377..fd4ad29b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). | IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | | IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` | | IA-24 | **Filed 2026-08-22, Campaign AR; amended 2026-09-04, Campaign OVERHAUL S5-c2 and its graphical closeout.** An explicitly selected atmospheric render pack adds cascaded real-time directional shadows from terrain, trees, buildings, players, monsters, and other retained outdoor casters. The one shadow direction follows the visible authored sun, then the dominant haloed moon (`0x01001F6A`), then the secondary moon (`0x01001F67`); a moon supplies direction only while colour/energy remains retail's single interpolated `SkyTimeOfDay.DirColor × DirBright` channel. Its active terrain/object projection borrows retail's **prior successfully completed** landscape visibility by exact set reference, then selects ordinary objects by S2's retained retail CELLARRAY intersection and building shells by their outdoor placement `EffectCellId`; missing membership and no completed view fail closed, with no resident/frustum fallback. **While shadows render, the pack's receiver vertex shaders (`mesh_atmospheric.vert`, `terrain_atmospheric.vert`) also take the outdoor directional LIGHTING direction from that same celestial source instead of retail's authored `uLights[0]` direction, so the lit term agrees with the shadow direction; whenever the shadow gate is closed (night, user strength 0, portal cover, indoor) the flag bit is clear and both shaders fall back to the plain pipeline's authored-light expression (Campaign VM VM6 round 5, `754d59d9`). The switch is binary even when the effective shadow strength is small: S5-c2's exact-parent/candidate graphical A/B (§16) reproduces a conspicuous whole-world darkening across pack re-enable. Issue #469 keeps that pre-existing gate discontinuity open before G4; recording it here does not classify it as an S5-c2 regression.** Retail renders none of these real-time object-shadow maps and does not expose a second moon light. | `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`; `src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs`; `src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` and OVERHAUL packet §§14,16 | This is the user-requested headline graphics enhancement and is strictly opt-in. Borrowing the prior completed retail landscape product and exact S2 membership narrows pack-on work without inventing another visibility owner. The retail path remains the default and authoritative fallback; pack-off does not build, select, upload, or draw shadow work and does not change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail and deliberately trails camera visibility by one successfully completed world view. A wrong CELLARRAY/EffectCell identity, stale prior-view transaction, celestial transform, or unresolved #469 gate transition would visibly add, omit, misalign, or abruptly relight the world; pack-off output changing would violate the campaign's primary safety contract. | Landscape input: `LScape::draw_check_blocks @0x00505F80`, `LScape::landcell_check @0x005050A0`, `CLandCell::IsInView @0x00532CB0`; celestial: `SkyDesc::GetLighting @0x00500A80`, `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research notes | -| IA-25 | **Filed 2026-08-22, Campaign VM VM6.** An explicitly selected atmospheric render pack sways procedural-scenery foliage (trees/bushes — entity ids in the `0x8XXYYIII` `ProceduralSceneryIdAllocator` namespace) in `mesh_atmospheric.vert` and the four `directional_shadow_world_*` caster vertex shaders, driven by a weather-table lean/branch/flutter vertex displacement (`foliage_wind.glsl`, `FoliageWindModel` CPU mirror) whose mean/gust strength is looked up per DAT-classified `AcDream.Core.World.WeatherKind` (Clear/Overcast/Rain/Snow/Storm — the same classification `WeatherState.cs` already derives from the active day group's authored name, not the day group's raw index, which carries no weather meaning by itself) and eases toward its target over `WeatherSystem.TransitionSeconds` (10 s) so a weather change never snaps. Retail's fixed-function renderer applies no per-vertex wind displacement to any scenery mesh — Dereth's trees are static geometry. Wind direction (`wind-direction-degrees`, default 225°) is a plain pack default: there is no authored retail wind direction to read (no wind data exists in retail at all). Render-only: `WorldPicker` picks the undisplaced mesh, so a swaying leaf can be up to `lean + branch` metres from its pick volume at the moment of a click; foliage subsets are cosmetic scenery, not interactable in retail either. | `src/AcDream.App/Rendering/Shaders/foliage_wind.glsl`; `src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs`; `src/AcDream.App/Rendering/Packs/FoliageWindModel.cs`; `src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs` (`ResolveFoliageWind`); `src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs` (wind settings + `FoliageWindByWeather`) | Explicitly opt-in graphics enhancement — the retail path (`mesh_modern`, `terrain_modern`, `mesh_detail`) never reads `BatchData.flags` bits 1/2 and is pixel-identical with the pack off. The classification never touches Runtime/Core physics — the collision BSP is the trunk, and picking against the undisplaced mesh has no gameplay consequence since foliage is not interactable. | Pack-on output intentionally differs from retail (moving foliage where retail has none). A wrong classification bit would sway a non-foliage object or leave a real tree still; a caster/receiver clock or amplitude mismatch would visibly misalign a leaf's shadow from the leaf itself. Pack-off output changing would violate the campaign's primary safety contract. | None — retail applies no vertex wind displacement to any geometry; `ProceduralSceneryIdAllocator` (top-nibble-0x8 entity-id namespace, existing acdream mechanism, not retail) | +| IA-25 | **Filed 2026-08-22, Campaign VM VM6.** An explicitly selected atmospheric render pack sways procedural-scenery foliage (trees/bushes — entity ids in the `0x8XXYYIII` `ProceduralSceneryIdAllocator` namespace) in `mesh_atmospheric.vert` and the four `directional_shadow_world_*` caster vertex shaders, driven by a weather-table lean/branch/flutter vertex displacement (`foliage_wind.glsl`, `FoliageWindModel` CPU mirror) whose mean/gust strength is looked up per DAT-classified `AcDream.Core.World.WeatherKind` (Clear/Overcast/Rain/Snow/Storm — the same classification `WeatherState.cs` already derives from the active day group's authored name, not the day group's raw index, which carries no weather meaning by itself) and eases toward its target over `WeatherSystem.TransitionSeconds` (10 s) so a weather change never snaps. Retail's fixed-function renderer applies no per-vertex wind displacement to any scenery mesh — Dereth's trees are static geometry. Wind direction (`wind-direction-degrees`, default 225°) is a plain pack default: there is no authored retail wind direction to read (no wind data exists in retail at all). Render-only: `WorldPicker` picks the undisplaced mesh, so a swaying leaf can be up to `lean + branch` metres from its pick volume at the moment of a click; foliage subsets are cosmetic scenery, not interactable in retail either. | `src/AcDream.App/Rendering/Shaders/foliage_wind.glsl`; `src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs`; `src/AcDream.App/Rendering/Packs/FoliageWindModel.cs`; `src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs` (`ResolveFoliageWind`); `src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs` (wind settings + `FoliageWindByWeather`) | Explicitly opt-in graphics enhancement — the retail path (`mesh_modern`, `terrain_modern`) never reads `BatchData.flags` bits 1/2 and is pixel-identical with the pack off. The classification never touches Runtime/Core physics — the collision BSP is the trunk, and picking against the undisplaced mesh has no gameplay consequence since foliage is not interactable. | Pack-on output intentionally differs from retail (moving foliage where retail has none). A wrong classification bit would sway a non-foliage object or leave a real tree still; a caster/receiver clock or amplitude mismatch would visibly misalign a leaf's shadow from the leaf itself. Pack-off output changing would violate the campaign's primary safety contract. | None — retail applies no vertex wind displacement to any geometry; `ProceduralSceneryIdAllocator` (top-nibble-0x8 entity-id namespace, existing acdream mechanism, not retail) | --- @@ -146,7 +146,7 @@ readiness/requeue adaptation. See | AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB | | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | | AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameBuildingSource.Gather`) | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 | -| AD-17 | **REWRITTEN 2026-09-03 at Campaign OVERHAUL v2 S3 chunk 4 (rounds `ff607a1e0` + `7df0b94c9`).** Only two GPU clips remain, both retail `PView::DrawCells` portal-polygon draws: the exit seals and the punch fans. Each carries ≤8 `gl_ClipDistance` half-planes per view region (`ClipPlaneSet.MaxPlanes`; `Render::copy_view` caps a view at 31 vertices). Overflow handling differs by consumer: a >8-edge exit-seal view uses its convex NDC AABB as a conservative four-plane gate (`WalkFrameDriver.AppendClipSlot`); a >8-edge or multi-polygon OUTSIDE view yields a zero-plane slice (`ClipFrameAssembler.AppendOutsideSlice`'s fallback: slot 0, empty `Planes`, `ScissorFallbacks` counted) and that view's punch fan draws UNCLIPPED. Retail CPU-clips its portal polygons exactly. The former union-AABB scissor fallback, the sky's doorway scissor bracket, the per-slice terrain/sky/weather clips, the TerrainClip UBO and `NdcScissorRect` are DELETED: sky, terrain and weather are never GPU-clipped, matching retail (`LScape::draw` installs no view before any of them). The per-cell clip machinery for ordinary parts and particles is DELETED (S3 review fix round 1, `6575cfcee`): `mesh_modern`, `mesh_atmospheric`, `mesh_detail`, `particle` and `particle_mesh` no longer declare the CellClip SSBO, the `gl_ClipDistance` loop or the `gl_PerVertex` redeclaration, and `WbDrawDispatcher`'s routing state (`SetClipRouting`, `_clipRoutingActive`, `ResolveSlotForFrame`'s routing arm — which had ZERO callers) is gone; every ordinary part is admitted by the walk's Boolean sphere test (`WbDrawDispatcher.WalkClassify.ResolvePartVisible`), matching retail's `DrawMeshInternal` @0x0059f360, and every particle draws unclipped. Residue named for the S5 cleanup inventory: the vertex layouts' `aClipSlot`/`instanceClipSlot` inputs (fed 0) and the CPU-side `ClipRegions` byte-pack + SSBO publish in `RhiWorldPassSurface.PrepareClipFrame` (no GPU reader; `ClipFrame.AppendSlot`/`GetSlotPlanes` stay as the CPU plane store the exit seals read). | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23`; `src/AcDream.App/Rendering/ClipFrameAssembler.cs` (`AppendOutsideSlice`); `src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs` (`AppendClipSlot`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs` (`DrawWalkPunchFan` reads `OutsideViewSlices[i].Planes`) | Vulkan's `VkPhysicalDeviceLimits::maxClipDistances` floor is 8; over-inclusion is the safe direction for a depth-only seal/punch (it can cover at most the whole aperture fan), under-inclusion is the bug class | A >8-edge exit-seal view seals its AABB instead of the exact polygon and a >8-edge outside view punches its whole fan — depth-only over-include with no color bleed (the former **#130** strip family died with the scissor); an interior root's aperture exactness now rests on the depth clear + exit seals + interior repaint, never on a GPU clip of the landscape | `ACRender::polyClipFinish` decomp:702749; `PView::DrawCells` 0x005a4840 portal_view slices; `Render::copy_view` 0x0054dfc0; `LScape::draw` 0x00506330 | +| AD-17 | **REWRITTEN 2026-09-03 at Campaign OVERHAUL v2 S3 chunk 4 (rounds `ff607a1e0` + `7df0b94c9`).** Only two GPU clips remain, both retail `PView::DrawCells` portal-polygon draws: the exit seals and the punch fans. Each carries ≤8 `gl_ClipDistance` half-planes per view region (`ClipPlaneSet.MaxPlanes`; `Render::copy_view` caps a view at 31 vertices). Overflow handling differs by consumer: a >8-edge exit-seal view uses its convex NDC AABB as a conservative four-plane gate (`WalkFrameDriver.AppendClipSlot`); a >8-edge or multi-polygon OUTSIDE view yields a zero-plane slice (`ClipFrameAssembler.AppendOutsideSlice`'s fallback: slot 0, empty `Planes`, `ScissorFallbacks` counted) and that view's punch fan draws UNCLIPPED. Retail CPU-clips its portal polygons exactly. The former union-AABB scissor fallback, the sky's doorway scissor bracket, the per-slice terrain/sky/weather clips, the TerrainClip UBO and `NdcScissorRect` are DELETED: sky, terrain and weather are never GPU-clipped, matching retail (`LScape::draw` installs no view before any of them). The per-cell clip machinery for ordinary parts and particles is DELETED (S3 review fix round 1, `6575cfcee`): `mesh_modern`, `mesh_atmospheric`, `particle` and `particle_mesh` no longer declare the CellClip SSBO, the `gl_ClipDistance` loop or the `gl_PerVertex` redeclaration, and `WbDrawDispatcher`'s routing state (`SetClipRouting`, `_clipRoutingActive`, `ResolveSlotForFrame`'s routing arm — which had ZERO callers) is gone; every ordinary part is admitted by the walk's Boolean sphere test (`WbDrawDispatcher.WalkClassify.ResolvePartVisible`), matching retail's `DrawMeshInternal` @0x0059f360, and every particle draws unclipped. Residue named for the S5 cleanup inventory: the vertex layouts' `aClipSlot`/`instanceClipSlot` inputs (fed 0) and the CPU-side `ClipRegions` byte-pack + SSBO publish in `RhiWorldPassSurface.PrepareClipFrame` (no GPU reader; `ClipFrame.AppendSlot`/`GetSlotPlanes` stay as the CPU plane store the exit seals read). | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23`; `src/AcDream.App/Rendering/ClipFrameAssembler.cs` (`AppendOutsideSlice`); `src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs` (`AppendClipSlot`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs` (`DrawWalkPunchFan` reads `OutsideViewSlices[i].Planes`) | Vulkan's `VkPhysicalDeviceLimits::maxClipDistances` floor is 8; over-inclusion is the safe direction for a depth-only seal/punch (it can cover at most the whole aperture fan), under-inclusion is the bug class | A >8-edge exit-seal view seals its AABB instead of the exact polygon and a >8-edge outside view punches its whole fan — depth-only over-include with no color bleed (the former **#130** strip family died with the scissor); an interior root's aperture exactness now rests on the depth clear + exit seals + interior repaint, never on a GPU clip of the landscape | `ACRender::polyClipFinish` decomp:702749; `PView::DrawCells` 0x005a4840 portal_view slices; `Render::copy_view` 0x0054dfc0; `LScape::draw` 0x00506330 | | AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) | | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | @@ -220,7 +220,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 161 active rows (AP-117 retired 2026-09-04 at Campaign OVERHAUL S5-c1; AP-241/AP-242/AP-243 filed 2026-09-04 at S4-c3a; recounted honestly 2026-09-04 at the S4-c2 fix round 1 — the prior "161" was stale; AP-240 filed 2026-09-04 at the S4-c2 fix round 1 (M2) — clip-mapped GfxObj/scenery/building subsets draw immediately outside retail's CLIP FIFO, `WbDrawDispatcher.IsOpaque` filtering them out before they ever reach the alpha queue; AP-34 RETIRED 2026-09-04 at the S4-c2 fix round 1 — its residuals continue as the renumbered AP-238/AP-239 (both were filed as the colliding/dangling "AP-236"/"AP-237"/"AP-236b" ids the S4-c2 chunk 2 commit introduced; ids are never reused, so the fix round renumbered them and restored AP-34's own row as struck-through rather than deleted outright — see A2/M8c); AP-234 RETIRED 2026-09-02 at OH2/S1 chunk B — retail's exact CellStruct surface/subset construction (`CellStructSideCandidates`, source-surface-index subsets, and the built-EnvCell `(Surface.Type & 6) != 0` admission test) replaces the NoPos-based approximation, ported from `D3DPolyRender::ConstructMesh` @0x0059DFA0, `RenderDeviceD3D::DrawEnvCell` @0x0059F170, and `D3DPolyRender::DrawMesh` @0x0059D4A0 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), with chunk-A/B test evidence; AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 160 active rows (AP-117 retired 2026-09-04 at Campaign OVERHAUL S5-c1; AP-241/AP-242/AP-243 filed 2026-09-04 at S4-c3a; recounted honestly 2026-09-04 at the S4-c2 fix round 1 — the prior "161" was stale; AP-240 filed 2026-09-04 at the S4-c2 fix round 1 (M2) — clip-mapped GfxObj/scenery/building subsets draw immediately outside retail's CLIP FIFO, `WbDrawDispatcher.IsOpaque` filtering them out before they ever reach the alpha queue; AP-34 RETIRED 2026-09-04 at the S4-c2 fix round 1 — its residuals continue as the renumbered AP-238/AP-239 (both were filed as the colliding/dangling "AP-236"/"AP-237"/"AP-236b" ids the S4-c2 chunk 2 commit introduced; ids are never reused, so the fix round renumbered them and restored AP-34's own row as struck-through rather than deleted outright — see A2/M8c); AP-234 RETIRED 2026-09-02 at OH2/S1 chunk B — retail's exact CellStruct surface/subset construction (`CellStructSideCandidates`, source-surface-index subsets, and the built-EnvCell `(Surface.Type & 6) != 0` admission test) replaces the NoPos-based approximation, ported from `D3DPolyRender::ConstructMesh` @0x0059DFA0, `RenderDeviceD3D::DrawEnvCell` @0x0059F170, and `D3DPolyRender::DrawMesh` @0x0059D4A0 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), with chunk-A/B test evidence; AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 RETIRED 2026-09-04 at Campaign OVERHAUL S5-c4 — exact one-pass detail material with authored surface opacity and squared final alpha replaces the two-draw approximation; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) **2026-08-28 count correction:** #408 retired AP-230 by moving property `0x3B` visibility into the shared importer and every retained stateful widget; @@ -263,7 +263,7 @@ research and is no longer active. |---|---|---|---|---|---| | AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | -| AP-232 | **Filed 2026-08-22 at Campaign VM slice VM1 (the #226 single-pass re-port; deviation introduced at `05970306`, row owed since then).** Retail's single-pass detail combine produces ONE pixel per subset whose OUTPUT alpha is stage 1's `MODULATE(TEXTURE, CURRENT)` (`D3DPolyRender::SetSurface @0x0059c4d0`, op at `0x0059c549`) — for a delayed-alpha (translucent) subset that product is the framebuffer blend weight. acdream draws the base subset with its own alpha, then a second `mesh_detail` draw weighted by `detail.a * instanceOpacity` under `SRCALPHA + INVSRCALPHA`. For OPAQUE subsets (base alpha 1) the two compose to exactly `lerp(base, detail, detail.a*opacity)` and, with both draws fogged, to retail's fog-after-combine pixel (identity pinned by `RetailDetailTextureContractTests`). For TRANSLUCENT building/EnvCell subsets the destination after the base draw is `mix(behind, foggedBase, baseAlpha)`, not `foggedBase`, so the detail weight differs from retail's single product. | `src/AcDream.App/Rendering/Shaders/mesh_detail.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (transparent interleave); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (transparent interleave) | Opaque subsets are the overwhelming majority of building shells and interior walls and are exact; translucent detail-bearing subsets (ClipMap/alpha/additive/inverse-alpha glass and grates) get a bounded weight difference that never exceeds the detail texture's own alpha (mean 0.132 on the live Dereth category texture). Collapsing to one draw would require the base pipelines to sample the detail texture, i.e. a second `mesh_modern` variant on the retail path. | A translucent building/EnvCell surface with the detail preference on reads visibly different from retail against a bright background. Separate from AP-34 (queue ORDER); this row is about the blend WEIGHT. | `D3DPolyRender::SetSurface @0x0059c4d0` (stage table), `RenderMeshSubset @0x0059ca10`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md` | +| ~~AP-232~~ | **RETIRED 2026-09-04 at Campaign OVERHAUL S5-c4.** The acdream-only base-plus-detail replay is deleted. `TextureBatchData.SurfaceOpacity` carries exact `1-Surface.Translucency` from `MeshExtractor` through recipe-9 prepared serialization, `ObjectRenderBatch`, and the unchanged 16-byte GPU batch ABI. `mesh_modern` and `mesh_atmospheric` share `retail_detail_material.glsl` and emit one pre-fog fragment per detail-active subset: `w=a*qA`, `C=q*w+(B*D)*(1-w)`, `X=a*qA*qA`, where `a=authoredOpacity*liveFade`; base texture alpha is excluded. The final `X` drives pure CLIP at 100/255 or 200/255 with equality surviving. Classic grouped, walk-ordered (#471), immediate alpha, delayed alpha, and EnvCell paths keep their original draw position and pipeline family; the separate RetailDetail pipelines, replay helpers, shaders, SPIR-V, and manifest entry are gone. | `src/AcDream.Content/MeshExtractor.cs`; `src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs`; `src/AcDream.App/Rendering/Shaders/retail_detail_material.glsl`; `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`; `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` | Exact retail single-pass stage arithmetic, authored-alpha provenance, framebuffer-family/CLIP CPU fixtures, shader ABI pins, deterministic recipe-9 round trips and rejection of recipe 8, one-draw command transcripts, shader/SPIR-V validation, and 0-B warmed-path proof. | None for the former weight/draw-count deviation. AP-238/AP-239/AP-240 remain separate queue/token/raw-mask placement residuals and are not changed by this retirement. | `D3DPolyRender::SetSurface @0x0059C4D0`; `RenderMeshSubset @0x0059CA10`; `DrawMesh @0x0059D4A0`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md`; Campaign OVERHAUL S5-c4 §22 | | AP-231 | **Filed 2026-08-16 at the Campaign CC gate round 1 closeout, Group 2 (Skills page info-box completion).** `CharacterCreationSkillsPage.ComposeFormula` ports `gmCGSkillsPage::MakeSkillFormula @0x00480e10` with HIGH CONFIDENCE for the `"Formula : "` prefix, the per-attribute `"(%u x %s)"`-vs-bare-name choice (a term's own multiplier > 1 gets the parenthesized form, else just the attribute name), the `" / %u"` divisor suffix (gated on `Divisor != 1`), and the `" +%u"` additive-bonus suffix (gated on `AdditiveBonus != 0`) — every one of those is a directly-read compiled string literal or a field the DatReaderWriter binding already exposes by name (`SkillFormula`'s six fields map 1:1 onto the decompiled struct's own `_w/_x/_y/_z/_attr1/_attr2` offsets, confirmed by their exact 0x28/0x2c/0x30/0x34/0x38/0x3c stride). LOWER CONFIDENCE: the CONNECTOR text between a two-attribute formula's two terms. This port renders `" + "` — the well-known "(Attr1 + Attr2) / N" shape most published AC skill formulas use — but the decompiled function's own two candidate connector literals (`data_7a01a4`, appended between the terms; `data_797584`, appended again immediately after BOTH terms are present, an apparently redundant second literal whose exact role this session could not resolve) sit behind reference-counted `PStringBase` appends whose actual wide-character content Binary Ninja's HLIL does not surface as a literal — this session had no live cdb attach and no running Ghidra MCP instance to recover the raw bytes. A two-attribute skill's formula therefore renders as `"Formula : (2 x Strength) + Endurance / 4 +2"`-shaped text that is very likely retail-correct in STRUCTURE but not byte-verified. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`ComposeFormula`, `AppendAttributeTerm`) | The single-attribute majority of skills render byte-correct today; only the minority of two-attribute formulas carry the unverified connector, and the gap is disclosed in the method's own doc rather than silently guessed. | A live retail capture of a two-attribute skill's formula text (e.g. via the cdb toolchain) could reveal `" + "` is wrong — the actual connector might be `" and "`, `" / "` (an OR-style formula, common for some AC skills that use whichever attribute is higher), or something else the two unresolved literals encode; `data_797584`'s role (appended after both terms) is also unexplained and could indicate a THIRD text segment this port omits entirely. | `gmCGSkillsPage::MakeSkillFormula @0x00480e10`; `SkillFormula` struct (`acclient.h`) | | ~~AP-230~~ | **RETIRED 2026-08-28 by #408.** `LayoutImporter.BuildWidget` now applies every effective `P0x3B=true` as initial `Visible=false`; `UiDatElement`, `UiText`, and `UiButton` apply the committed named or DirectState value through their state machines; and the chargen-only walk is deleted. The installed-DAT sweep enumerates 1,019 resolved authored-invisible elements across 38 layouts, builds 990 after normal child consumption, and proves 990/990 start hidden. The complete automated App lane passes 6,511/6,511. | `src/AcDream.App/UI/Layout/LayoutImporter.cs`; `src/AcDream.App/UI/Layout/UiDatElement.cs`; `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/UiButton.cs`; `tests/AcDream.App.Tests/UI/Layout/LayoutImporterInvisibleSweepTests.cs` | The old scoped-vs-general implementation split no longer exists. | None for property 0x3B visibility; runtime controllers may intentionally issue later visibility changes exactly as retail does. | `UIElement::OnSetAttribute @0x00462d80` case 8; `UIElement::SetState @0x00464e70` | | AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. **Dialog-as-sibling addendum (F3, gate round 1 closeout, 2026-08-16):** the same flat-sibling-list mechanism that motivates this row ALSO covers `RetailDialogFactory`'s own open dialogs — a dialog's root is a direct sibling of the chargen/character-management screen roots under the SAME `Host.Root`, and `RetailWindowManager.BringToFront` is a simple "highest ZOrder among siblings + 1", so whichever sibling's own `BringToFront` call runs LAST in a frame wins z-order. This was GF-15's actual root cause (a dialog opened while chargen is active got buried the very next frame because the screen's own per-tick `BringToFront` ran after the dialog's one-time open-time raise) and is now closed by `RetailDialogFactory.Tick()` re-raising every open dialog, in `_openOrder`, every tick — but the underlying divergence (dialogs and screens sharing one z-order list at all, where retail's dialog layer is architecturally separate from `UIFlow`'s single current framework) remains; any FUTURE sibling that calls its own unconditional per-tick `BringToFront` could reintroduce the same failure class against a dialog OR against chargen itself. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | @@ -282,7 +282,7 @@ research and is no longer active. | AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | ~~AP-196~~ | **RETIRED 2026-08-11 at Campaign OP slice OP9.** Filed at the OP4 review-fix round (MUST-FIX 3/blast M2) recording that OP4's Group-C re-point deleted only three of the eight re-pointed `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`), leaving `VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun` behind as WRITE-BEHIND `settings.json` persistence/draft mirrors of the now-authoritative server bit (plus a "two writable copies" default-source change, ADDENDUM historical only). OP9 verified all remaining `GameplaySettings` members — those five plus `ShowTooltips`/`SideBySideVitals`/`SpellDuration`/`AllowGive`/`ShowHelm`/`ShowCloak`/`AdvancedCombatUI`/`UseMouseTurning`, 13 total — already had a live server-bit home in `RuntimeCharacterOptionsState` (11 as OP4 Character-tab rows through `CharacterOptionTable`/`CharacterOptionsPageController`; `LockUI` through `/lockui` + the PlayerDescription `SetUiLocked` convergence, deliberately not a Character-tab row; `UseMouseTurning` through the Gameplay-tab mouse-macro button + the Config tab's Use-Mouse-Turning row — OP9 review NIT 6's channel-attribution correction) and deleted the `GameplaySettings` record outright — the type, the `SettingsStore.LoadGameplay`/`SaveGameplay` plumbing, and `RuntimeSettingsController`'s `Gameplay` property/`SetAcceptLootPermits` write-behind method — closing the "two writable copies" gap for good: there is no longer a second store to diverge from server truth. | `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`; `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | — | — | `docs/research/2026-08-10-character-options-map.md` §7.1/§7.2 (Group C re-point directive); `CharacterOptionTable.cs` | | AP-197 | **Filed 2026-08-11 at the OP4 review-fix round (SF-1/S4).** "Display Timestamps" hardcodes retail's `PlayerModule` constructor-default format string `"%#H:%M:%S "` rather than reading the PER-CHARACTER override `GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)` carries when the wire's `GenericQualitiesData` string-key `1` is populated — acdream's `PlayerDescription` parser reads and discards that field (wire research doc: "timestamp string (`0x80`) \| read, discarded \| ❌ \| never sent"). | `src/AcDream.Core/Chat/ChatLog.cs` (`FormatTimestampPrefix`); parser site cited at `docs/research/2026-08-10-set-character-options-wire.md:647` | The 2013 client's own constructor default is the only format any fresh/default character would ever show — retail ships no options-panel control that authors a custom one — so hardcoding the one value every real player sees is a safe, honest approximation until a consumer needs the per-character override. | A character whose account somehow carries a non-default persisted timestamp format (a modded/legacy server, or a hypothetical later retail patch exposing a UI for it) sees acdream render the DEFAULT format instead of their stored one — cosmetic only (still a valid H:MM:SS-shaped timestamp), never a wire or data-loss risk. | `PlayerModule::PlayerModule @0x005D51F0` (ctor default literal); `GenericQualitiesData::InqString` call site (wire doc §3.3); `docs/research/2026-08-10-set-character-options-wire.md` U6 | -| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2); NARROWED 2026-08-21 by #226; NARROWED 2026-09-04 at Campaign OVERHAUL v2 S5-c3; CORRECTED 2026-09-04 at S5-c3 fix round 1.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author five rows that still have no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, and `Render_MultiPassAlpha`. #226 removed `Render_BuildingDetailTextures` from this row: the existing checkbox now directly gates the retail building/EnvCell detail replay and is no longer caption-dimmed as store-only. S5-c3 removes `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, and `Render_DegradeDistance`: they now feed the single renderer-lifetime retail building-degrade controller and visibly change selected building shells. `Render_LandscapeDrawDistance` was already live before S5-c3 through `RuntimeSettingsController.ApplyLandscapeDrawDistance`, which writes the aggregate quality setting's far streaming radius and reapplies the live streaming window. acdream's remaining world-quality controls are Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature consumer for the five residual rows. Each residual round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`); `src/AcDream.App/Rendering/BuildingDegradeController.cs`; `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ApplyLandscapeDrawDistance`) | Building the five residual dead per-feature render knobs into a Vulkan renderer that has no analogous controls would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest while the register makes the "no effect" fact auditable. | A user who changes any of these five residual Config-tab controls sees no visual change. Landscape Draw Distance, Building Detail Textures, and the three S5-c3 degrade controls are explicitly outside this residual and must visibly affect their respective streaming or eligible building/EnvCell surfaces. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`/`ApplyLandscapeDrawDistance`); `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; Campaign OVERHAUL v2 S5-c3 | +| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2); NARROWED 2026-08-21 by #226; NARROWED 2026-09-04 at Campaign OVERHAUL v2 S5-c3; CORRECTED 2026-09-04 at S5-c3 fix round 1.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author five rows that still have no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, and `Render_MultiPassAlpha`. #226 removed `Render_BuildingDetailTextures` from this row: the existing checkbox now directly gates the retail one-pass building/EnvCell detail material and is no longer caption-dimmed as store-only. S5-c3 removes `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, and `Render_DegradeDistance`: they now feed the single renderer-lifetime retail building-degrade controller and visibly change selected building shells. `Render_LandscapeDrawDistance` was already live before S5-c3 through `RuntimeSettingsController.ApplyLandscapeDrawDistance`, which writes the aggregate quality setting's far streaming radius and reapplies the live streaming window. acdream's remaining world-quality controls are Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature consumer for the five residual rows. Each residual round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`); `src/AcDream.App/Rendering/BuildingDegradeController.cs`; `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ApplyLandscapeDrawDistance`) | Building the five residual dead per-feature render knobs into a Vulkan renderer that has no analogous controls would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest while the register makes the "no effect" fact auditable. | A user who changes any of these five residual Config-tab controls sees no visual change. Landscape Draw Distance, Building Detail Textures, and the three S5-c3 degrade controls are explicitly outside this residual and must visibly affect their respective streaming or eligible building/EnvCell surfaces. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`/`ApplyLandscapeDrawDistance`); `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; Campaign OVERHAUL v2 S5-c3 | | AP-199 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review M2) — the field names and the "gating to zero when disabled" wording were describing an INVERTED, muted-by-default bug, not the shipped behaviour.** The Config tab's "Sound Options" section authors three rows with no acdream consumer: `Sound_SoundFeatures` (Stereo/Mono menu — acdream's OpenAL backend has no channel-count toggle), the Interface Sound toggle+slider trio (`Sound_InterfaceSoundDisabled`/`Sound_InterfaceSoundVolume` — AP-174 already documents this as retail's OWN dead knob, "registered and then never read... interface sounds are scaled by the EFFECT knob"; acdream matches that exact behaviour rather than building a working Interface bus), and `Sound_PlaySoundOnlyWhenActive` (no window-focus-based audio mute subsystem exists). All three round-trip faithfully through the new `AudioSettings.SoundFeatures`/`InterfaceEnabled`/`InterfaceVolume`/`PlaySoundOnlyWhenActive` fields. The Sound and Ambient trios' own toggle+slider pairs are NOT covered by this row — `SfxEnabled`/`AmbientEnabled`/`Sfx`/`Ambient` are LIVE (`RuntimeSettingsController.SaveAudio` now pushes into `OpenAlAudioEngine` on every change; the effective volume is zero only when the corresponding `*Enabled` flag is false — retail's own `SoundManager::effect_sounds_enabled`/`ambient_sounds_enabled` statics default to enabled, so a fresh profile is audible, not muted). | `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindSoundSection`) | Matches the SAME reasoning AP-174 already established for the Interface knob specifically; Sound Features and Play-Only-When-Active are honest new store-only rows with no existing or planned acdream subsystem to bind (stereo/mono output selection and window-focus audio gating are both out of this campaign's scope). | A user who changes any of these three Config-tab controls sees/hears no change — the CONTRACTED behaviour, matching retail's own Interface-knob precedent for two of the three. | `gmClient::InitUIPreferences @0x004035b0` (`AttachPreference(&Sound_SoundFeatures, ...)`/`&Sound_InterfaceSoundDisabled`/`&Sound_InterfaceSoundVolume`/`&Sound_PlaySoundOnlyWhenActive`); AP-174 (Interface-knob precedent); `SoundManager::InitPrefs @0x005503F0` (`UserPreferences::RegisterPreference` binding the enabled-sense statics) | | AP-200 | **Filed 2026-08-11 at Campaign OP slice OP6.** The Config tab's "UI Options" section authors `UI_ChatFontFace`/`UI_ChatFontSize` menu rows (retail Windows TrueType face name / a Tiny-Small-Medium-Large-XLarge size-tier enum). These are DELIBERATELY separate NEW fields (`ChatSettings.ChatFontFace`/`ChatFontSizeIndex`) rather than reusing the existing LIVE `ChatSettings.FontSize` (a 10..20pt float acdream's chat panel already renders with) — there is no verified index-to-point mapping from retail's five-tier enum to that float range, and acdream's text rendering has no arbitrary system-font-face swap capability (DAT-baked/bitmap fonts only, not OS TrueType files). Store-only round-trip; `FontSize` is untouched by these two rows. | `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindUiSection`) | Inventing a size-index-to-point mapping without retail evidence would risk silently overwriting `FontSize`'s own already-live, user-visible behaviour with a guessed value; keeping the two concepts separate is the honest choice until a byte-verified mapping (or a font-face-swap capability) exists. | A user who changes either Config-tab font control sees no chat-panel rendering change; the SEPARATE, pre-existing font-size control (wherever acdream currently exposes `ChatSettings.FontSize`) remains the only live one. | `gmClient::InitUIPreferences @0x0040387b`/`@0x00403a1a` (`AttachPreference(&UI_ChatFontFace, ...)`/`&UI_ChatFontSize`, `SetEnumChoices` choice arrays "Arial"/"Tiny".."XLarge") | | AP-172 | **Filed 2026-08-08 (#354 fix — spell-bar drag reorder).** Retail removes a lifted favorite from `PlayerModule` (+ UI list + wire) the instant a drag starts and the remaining shortcuts visibly slide left to close the gap for the rest of the gesture (`RecvNotice_ItemListBeginDrag` → `RemoveSpellFromMenu`, live). acdream's controller performs the same PlayerModule/wire removal at drag-begin but DEFERS the whole favorite-list's visual rebuild until the drag concludes (drop or off-bar release) — the lifted cell's icon stays visible in its old slot and siblings do not slide until release, instead of reflowing continuously through the gesture. `DropFavorite` compensates by porting retail's own `AddFavorite`-side index adjustment (decrement the target index by one when the lifted item's original index was before it) against the now-intentionally-stale sibling numbering, so the FINAL landed position is byte-identical to retail's in every case exercised (`DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire`). **NARROWED + CORRECTED 2026-08-08 (drop-ring change).** Correction: this row originally claimed empty-tail-slot drops were "already-live-count-relative and are untouched" — false. The #354 `-1` adjustment sat inside `DropFavorite`, which the empty-cell path also calls, so its live-count-clamped (post-lift-numbered) index was double-corrected: lifting a non-last favorite onto the empty tail landed it second-to-last instead of last. `FavoriteDropIndex` is now THE one landing computation and applies retail's rule exactly — the `-1` is gated on the lifted spell's pre-lift-numbered removal site (retail's `RemoveSpellFromMenu`-return-gated decrement @0x004C7157), which for a live-numbered empty-tail target is retail's `RemoveSpellFromMenu == -1` no-adjustment case (test `SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` fails against the double-correcting code). Narrowing: the mid-drag presentation now includes retail's authored drag-over Accept ring — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 setting the per-cell authored DragAccept child (element 0x1000045A, `UIElement_UIItem::PostInit` @0x004E1870) to `ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9) on the hovered cell while a spell drag is live, cleared on leave/drop (`UiCatalogSlot.DragOverAcceptance` → `UiItemSlot.DrawDragAcceptOverlay`), with the ring and the drop sharing `FavoriteDropIndex` so the ring cannot promise a different landing. | `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`BeginFavoriteDrag`, `EndFavoriteDrag`, `DropFavorite`, `Tick` — the `_favoriteDragActive` gate) | `UiRoot`'s subtree-removal safety net (`ClearSubtreeOwnership`, `UiRoot.cs:240-247`) cancels any in-flight drag whose source widget is destroyed, and `Rebuild()` tears down and recreates every cell in the list (`UiItemList.Flush` → `RemoveChild` per cell) rather than incrementally diffing. Left unguarded, the press-time removal's `SpellbookChanged` event would let the very next per-frame `Tick()` (production drives this unconditionally via `RetailUiRuntime.Tick`) destroy the cell driving the gesture and silently cancel the reorder before the user could complete the drop — this was the reported bug. Deferring the rebuild for the gesture's duration is the minimal fix that does not touch the shared `UiRoot` drag machinery every other panel (toolbar/inventory/vendor/paperdoll) also depends on. | A future rewrite that makes `Rebuild()` an incremental per-cell diff (add/remove/reflow one cell) instead of flush-and-recreate-all would make this deferral unnecessary and should retire this row along with it — until then, a player watching their OWN spell bar mid-drag sees the vacated slot's icon linger and siblings snap into place only on release, rather than reflowing live as retail does — and one ring consequence of that frozen bar: when dragging rightward past the source, the Accept ring's SCREEN slot sits one cell right of where the icon finally lands (retail's live-reflowed bar makes them coincide); the ring is on the correct CELL in both — the spell lands immediately before that cell's spell, retail's exact insert-before semantic. No effect on final position, the wire pair sent, or any other panel; cross-window spellbook→favorite drops are live-count-relative and untouched (the empty-tail claim this sentence used to carry was corrected 2026-08-08 — see the Divergence column). | `gmSpellcastingUI::RecvNotice_ItemListBeginDrag` @0x004C7360 (`SpellCastSubMenu::RemoveSpellFromMenu`, immediate live-list removal at lift); `SpellCastSubMenu::AddFavorite` @0x004C7060 (`RemoveSpellFromMenu`'s return value gating the `-1`-if-lifted-before-target `m_numSpells` adjustment before `ItemList_InsertSpellShortcut`); `PlayerModule::AddSpellFavorite` @0x005D43E0 (`InsertPos`); `PlayerModule::RemoveSpellFavorite` @0x005D4910 | @@ -510,7 +510,7 @@ research and is no longer active. | 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-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); 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-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 renders eligible building-shell and EnvCell subsets once with retail's **single-pass** detail combine — the subset's original framebuffer family after `C=q*(a*qA)+(base.rgb*diffuse)*(1-a*qA)`, `X=a*qA²` — 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/retail_detail_material.glsl`; `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Shaders/mesh_atmospheric.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 leaves the original draw detail-unarmed, 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-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`) | diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs index e8508e29..b3b7db65 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs @@ -143,17 +143,6 @@ internal enum GpuBlendMode /// `ParticleRenderer` needs it too (slice V4e). /// InverseAlpha, - - /// - /// Retail building/EnvCell detail overlay: SrcAlpha, - /// OneMinusSrcAlpha. VM2's live cdb read proved retail hardware runs - /// the single-pass texture-stage combine (D3DPolyRender::SetSurface - /// 0x0059c4d0), a lerp toward the detail colour by - /// detail.a * diffuse.a — a mild darkening on the live Dereth - /// category texture, not the two-pass DstColor fallback's - /// brightening. - /// - RetailDetail, } internal enum GpuCompareOp diff --git a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs index 59f31b7a..c4513af9 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs @@ -56,7 +56,7 @@ internal struct GpuPushConstants public uint TextureIndexA; /// GLSL uTextureIndexB. Secondary per-pass slot; terrain - /// uses it for the alpha-mask array, while shared-pose world/detail passes + /// uses it for the alpha-mask array, while shared-pose world passes /// carry the absolute transform-prefix instance count. public uint TextureIndexB; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs index e78874a0..4b71e729 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs @@ -87,7 +87,7 @@ internal sealed record VulkanDeviceFeatureSupport /// /// #459: the shader compiler targets Vulkan 1.3 / SPIR-V 1.6, where glslang /// lowers every fragment discard to OpDemoteToHelperInvocation - /// (five committed modules declare the capability — sky, mesh_detail, + /// (five committed modules declare the capability — sky, mesh_modern, /// mesh_atmospheric and both directional-shadow cutout fragments). A module /// that declares the capability without the feature enabled is invalid /// usage (VUID-VkShaderModuleCreateInfo-pCode-08740), so the feature diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs index ca130289..ac404025 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -178,12 +178,6 @@ internal static class VulkanViewportMapping GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), - // VM2 (live cdb read, 2026-08-22): real hardware runs the single-pass - // texture-stage detail combine, not the two-pass DESTCOLOR fallback. - // 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), _ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."), }; diff --git a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs index 36fc600a..699f7753 100644 --- a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs +++ b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs @@ -1,11 +1,10 @@ using System.Numerics; -using AcDream.App.Rendering.Gpu; namespace AcDream.App.Rendering; /// -/// Testable CPU statement of retail's detail-pass gate and pixel math. The -/// production pixels are produced by mesh_detail; keeping these facts +/// Testable CPU statement of retail's detail gate and one-pass pixel math. The +/// production pixels are produced by both world-mesh fragment families; keeping these facts /// in one small contract makes the setting and the combine independently /// assertable without a GPU. /// @@ -17,15 +16,12 @@ namespace AcDream.App.Rendering; /// D3DPolyRender::RenderMeshSubset (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 -/// D3DPolyRender::SetSurface (0x0059c4d0): -/// lerp(base * diffuse, detail.rgb, detail.a * diffuse.a) — a blend -/// TOWARD the detail colour by detail.a * diffuse.a, not the -/// fallback's dest * (detail.rgb + 1 - detail.a). Built meshes light -/// with tmpmaterial.Diffuse.a = 1 for opaque subsets -/// (RenderMeshSubset), 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. +/// D3DPolyRender::SetSurface (0x0059c4d0). With base RGB B, +/// diffuse RGB D, active material alpha a, and detail texel +/// Q=(q,qA), that stage program emits +/// C=q*(a*qA)+(B*D)*(1-a*qA) and X=a*qA*qA. Base texture alpha +/// is deliberately excluded. This is not the fallback's framebuffer replay +/// result dest * (detail.rgb + 1 - detail.a). /// /// There is no distance fade on this path. Retail's /// ACRender::get_alpha_for_z (0x006b6230) is only evaluated in @@ -44,66 +40,69 @@ internal static class RetailDetailTextureContract settingEnabled && binding.IsAvailable; /// - /// Opaque detail must compare equal against the depth written by its exact - /// base geometry. On an MSAA target that inherits the base pass's per-sample - /// alpha-to-coverage mask without applying A2C to the detail alpha itself. - /// Transparent bases do not write depth, so their adjacent detail uses the - /// accepted less-or-equal comparison instead. + /// Retail's exact pre-fog stage result. Base alpha is deliberately absent. /// - internal static GpuCompareOp DetailDepthCompare(bool transparent) => - transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal; - - /// - /// The exact pixel mesh_detail composites onto the existing - /// framebuffer colour: retail's single-pass stage-1 - /// BLENDCURRENTALPHA(TEXTURE, CURRENT), a lerp from - /// toward 's RGB by - /// detail.a * 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 - /// instanceAlpha[instanceIndex] read. - /// - internal static Vector3 Expected(Vector3 baseColour, Vector4 detail, float opacity) => - Vector3.Lerp( - baseColour, - new Vector3(detail.X, detail.Y, detail.Z), - detail.W * opacity); - - /// - /// True when the combine above is an exact no-op — either the detail - /// texel is fully transparent or the base subset's own diffuse alpha (the - /// translucency fade) has reached zero. Neutral is detail.a * opacity - /// == 0, not any particular colour equality. - /// - internal static bool IsNeutral(Vector4 detail, float opacity) => - detail.W * opacity == 0f; - - /// - /// Review fix (post-05970306): retail's D3D fog stage runs AFTER the - /// texture-stage combine, applying to the FINAL pixel, not to - /// detail.rgb in isolation. acdream draws the combine as two - /// separate passes (mesh_modern's base draw, then mesh_detail's blended - /// replay), so each draw fogs its OWN colour before the fixed-function - /// blend recombines them — this is the CPU statement of that two-draw - /// path: lerp(mix(base,fog,f), mix(detail,fog,f), detail.a*opacity). - /// It is algebraically identical to retail's single-draw - /// fog-after-combine order, mix(Expected(base,detail,opacity), fog, - /// f) — see RetailDetailTextureContractTests for the identity - /// pinned numerically, and mesh_detail.frag's header comment for the - /// derivation. - /// - internal static Vector3 ExpectedFogged( - Vector3 baseColour, + internal static Vector4 Combine( + Vector4 baseTexel, + Vector3 diffuse, Vector4 detail, - float opacity, - Vector3 fog, - float fogFactor) + float authoredOpacity, + float liveOpacity) { - Vector3 foggedBase = Vector3.Lerp(baseColour, fog, fogFactor); - Vector3 foggedDetail = Vector3.Lerp( - new Vector3(detail.X, detail.Y, detail.Z), - fog, - fogFactor); - return Vector3.Lerp(foggedBase, foggedDetail, detail.W * opacity); + float alpha = authoredOpacity * liveOpacity; + float weight = alpha * detail.W; + Vector3 colour = new(detail.X, detail.Y, detail.Z); + colour *= weight; + colour += new Vector3(baseTexel.X, baseTexel.Y, baseTexel.Z) + * diffuse * (1f - weight); + float outputAlpha = alpha * detail.W * detail.W; + return new Vector4(colour, outputAlpha); + } + + /// + /// Applies the subset's post-material fog to RGB. Retail fog never changes + /// the detail-active final alpha. + /// + internal static Vector4 ApplyFog(Vector4 material, Vector3 fog, float fogFactor) => + new(Vector3.Lerp(new Vector3(material.X, material.Y, material.Z), fog, fogFactor), material.W); + + /// + /// Mirrors D3D's GREATER_EQUAL alpha test: equality survives. + /// + internal static bool SurvivesClip(float outputAlpha, float reference) => + outputAlpha >= reference; + + /// + /// The retained framebuffer-family equations applied to retail's one-pass + /// source fragment. + /// + internal enum FramebufferFamily + { + Opaque, + Alpha, + AlphaAdditive, + Additive, + InverseAlpha, + InverseAlphaAdditive, + Clip, + } + + internal static Vector4 Composite( + Vector4 source, + Vector4 destination, + FramebufferFamily family) + { + float x = source.W; + return family switch + { + FramebufferFamily.Opaque => source, + FramebufferFamily.Alpha => source * x + destination * (1f - x), + FramebufferFamily.AlphaAdditive => source * x + destination, + FramebufferFamily.Additive => source + destination, + FramebufferFamily.InverseAlpha => source * (1f - x) + destination * x, + FramebufferFamily.InverseAlphaAdditive => source * (1f - x) + destination, + FramebufferFamily.Clip => source + destination * new Vector4(1f - x), + _ => throw new ArgumentOutOfRangeException(nameof(family)), + }; } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag index 32002dbf..65b81ebb 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag @@ -14,8 +14,12 @@ in flat uint vTextureLayer; in flat float vOpacityMultiplier; // #188 in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse in flat uint vReceivesDirectionalShadow; +in flat float vSurfaceOpacity; +in flat uint vBatchFlags; +in flat uint vDetailCategory; #include "directional_shadow_receiver.glsl" +#include "retail_detail_material.glsl" // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // 0 = opaque pass — discard fragments with alpha < 0.95 @@ -26,6 +30,9 @@ in flat uint vReceivesDirectionalShadow; // transparent overdraw cost otherwise) uniform int uRenderPass; uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here +uniform uint uTextureIndexA; +uniform float uParamA; +uniform float uParamB; // SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. struct Light { @@ -61,35 +68,14 @@ vec3 applyFog(vec3 lit, vec3 worldPos) { out vec4 FragColor; +bool isRetailClipReference(float value) { + return abs(value - (100.0 / 255.0)) < 0.000001 + || abs(value - (200.0 / 255.0)) < 0.000001; +} + void main() { vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer))); - - // Two-pass alpha-test (N.5 Decision 2). - // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE - // derives the MSAA sample mask from it — ClipMap foliage edges become smooth. - // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking. - if (uRenderPass == 0) { - if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C) - } else { - // Transparent pass. - // - // Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here. - // Native AC transparent-flagged surfaces routinely include - // effectively-opaque pixels — e.g. the Holtburg lifestone crystal core - // (surface 0x080011DE) which the spawn manifest classifies as - // transparent (batch.IsTransparent=True) but whose decoded texture - // alpha lands ≥0.95 across the visible surface. Those pixels still - // compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so - // discarding them here threw away the whole crystal. The original - // N.5 §2 rationale (high-α fragments belong in the opaque pass) does - // not apply when the SURFACE is dat-flagged transparent — those - // pixels can't reach the opaque pass at all. - // - // Keep the α<0.05 short-circuit as a fragment-cost optimization - // (skip fully-empty pixels — saves blend bandwidth on alpha-keyed - // sprites with large transparent margins). - if (color.a < 0.05) discard; - } + float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05; // Only the authored outdoor directional term is shadowed. Ambient, local // lights and material selection pulses remain exactly on the retail path. @@ -110,6 +96,7 @@ void main() { // ignored). Stripes visible HERE = a vertex-lighting artifact; absent = // the pattern comes from texture/per-pixel machinery. Throwaway diagnostic. if (uLightDebug == 3) { + if (color.a < alphaCutoff) discard; FragColor = vec4(min(lit, vec3(1.0)), 1.0); return; } @@ -120,11 +107,30 @@ void main() { // Retail clamp per-channel to 1.0 (r13 §13.1). lit = min(lit, vec3(1.0)); - vec3 rgb = color.rgb * lit; + bool detailActive = uParamA != 0.0 + && vDetailCategory != 0u + && (vBatchFlags & 1u) != 0u; + vec3 rgb; + float alpha; + if (detailActive) { + vec4 detail = ACDREAM_SAMPLE_ARRAY( + uTextureIndexA, + vec3(vTexCoord * uParamA, 0.0)); + RetailDetailMaterialResult material = acdreamRetailDetailMaterial( + color.rgb, + lit, + detail, + vSurfaceOpacity * vOpacityMultiplier); + rgb = material.rgb; + alpha = material.alpha; + } else { + rgb = color.rgb * lit; + alpha = color.a * vOpacityMultiplier; + } + + if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff) + discard; + rgb = applyFog(rgb, vWorldPos); - // #188: multiply the FINAL alpha only — the discard thresholds above stay - // keyed on the raw sampled color.a, so the last few frames of a fade - // (multiplier crossing under 0.05) still ramp smoothly toward zero rather - // than popping invisible early against the discard cutoff. - FragColor = vec4(rgb, color.a * vOpacityMultiplier); + FragColor = vec4(rgb, alpha); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert index 7cf118a2..151fab53 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert @@ -23,7 +23,7 @@ struct InstanceData { // CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes). struct BatchData { uint textureIndex; // slot into the global texture table - uint _pad; // keeps textureLayer/flags at offsets 8/12 + float surfaceOpacity; // authored material alpha (not base texture alpha) uint textureLayer; // layer in the shared WB or pooled composite array uint flags; // reserved — N.5 dispatcher owns all blend state // (glBlendFunc per pass). If a future phase wants @@ -116,6 +116,10 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf { vec2 instanceSelectionLighting[]; }; +layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf { + uint instanceDetailCategory[]; +}; + uniform mat4 uViewProjection; // Absolute transform prefix in the shared shadow/world pose arena. Every // parallel per-instance array remains local to this submission, so only the @@ -309,6 +313,9 @@ out flat uint vTextureLayer; out flat float vOpacityMultiplier; // #188 out flat vec2 vSelectionLighting; out flat uint vReceivesDirectionalShadow; +out flat float vSurfaceOpacity; +out flat uint vBatchFlags; +out flat uint vDetailCategory; void main() { int transformIndex = gl_BaseInstanceARB + gl_InstanceID; @@ -358,4 +365,7 @@ void main() { // could read its flags — Campaign VM VM6.) vTextureIndex = b.textureIndex; vTextureLayer = b.textureLayer; + vSurfaceOpacity = b.surfaceOpacity; + vBatchFlags = b.flags; + vDetailCategory = instanceDetailCategory[instanceIndex]; } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag deleted file mode 100644 index 8d6f09de..00000000 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag +++ /dev/null @@ -1,121 +0,0 @@ -#version 430 core -#extension GL_ARB_bindless_texture : require - -in vec2 vBaseUv; -in vec2 vDetailUv; -in float vDetailOpacity; -in vec3 vWorldPos; -in flat uint vBaseTextureIndex; -in flat uint vBaseTextureLayer; -in flat uint vBatchFlags; -in flat uint vDetailCategory; - -uniform uint uTextureIndexA; // category detail texture, layer 0 -// S4-c2 F3: 100/255 or 200/255 for a pure ClipMap base/detail pair. Exact 1 -// remains mesh_detail.vert's existing building-category sentinel and must use -// the ordinary empty-fragment cutoff. Zero is the ordinary EnvCell category. -uniform float uParamB; - -// SceneLighting UBO — IDENTICAL layout to mesh_modern.frag binding=1 (same -// std140 block, same struct, same binding). Declared here ONLY for fog -// (uFogParams/uFogColor/uCameraAndTime); mesh_detail never lights (no -// uLights[]/uCellAmbient read), it only needs applyFog below to match -// retail's fog placement exactly. -struct Light { - vec4 posAndKind; - vec4 dirAndRange; - vec4 colorAndIntensity; - vec4 coneAngleEtc; -}; -layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting { - Light uLights[8]; - vec4 uCellAmbient; - vec4 uFogParams; - vec4 uFogColor; - vec4 uCameraAndTime; -}; - -// Copied verbatim from mesh_modern.frag — same math, not "improved". Retail's -// D3D fixed-function fog stage runs AFTER the texture-stage pipeline -// (RenderDeviceD3D's fog render state applies to the final pixel the -// blender produced, not to an individual texture stage's output), so the -// detail contribution must be fogged exactly like the base pass fogs its -// own colour, with the identical fog curve. -vec3 applyFog(vec3 lit, vec3 worldPos) { - int mode = int(uFogParams.w); - if (mode == 0) return lit; - float d = length(worldPos - uCameraAndTime.xyz); - float fogStart = uFogParams.x; - float fogEnd = uFogParams.y; - float span = max(1e-3, fogEnd - fogStart); - float fog = clamp((d - fogStart) / span, 0.0, 1.0); - return mix(lit, uFogColor.xyz, fog); -} - -out vec4 FragColor; - -bool isRetailClipReference(float value) { - return abs(value - (100.0 / 255.0)) < 0.000001 - || abs(value - (200.0 / 255.0)) < 0.000001; -} - -// 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. -// -// Review fix (post-05970306): retail's texture-stage combine above happens -// BEFORE the D3D fixed-function fog stage, not after — fog is the LAST thing -// applied to the pixel, so it applies to the lerp's result, not to detail.rgb -// alone. mesh_modern.frag already fogs the base colour before this replay -// draws over it (applyFog(rgb, vWorldPos) there), so fogging detail.rgb here -// too makes the two-draw blend collapse to retail's single-draw order: -// (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) -// = (1-f)*[(1-a)*base + a*detail] + f*fog -// = mix(lerp(base,detail,a), fog, f) -// which is exactly retail's fog-after-combine pixel. Leaving detail.rgb -// unfogged would draw detail at full saturation/brightness even at maximum -// fog distance. RetailDetailTextureContractTests pins this identity -// numerically (ExpectedFogged). - -void main() { - // Object command replays may contain ordinary instances; only building - // shells survive. Bit 0 means this command came through retail's built-mesh - // DrawMesh path. Unlike the land-polygon path, RenderMeshSubset receives - // curr_detail_surface for every built-mesh material subset. - if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u) - discard; - - vec4 base = ACDREAM_SAMPLE_ARRAY( - vBaseTextureIndex, - vec3(vBaseUv, float(vBaseTextureLayer))); - float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05; - if (base.a < alphaCutoff) - discard; - - vec4 detail = ACDREAM_SAMPLE_ARRAY( - uTextureIndexA, - vec3(vDetailUv, 0.0)); - - FragColor = vec4(applyFog(detail.rgb, vWorldPos), detail.a * vDetailOpacity); -} diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert deleted file mode 100644 index 318c6587..00000000 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert +++ /dev/null @@ -1,104 +0,0 @@ -#version 430 core -#extension GL_ARB_shader_draw_parameters : require - -layout(location = 0) in vec3 aPosition; -layout(location = 1) in vec3 aNormal; -layout(location = 2) in vec2 aTexCoord; - -struct InstanceData { - mat4 transform; -}; - -struct BatchData { - uint textureIndex; - uint _pad; - uint textureLayer; - uint flags; -}; - -layout(std430, binding = 0) readonly buffer InstanceBuffer { - InstanceData Instances[]; -}; -layout(std430, binding = 1) readonly buffer BatchBuffer { - BatchData Batches[]; -}; - -// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate -// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side -// routing that could ever select a non-zero slot for an instance -// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every -// instance has always mapped to slot 0 (no-clip) in every shipped build; a -// shader-side clip test against a table that only ever holds the reserved -// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot -// index, parallel to the binding=0 instance buffer and indexed by the -// IDENTICAL per-instance index — stays declared (the CPU side still writes -// it, always 0) but is no longer read here; the instance-buffer layout it -// occupies is S5's to revisit. -layout(std430, binding = 3) readonly buffer ClipSlotBuf { - uint instanceClipSlot[]; -}; - -// Exact uParamB=1 selects this per-instance building/object category. EnvCell -// zero and its pure-ClipMap 100/255 or 200/255 references retain the bound -// environment category; the fragment shader consumes those CLIP references. -layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf { - 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[]; -}; - -uniform mat4 uViewProjection; -uniform int uDrawIDOffset; -uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena -uniform float uParamA; // detail UV tiling -// Exact 1 remains the existing "require building instance" sentinel. S4-c2 -// also passes 100/255 or 200/255 for an EnvCell ClipMap alpha reference; both -// must retain the EnvCell category rather than taking the building branch. -uniform float uParamB; - -out vec2 vBaseUv; -out vec2 vDetailUv; -out float vDetailOpacity; -out vec3 vWorldPos; // review fix: mesh_detail.frag needs this for applyFog, - // exactly like mesh_modern.vert's vWorldPos. -out flat uint vBaseTextureIndex; -out flat uint vBaseTextureLayer; -out flat uint vBatchFlags; -out flat uint vDetailCategory; - -void main() { - int transformIndex = gl_BaseInstanceARB + gl_InstanceID; - int instanceIndex = transformIndex - int(uTextureIndexB); - vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0); - gl_Position = uViewProjection * worldPos; - vWorldPos = worldPos.xyz; - - // No distance term: VM2 found the fade only exists in - // D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon - // path) and only when the static noFadeDetail (0x00820e38, initialised - // to 1) is 0. Every loaded CGfxObj sets use_built_mesh=1 - // (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; - vDetailUv = aTexCoord * uParamA; - - BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; - vBaseTextureIndex = batch.textureIndex; - vBaseTextureLayer = batch.textureLayer; - vBatchFlags = batch.flags; - vDetailCategory = uParamB == 1.0 - ? instanceDetailCategory[instanceIndex] - : 1u; -} diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 5e5d97e4..291758a1 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -12,6 +12,11 @@ in flat uint vTextureIndex; in flat uint vTextureLayer; in flat float vOpacityMultiplier; // #188 in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse +in flat float vSurfaceOpacity; +in flat uint vBatchFlags; +in flat uint vDetailCategory; + +#include "retail_detail_material.glsl" // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // 0 = opaque pass — discard fragments with alpha < 0.95 @@ -26,6 +31,8 @@ uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 // recognized, supplied per MDI range from TextureKey.PaletteId (100/255 // paletted, 200/255 DDS/non-paletted). Every other value keeps 0.05. uniform float uParamB; +uniform uint uTextureIndexA; +uniform float uParamA; // SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. struct Light { @@ -70,35 +77,6 @@ void main() { vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer))); float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05; - // Two-pass alpha-test (N.5 Decision 2). - // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE - // derives the MSAA sample mask from it — ClipMap foliage edges become smooth. - // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking. - if (uRenderPass == 0) { - // '<' spells D3DCMP_GREATEREQUAL: equality passes. Retail loads - // 100 or 200 and func 7 at SetSurface 0x0059c73c..0x0059c838. - if (color.a < alphaCutoff) discard; - } else { - // Transparent pass. - // - // Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here. - // Native AC transparent-flagged surfaces routinely include - // effectively-opaque pixels — e.g. the Holtburg lifestone crystal core - // (surface 0x080011DE) which the spawn manifest classifies as - // transparent (batch.IsTransparent=True) but whose decoded texture - // alpha lands ≥0.95 across the visible surface. Those pixels still - // compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so - // discarding them here threw away the whole crystal. The original - // N.5 §2 rationale (high-α fragments belong in the opaque pass) does - // not apply when the SURFACE is dat-flagged transparent — those - // pixels can't reach the opaque pass at all. - // - // Keep the α<0.05 short-circuit as a fragment-cost optimization - // (skip fully-empty pixels — saves blend bandwidth on alpha-keyed - // sprites with large transparent margins). - if (color.a < alphaCutoff) discard; - } - // Per-vertex Gouraud lighting from the vertex shader (ambient + capped // lights), passed through retail CMaterial's two click-pulse replacements: // emissive/luminosity + diffuse * scene lighting. Normal is (0,1). @@ -108,6 +86,7 @@ void main() { // ignored). Stripes visible HERE = a vertex-lighting artifact; absent = // the pattern comes from texture/per-pixel machinery. Throwaway diagnostic. if (uLightDebug == 3) { + if (color.a < alphaCutoff) discard; FragColor = vec4(min(lit, vec3(1.0)), 1.0); return; } @@ -118,11 +97,32 @@ void main() { // Retail clamp per-channel to 1.0 (r13 §13.1). lit = min(lit, vec3(1.0)); - vec3 rgb = color.rgb * lit; + bool detailActive = uParamA != 0.0 + && vDetailCategory != 0u + && (vBatchFlags & 1u) != 0u; + vec3 rgb; + float alpha; + if (detailActive) { + vec4 detail = ACDREAM_SAMPLE_ARRAY( + uTextureIndexA, + vec3(vTexCoord * uParamA, 0.0)); + RetailDetailMaterialResult material = acdreamRetailDetailMaterial( + color.rgb, + lit, + detail, + vSurfaceOpacity * vOpacityMultiplier); + rgb = material.rgb; + alpha = material.alpha; + } else { + rgb = color.rgb * lit; + alpha = color.a * vOpacityMultiplier; + } + + // '<' spells D3DCMP_GREATEREQUAL: equality passes. Detail-active CLIP + // tests retail's final stage-1 alpha; detail-off keeps base-alpha logic. + if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff) + discard; + rgb = applyFog(rgb, vWorldPos); - // #188: multiply the FINAL alpha only — the discard thresholds above stay - // keyed on the raw sampled color.a, so the last few frames of a fade - // (multiplier crossing under 0.05) still ramp smoothly toward zero rather - // than popping invisible early against the discard cutoff. - FragColor = vec4(rgb, color.a * vOpacityMultiplier); + FragColor = vec4(rgb, alpha); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index b834886f..7b00613f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -19,7 +19,7 @@ struct InstanceData { // CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes). struct BatchData { uint textureIndex; // slot into the global texture table - uint _pad; // keeps textureLayer/flags at offsets 8/12 + float surfaceOpacity; // authored material alpha (not base texture alpha) uint textureLayer; // layer in the shared WB or pooled composite array uint flags; // reserved — N.5 dispatcher owns all blend state // (glBlendFunc per pass). If a future phase wants @@ -112,6 +112,10 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf { vec2 instanceSelectionLighting[]; }; +layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf { + uint instanceDetailCategory[]; +}; + uniform mat4 uViewProjection; // Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[]. @@ -272,6 +276,9 @@ out flat uint vTextureIndex; out flat uint vTextureLayer; out flat float vOpacityMultiplier; // #188 out flat vec2 vSelectionLighting; +out flat float vSurfaceOpacity; +out flat uint vBatchFlags; +out flat uint vDetailCategory; void main() { int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; @@ -295,4 +302,7 @@ void main() { // site in mesh_modern.frag, which is the only form Vulkan can express. vTextureIndex = b.textureIndex; vTextureLayer = b.textureLayer; + vSurfaceOpacity = b.surfaceOpacity; + vBatchFlags = b.flags; + vDetailCategory = instanceDetailCategory[instanceIndex]; } diff --git a/src/AcDream.App/Rendering/Shaders/retail_detail_material.glsl b/src/AcDream.App/Rendering/Shaders/retail_detail_material.glsl new file mode 100644 index 00000000..a0332324 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/retail_detail_material.glsl @@ -0,0 +1,18 @@ +// D3DPolyRender::SetSurface @0x0059C4D0, retail single-pass detail stages. +// Keep this one source shared by the ordinary and atmospheric mesh families. +struct RetailDetailMaterialResult { + vec3 rgb; + float alpha; +}; + +RetailDetailMaterialResult acdreamRetailDetailMaterial( + vec3 baseRgb, + vec3 diffuseRgb, + vec4 detail, + float materialAlpha) +{ + float weight = materialAlpha * detail.a; + return RetailDetailMaterialResult( + detail.rgb * weight + (baseRgb * diffuseRgb) * (1.0 - weight), + materialAlpha * detail.a * detail.a); +} diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv index 78335661..258dd8c2 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv index 1fa7572d..3dc26aa0 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv deleted file mode 100644 index b8fbf8e9..00000000 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv and /dev/null differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv deleted file mode 100644 index 5ae4f237..00000000 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv and /dev/null differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv index 337ecaeb..577c041b 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv index 3ab0bbb5..c48d1399 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index b4118fd9..7e8a63c0 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -215,28 +215,12 @@ "stages": [ { "stage": "vert", - "sourceSha256": "e552edb7ef42e9e32812824c075310f2d99eb5826e527db5c22b54890ef02088", + "sourceSha256": "71950823770a69d610db03e0c54c645934e3b46a686c8abac85b52f2476afcfa", "compiled": true }, { "stage": "frag", - "sourceSha256": "62b001a72080ea74bdaef0fb2a4ed92533feaeab76187ce4215813c6327432e7", - "compiled": true - } - ] - }, - { - "name": "mesh_detail", - "vulkanReady": true, - "stages": [ - { - "stage": "vert", - "sourceSha256": "2d8b5b42f053a2d42e1ff44db3e331d5bea2d3f7dd36e20d813b7ae22c033181", - "compiled": true - }, - { - "stage": "frag", - "sourceSha256": "a5285c51e8d6a1fa91f403f8489819595fa5a20abaaaccc969f220ea2c4eb335", + "sourceSha256": "5eb058e92ab27f1f6c245401e05f78a278fe00696b5c77f36ae33f8428f83bf3", "compiled": true } ] @@ -247,12 +231,12 @@ "stages": [ { "stage": "vert", - "sourceSha256": "dba51ce23f2b621683fc78db73f608a0c000479b8e827cd3ecc8731d46c6ee64", + "sourceSha256": "c41448edcd258313f921f3b66c0884c8f78d4d592eef1abe0804c6cb2890c58f", "compiled": true }, { "stage": "frag", - "sourceSha256": "d6ae09de279e814b159e7c481d6b66f3cc37c741b3d8a249fbe2bc8156e52d4e", + "sourceSha256": "396c382af73505bc2cac7a2db5878a63166730e47096a94630c35ad8376fe565", "compiled": true } ] diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 2cb4e1a5..f2730573 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -1094,11 +1094,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource /// this method. This list's premise is the opaque-only, depth-test-and- /// write, no-blend pipeline pair the walk's ordered stream actually uses /// TODAY — not StreamMark itself: if any future ordered-stream - /// stage draws with blending or a disabled depth write (for example the - /// deferred building-detail overlay replay, - /// WbDrawDispatcher.DrawBuildingDetailRangeRhi's - /// RetailDetailTransparent pass — not wired into this walk path - /// today, OrderedStream.cs:46-52), that stage's StreamMark MUST + /// stage draws with blending or a disabled depth write, that stage's + /// StreamMark MUST /// become a flush point again; the contract gets amended, not bent. /// internal void Replay(IGpuFrame frame, IGpuPassEncoder encoder) diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index d5050553..e86aa34b 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -28,9 +28,6 @@ public sealed unsafe partial class EnvCellRenderer private IGpuPipeline? _alphaPipeline; private IGpuPipeline? _clipPipeline; private IGpuPipeline? _additivePipeline; - private IGpuPipeline? _detailPipeline; - private IGpuPipeline? _transparentDetailPipeline; - private IGpuPipeline? _clipDetailPipeline; private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail; private readonly Func _buildingDetailEnabled; @@ -46,11 +43,12 @@ public sealed unsafe partial class EnvCellRenderer /// to the retail alpha queue for later replay. /// internal bool TransparentDetailEnabled => - RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _environmentDetail); + RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _environmentDetail) + && _environmentDetail.Tiling != 0f; /// /// The RHI arm's constructor. It also completes Initialize's job: the - /// seven pipelines ARE this renderer's program, so there is no second step + /// four pipelines ARE this renderer's program, so there is no second step /// and no Shader to hand in. /// internal EnvCellRenderer( @@ -82,33 +80,6 @@ public sealed unsafe partial class EnvCellRenderer scope.SampleCount); _additivePipeline = CreateShellPipeline( device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); - _detailPipeline = CreateShellPipeline( - device, - "envcell-retail-detail", - GpuBlendMode.RetailDetail, - depthWrite: true, - scope.SampleCount, - shaderName: "mesh_detail", - depthCompare: RetailDetailTextureContract.DetailDepthCompare( - transparent: false)); - _transparentDetailPipeline = CreateShellPipeline( - device, - "envcell-retail-detail-alpha", - GpuBlendMode.RetailDetail, - depthWrite: false, - scope.SampleCount, - shaderName: "mesh_detail", - depthCompare: RetailDetailTextureContract.DetailDepthCompare( - transparent: true)); - _clipDetailPipeline = CreateShellPipeline( - device, - "envcell-retail-detail-clip", - GpuBlendMode.RetailDetail, - depthWrite: true, - scope.SampleCount, - shaderName: "mesh_detail", - depthCompare: RetailDetailTextureContract.DetailDepthCompare( - transparent: false)); _initialized = true; } @@ -267,7 +238,7 @@ public sealed unsafe partial class EnvCellRenderer BindRingSection( encoder, frame, GpuBindingModel.StorageInstanceLightSets, _lightSetData.AsSpan(0, uniqueInstanceCount * lightStride)); - BindEnvironmentDetailCategory(encoder, frame); + BindEnvironmentDetailCategory(encoder, frame, uniqueInstanceCount); // The frame-global sections, bound after this renderer's own binds // because those binds are what select the descriptor scope. @@ -282,9 +253,7 @@ public sealed unsafe partial class EnvCellRenderer MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data); IGpuBuffer commandBuffer = commands.Buffer; uint commandBase = commands.OffsetBytes; - bool detailEnabled = RetailDetailTextureContract.ShouldRender( - _buildingDetailEnabled(), - _environmentDetail); + bool detailEnabled = TransparentDetailEnabled; for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) { @@ -324,56 +293,14 @@ public sealed unsafe partial class EnvCellRenderer pushConstants.ParamB = isClip ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f : 0f; + pushConstants.TextureIndexA = detailEnabled + ? _environmentDetail.TextureSlot.Index + : 0u; + pushConstants.ParamA = detailEnabled + ? _environmentDetail.Tiling + : 0f; encoder.SetPushConstants(in pushConstants); - // Retail DrawMesh's two-pass fallback redraws each transparent - // RenderMeshSubset immediately, before the next delayed-alpha - // subset. Preserve that base/detail adjacency so another shell or - // particle cannot be composited between the two contributions. - if (renderPass == WbRenderPass.Transparent && detailEnabled) - { - int rangeEnd = drawRange.FirstCommand + drawRange.CommandCount; - for (int command = drawRange.FirstCommand; command < rangeEnd; command++) - { - BindPipelineWithMesh(encoder, rangeBasePipeline, mesh); - SetCullMode(encoder, cullMode); - pushConstants.RenderPass = isAdditive - ? (int)renderPass | 0x100 - : (int)renderPass; - pushConstants.DrawIdOffset = command; - pushConstants.TextureIndexA = 0; - pushConstants.ParamA = 0f; - pushConstants.ParamB = isClip - ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f - : 0f; - encoder.SetPushConstants(in pushConstants); - encoder.MultiDrawIndexedIndirect( - commandBuffer, - commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)), - 1, - (uint)sizeof(DrawElementsIndirectCommand)); - - BindPipelineWithMesh( - encoder, - isClip ? _clipDetailPipeline! : _transparentDetailPipeline!, - mesh); - SetCullMode(encoder, cullMode); - pushConstants.DrawIdOffset = command; - pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; - pushConstants.ParamA = _environmentDetail.Tiling; - pushConstants.ParamB = isClip - ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f - : 0f; - encoder.SetPushConstants(in pushConstants); - encoder.MultiDrawIndexedIndirect( - commandBuffer, - commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)), - 1, - (uint)sizeof(DrawElementsIndirectCommand)); - } - continue; - } - encoder.MultiDrawIndexedIndirect( commandBuffer, commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), @@ -381,39 +308,10 @@ public sealed unsafe partial class EnvCellRenderer (uint)sizeof(DrawElementsIndirectCommand)); } - // Retail DrawEnvCell category (2). Replay only the already-filtered - // opaque shell commands. Transparent ClipMap is excluded here: it - // takes the immediate/delayed CLIP path and, when detail is active, - // its own _clipDetailPipeline contribution. No distance fade - // (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the - // immediate-polygon path only, which built meshes never reach; - // attenuation is the sampler's linear mip chain converging to the - // texture mean. The existing "Building Detail Textures" option gates - // both this and buildings, matching LScape::ChangeRegion. - if (renderPass == WbRenderPass.Opaque - && detailEnabled) - { - BindPipelineWithMesh(encoder, _detailPipeline!, mesh); - pushConstants.RenderPass = 0; - pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; - pushConstants.ParamA = _environmentDetail.Tiling; - pushConstants.ParamB = 0f; - - for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) - { - MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; - CullMode cullMode = ResolveRetailCellShellCullMode( - (CullMode)(drawRange.GroupIndex % CullGroupCount)); - SetCullMode(encoder, cullMode); - pushConstants.DrawIdOffset = drawRange.FirstCommand; - encoder.SetPushConstants(in pushConstants); - encoder.MultiDrawIndexedIndirect( - commandBuffer, - commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), - (uint)drawRange.CommandCount, - (uint)sizeof(DrawElementsIndirectCommand)); - } - } + pushConstants.TextureIndexA = 0; + pushConstants.ParamA = 0f; + pushConstants.ParamB = 0f; + encoder.SetPushConstants(in pushConstants); } private void BindPipelineWithMesh( @@ -502,23 +400,23 @@ public sealed unsafe partial class EnvCellRenderer } /// - /// Binds one category word for mesh_detail.vert's statically used - /// binding 9. The vertex shader treats only exact uParamB=1 as the - /// object/building-category sentinel; ordinary EnvCell zero and the CLIP - /// references 100/255 and 200/255 therefore keep the environment category. - /// Vulkan still requires the declared descriptor to be valid. + /// Binds one EnvCell category word per instance for the world vertex + /// shader's statically used binding 9. Vulkan requires the declared + /// descriptor to be valid even when detail is unarmed. /// - internal static void BindEnvironmentDetailCategory( + internal void BindEnvironmentDetailCategory( IGpuPassEncoder encoder, - IGpuFrame frame) + IGpuFrame frame, + int instanceCount) { - Span category = stackalloc uint[1]; - category[0] = 1u; + if (_detailCategoryData.Length < instanceCount) + Array.Resize(ref _detailCategoryData, Math.Max(instanceCount, 16)); + Array.Fill(_detailCategoryData, 1u, 0, instanceCount); BindRingSection( encoder, frame, GpuBindingModel.StorageInstanceDetailCategory, - category); + _detailCategoryData.AsSpan(0, instanceCount)); } private void DisposeRhiResources() @@ -531,11 +429,5 @@ public sealed unsafe partial class EnvCellRenderer _clipPipeline = null; _additivePipeline?.Dispose(); _additivePipeline = null; - _detailPipeline?.Dispose(); - _detailPipeline = null; - _transparentDetailPipeline?.Dispose(); - _transparentDetailPipeline = null; - _clipDetailPipeline?.Dispose(); - _clipDetailPipeline = null; } } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 3830ee9a..a397d0df 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -93,10 +93,10 @@ public sealed partial class EnvCellRenderer : private uint[] _clipSlotData = Array.Empty(); // Campaign VM VM1 follow-up: per-instance opacity multiplier, parallel to - // _gpuInstanceTransforms, feeding mesh_modern.vert/mesh_detail.vert's + // _gpuInstanceTransforms, feeding both world vertex families' // InstanceAlphaBuf (binding 7, GpuBindingModel.StorageInstanceAlpha). - // Before this field the shell and interior-detail passes never bound that - // storage buffer at all, so both shaders read whatever section + // Before this field the shell pass never bound that storage buffer at + // all, so the shader read whatever section // WbDrawDispatcher's own SubmitRhi last bound in the same pass — an // unrelated object's opacity array, indexed by these cell instance ids. // EnvCell shells never carry a #188 TransparentPartHook translucency fade @@ -137,6 +137,7 @@ public sealed partial class EnvCellRenderer : // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() private DrawElementsIndirectCommand[] _commands = Array.Empty(); private ModernBatchData[] _modernBatches = Array.Empty(); + private uint[] _detailCategoryData = Array.Empty(); private readonly List _prepareLandblocks = new(); private readonly List _renderInstances = new(); private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new(); @@ -882,7 +883,7 @@ public sealed partial class EnvCellRenderer : EnvCellTransparentRoute transparentRoute, bool detailSurfaceActive) { - // WB EnvCellRenderManager.cs:400: the RHI arm's three pipelines are built + // WB EnvCellRenderManager.cs:400: the RHI arm's four pipelines are built // at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone // answers whether this renderer is ready to draw. if (!_initialized) return; @@ -1312,6 +1313,7 @@ public sealed partial class EnvCellRenderer : // Slice V4t: the slot is the device's, assigned by the // mesh manager at upload rather than interned here. TextureTableIndex = item.batch.TextureSlot.Index, + SurfaceOpacity = item.batch.SurfaceOpacity, TextureIndex = (uint)item.batch.TextureIndex, // #226: this renderer submits built EnvCell meshes. // Retail DrawMesh forwards curr_detail_surface to diff --git a/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs b/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs index 8b84ff1e..8068359b 100644 --- a/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs +++ b/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs @@ -6,7 +6,7 @@ namespace AcDream.App.Rendering.Wb; /// /// Campaign VM VM6: the two BatchData.flags bits that mark a world /// draw batch as swaying foliage — bit 0 remains #226's built-mesh marker -/// (see mesh_detail.frag's vBatchFlags & 1u check) and is +/// (see the world fragment shaders' vBatchFlags & 1u check) and is /// never touched here. mesh_atmospheric.vert and the four /// directional_shadow_world_*.vert caster shaders read these bits /// through the shared foliage_wind.glsl include diff --git a/src/AcDream.App/Rendering/Wb/GroupKey.cs b/src/AcDream.App/Rendering/Wb/GroupKey.cs index 7a7d8981..da5fccfe 100644 --- a/src/AcDream.App/Rendering/Wb/GroupKey.cs +++ b/src/AcDream.App/Rendering/Wb/GroupKey.cs @@ -52,4 +52,5 @@ internal readonly record struct GroupKey( uint TextureLayer, TranslucencyKind Translucency, uint FoliageFlags, + float SurfaceOpacity = 1f, CullMode CullMode = CullMode.CounterClockwise); diff --git a/src/AcDream.App/Rendering/Wb/ModernRenderData.cs b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs index f670a7ec..0c7c599f 100644 --- a/src/AcDream.App/Rendering/Wb/ModernRenderData.cs +++ b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs @@ -6,13 +6,13 @@ namespace AcDream.App.Rendering.Wb { /// Consists of a texture-table slot (Campaign V slice V2; was a raw 64-bit /// bindless handle) and the layer index within the shared/pooled array. /// Indexed by gl_DrawIDARB in the vertex shader. Same 16-byte std430 shape - /// as mesh_modern.vert's BatchData: TextureTableIndex/Reserved/TextureIndex/ + /// as mesh_modern.vert's BatchData: TextureTableIndex/SurfaceOpacity/TextureIndex/ /// Flags at offsets 0/4/8/12 — see ModernBatchDataLayoutTests. /// [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct ModernBatchData { public uint TextureTableIndex; // 4 bytes — slot into the binding=9 handle table - public uint Reserved; // 4 bytes — pad, keeps TextureIndex/Flags at offsets 8/12 + public float SurfaceOpacity; // 4 bytes — authored material alpha public uint TextureIndex; // 4 bytes — layer within the texture array public uint Flags; // 4 bytes — reserved, matches mesh_modern.vert's BatchData.flags } diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 361fcce4..aad48c6e 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -123,6 +123,8 @@ namespace AcDream.App.Rendering.Wb public bool IsTransparent { get; set; } public bool IsAdditive { get; set; } public bool HasWrappingUVs { get; set; } + /// Authored material alpha; base texture alpha is separate. + public float SurfaceOpacity { get; set; } = 1f; // Modern rendering path fields public uint FirstIndex { get; set; } @@ -2263,6 +2265,7 @@ namespace AcDream.App.Rendering.Wb IsTransparent = batch.IsTransparent, IsAdditive = batch.IsAdditive, HasWrappingUVs = batch.HasWrappingUVs, + SurfaceOpacity = batch.SurfaceOpacity, Key = batch.Key, CullMode = batch.CullMode, FirstIndex = firstIndex, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs index 2d2ac957..da960b03 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs @@ -42,14 +42,9 @@ namespace AcDream.App.Rendering.Wb; /// calls the pair (prepare once, draw the whole stream as one range, or as /// several — see OrderPreservingSubmitterTests). /// -/// Scope: this stage proves walk-order submission through the existing -/// RHI on static content (plan §FW2). It does NOT wire the retail -/// building-detail overlay replay (DrawBuildingDetailRangeRhi's second -/// pass through RetailDetail/RetailDetailTransparent) — a -/// detail-category command still forces a solo merge run (mirroring the -/// deferred-alpha detail break), but this submitter issues only the -/// base-pipeline draw for it. The overlay replay is production wiring, -/// deferred to whichever stage cuts the walk over for real content. +/// OVERHAUL S5-c4 arms retail detail on a detail-category command's +/// existing solo base run. It remains one draw and therefore introduces no +/// blended overlay or additional terrain/StreamMark flush boundary. /// public sealed unsafe partial class WbDrawDispatcher { @@ -395,6 +390,7 @@ public sealed unsafe partial class WbDrawDispatcher _batchData[i] = new BatchData { TextureIndex = key.TextureSlot.Index, + SurfaceOpacity = key.SurfaceOpacity, TextureLayer = key.TextureLayer, Flags = 1u | key.FoliageFlags, }; @@ -545,6 +541,10 @@ public sealed unsafe partial class WbDrawDispatcher IGpuBuffer commandBuffer = _orderedCommands.Buffer!; uint commandBase = _orderedCommands.OffsetBytes; int rangeEnd = firstCommand + commandCount; + bool detailEnabled = RetailDetailTextureContract.ShouldRender( + _buildingDetailEnabled(), + _buildingDetail) + && _buildingDetail.Tiling != 0f; foreach (OrderedMergeRun run in _orderedRuns) { @@ -569,6 +569,11 @@ public sealed unsafe partial class WbDrawDispatcher PipelineBucket bucket = BucketFor(_orderedStream.Keys[run.FirstCommand].Translucency); IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket); pushConstants.RenderPass = bucket == PipelineBucket.Opaque ? 0 : 1; + if (detailEnabled + && _orderedStream.DetailCategories[run.FirstCommand] != 0u) + ArmBuildingDetail(ref pushConstants); + else + ClearDetailPushConstants(ref pushConstants); BindPipelineWithMesh(encoder, pipeline, global); DrawIndirectRangeRhi( @@ -580,6 +585,9 @@ public sealed unsafe partial class WbDrawDispatcher run.FirstCommand, run.CommandCount); } + + ClearDetailPushConstants(ref pushConstants); + encoder.SetPushConstants(in pushConstants); } /// diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index 1c26d668..b4091101 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -33,7 +33,7 @@ public sealed unsafe partial class WbDrawDispatcher private readonly IWorldPassScope? _scope; /// - /// The seven mesh pipelines at ONE sample count. + /// The five retail material/blend pipelines at ONE sample count. /// /// Campaign V slice V6l: there are two of these. Vulkan requires a /// pipeline's rasterizationSamples to equal the pass it draws in, and @@ -52,16 +52,13 @@ public sealed unsafe partial class WbDrawDispatcher IGpuPipeline OpaqueAlphaToCoverage, IGpuPipeline AlphaBlend, IGpuPipeline AlphaAdditive, - IGpuPipeline AlphaInverse, - IGpuPipeline RetailDetail, - IGpuPipeline RetailDetailTransparent); + IGpuPipeline AlphaInverse); private MeshPipelineSet? _backbufferPipelines; private MeshPipelineSet? _offscreenPipelines; private const string OpaqueTimerScope = "wb-entities-opaque"; private const string TransparentTimerScope = "wb-entities-transparent"; - private const string DetailTimerScope = "wb-buildings-detail"; private readonly TerrainAtlas.RetailDetailTextureBinding _buildingDetail; private readonly Func _buildingDetailEnabled; @@ -158,8 +155,7 @@ public sealed unsafe partial class WbDrawDispatcher /// /// The RHI arm's constructor. No GL context, no Shader, no - /// BindlessSupport: five base pipelines compile mesh_modern and - /// the detail overlay compiles mesh_detail from committed SPIR-V; + /// BindlessSupport: five base pipelines compile mesh_modern; /// batch data already carries the device's own /// GpuTextureSlot (V4t) rather than a bindless handle. /// @@ -231,7 +227,7 @@ public sealed unsafe partial class WbDrawDispatcher bool usesRenderPackShaderAbi = false) { string suffix = samples > 1 ? string.Empty : "-1x"; - var created = new List(7); + var created = new List(5); try { return new MeshPipelineSet( @@ -260,28 +256,6 @@ public sealed unsafe partial class WbDrawDispatcher device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples, shaders: baseShaders, shaderName: baseShaderName, - usesRenderPackShaderAbi: usesRenderPackShaderAbi)), - Track(CreateMeshPipeline( - device, - $"wb-mesh-retail-detail{suffix}", - GpuBlendMode.RetailDetail, - true, - false, - samples, - shaderName: "mesh_detail", - depthCompare: RetailDetailTextureContract.DetailDepthCompare( - transparent: false), - usesRenderPackShaderAbi: usesRenderPackShaderAbi)), - Track(CreateMeshPipeline( - device, - $"wb-mesh-retail-detail-alpha{suffix}", - GpuBlendMode.RetailDetail, - false, - false, - samples, - shaderName: "mesh_detail", - depthCompare: RetailDetailTextureContract.DetailDepthCompare( - transparent: true), usesRenderPackShaderAbi: usesRenderPackShaderAbi))); } catch @@ -441,10 +415,12 @@ public sealed unsafe partial class WbDrawDispatcher transformBaseInstance); IGpuBuffer commandBuffer = commands.Buffer; uint commandBase = commands.OffsetBytes; - ReadOnlySpan usedCommands = - _indirectCommands.AsSpan(0, totalDraws); ReadOnlySpan usedDetailCategories = _detailCategoryData.AsSpan(0, immediateInstances); + bool detailEnabled = RetailDetailTextureContract.ShouldRender( + _buildingDetailEnabled(), + _buildingDetail) + && _buildingDetail.Tiling != 0f; // ── Phase 7: opaque pass ───────────────────────────────────────────── if (_opaqueDrawCount > 0) @@ -459,67 +435,12 @@ public sealed unsafe partial class WbDrawDispatcher encoder.SetPushConstants(in pushConstants); using (BeginRhiTimer(encoder, diag, OpaqueTimerScope)) { - DrawIndirectRangeRhi( + DrawDetailAwareRangeRhi( encoder, ref pushConstants, commandBuffer, commandBase, - 0, _opaqueDrawCount); + 0, _opaqueDrawCount, usedDetailCategories, detailEnabled); } } - // Retail DrawBuilding detail category (1). Only consecutive command - // runs containing a building are replayed; mesh_detail filters ordinary - // instances inside a mixed command. This includes ClipMap built-mesh - // subsets: the named retail - // DrawMesh path forwards curr_detail_surface to RenderMeshSubset for - // every material kind. The setting is read at draw time so the existing - // Options checkbox changes the live scene immediately. - if (_opaqueDrawCount > 0 - && RetailDetailTextureContract.ShouldRender( - _buildingDetailEnabled(), - _buildingDetail)) - { - int searchStart = 0; - if (TryGetNextDetailCommandRun( - usedCommands, - usedDetailCategories, - searchStart, - _opaqueDrawCount, - out DetailCommandRun run)) - { - BindPipelineWithMesh(encoder, pipelines.RetailDetail, mesh); - pushConstants.RenderPass = 0; - pushConstants.DrawIdOffset = 0; - pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index; - pushConstants.ParamA = _buildingDetail.Tiling; - pushConstants.ParamB = 1f; - encoder.SetPushConstants(in pushConstants); - using (BeginRhiTimer(encoder, diag, DetailTimerScope)) - { - do - { - DrawIndirectRangeRhi( - encoder, - ref pushConstants, - commandBuffer, - commandBase, - run.FirstCommand, - run.CommandCount); - searchStart = run.FirstCommand + run.CommandCount; - } - while (TryGetNextDetailCommandRun( - usedCommands, - usedDetailCategories, - searchStart, - _opaqueDrawCount, - out run)); - } - } - - // Later base passes expect neutral spare push fields. - pushConstants.TextureIndexA = 0; - pushConstants.ParamA = 0f; - pushConstants.ParamB = 0f; - } - // ── Phase 8: transparent pass ──────────────────────────────────────── if (_transparentDrawCount > 0) { @@ -538,7 +459,8 @@ public sealed unsafe partial class WbDrawDispatcher ref pushConstants, commandBuffer, commandBase, - usedDetailCategories); + usedDetailCategories, + detailEnabled); } } @@ -594,7 +516,7 @@ public sealed unsafe partial class WbDrawDispatcher /// difference between the two call sites is /// (the drain reads the deferred scope's own _deferredAlphaViewProjection; /// an immediate draw uses whatever the CURRENT frame's viewProjection - /// is) and whether a building-detail overlay pass follows. + /// is). The immediate site additionally arms detail on its original draw. /// private GpuPushConstants BindAlphaDrawState( IGpuPassEncoder encoder, @@ -703,14 +625,12 @@ public sealed unsafe partial class WbDrawDispatcher /// /// S4-c2 fix round 1 (M3b): DrawMesh row 1's "render immediately; /// enqueue nothing" outcome for ONE translucent building-shell instance - /// under building detail — bind the blend pipeline, one indirect draw, - /// then the detail pass, reusing and - /// (the same code - /// used to use) for the single - /// slot just + /// under building detail — arm the retail one-pass material on the + /// original blend pipeline and submit the single slot + /// just /// prepared. The caller only reaches this method when - /// detailSurfaceActive was true, so the detail pass always - /// applies here — the drain's own per-command detail CHECK is gone + /// detailSurfaceActive was true, so detail always applies here — + /// the drain's own per-command detail CHECK is gone /// (M3c) precisely because detail-eligible content no longer reaches it. /// private void DrawImmediateAlphaInstanceRhi( @@ -726,6 +646,7 @@ public sealed unsafe partial class WbDrawDispatcher BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh); encoder.SetPushConstants(in pushConstants); + ArmBuildingDetail(ref pushConstants); DrawIndirectRangeRhi( encoder, ref pushConstants, @@ -733,16 +654,8 @@ public sealed unsafe partial class WbDrawDispatcher _alphaCommands.OffsetBytes, startCommand: 0, commandCount: 1); - - DrawBuildingDetailRangeRhi( - encoder, - mesh, - pipelines.RetailDetailTransparent, - ref pushConstants, - _alphaCommands.Buffer!, - _alphaCommands.OffsetBytes, - firstCommand: 0, - commandCount: 1); + ClearDetailPushConstants(ref pushConstants); + encoder.SetPushConstants(in pushConstants); } private void DrawImmediateTransparentRhi( @@ -752,21 +665,21 @@ public sealed unsafe partial class WbDrawDispatcher ref GpuPushConstants pushConstants, IGpuBuffer commandBuffer, uint commandBase, - ReadOnlySpan usedDetailCategories) + ReadOnlySpan usedDetailCategories, + bool detailEnabled) { - bool detailEnabled = RetailDetailTextureContract.ShouldRender( - _buildingDetailEnabled(), - _buildingDetail); int command = _opaqueDrawCount; int end = command + _transparentDrawCount; while (command < end) { + TranslucencyKind blend = _groupInputScratch[command].Translucency; bool hasDetail = detailEnabled && CommandContainsDetailCategory( _indirectCommands[command], usedDetailCategories); int runEnd = command + 1; while (runEnd < end + && _groupInputScratch[runEnd].Translucency == blend && !hasDetail && (!detailEnabled || !CommandContainsDetailCategory( @@ -776,8 +689,14 @@ public sealed unsafe partial class WbDrawDispatcher runEnd++; } - BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh); - ClearDetailPushConstants(ref pushConstants); + BindPipelineWithMesh( + encoder, + PipelineForBlend(pipelines, blend), + mesh); + if (hasDetail) + ArmBuildingDetail(ref pushConstants); + else + ClearDetailPushConstants(ref pushConstants); DrawIndirectRangeRhi( encoder, ref pushConstants, @@ -785,45 +704,60 @@ public sealed unsafe partial class WbDrawDispatcher commandBase, command, runEnd - command); - - if (hasDetail) - { - DrawBuildingDetailRangeRhi( - encoder, - mesh, - pipelines.RetailDetailTransparent, - ref pushConstants, - commandBuffer, - commandBase, - command, - 1); - } command = runEnd; } + ClearDetailPushConstants(ref pushConstants); + encoder.SetPushConstants(in pushConstants); } - private void DrawBuildingDetailRangeRhi( + private void DrawDetailAwareRangeRhi( IGpuPassEncoder encoder, - GlobalMeshBuffer mesh, - IGpuPipeline detailPipeline, ref GpuPushConstants pushConstants, IGpuBuffer commandBuffer, uint commandBase, int firstCommand, - int commandCount) + int commandCount, + ReadOnlySpan detailCategories, + bool detailEnabled) + { + int command = firstCommand; + int end = firstCommand + commandCount; + while (command < end) + { + bool hasDetail = detailEnabled + && CommandContainsDetailCategory( + _indirectCommands[command], detailCategories); + int runEnd = command + 1; + while (runEnd < end + && !hasDetail + && (!detailEnabled + || !CommandContainsDetailCategory( + _indirectCommands[runEnd], detailCategories))) + { + runEnd++; + } + + if (hasDetail) + ArmBuildingDetail(ref pushConstants); + else + ClearDetailPushConstants(ref pushConstants); + DrawIndirectRangeRhi( + encoder, + ref pushConstants, + commandBuffer, + commandBase, + command, + runEnd - command); + command = runEnd; + } + ClearDetailPushConstants(ref pushConstants); + encoder.SetPushConstants(in pushConstants); + } + + private void ArmBuildingDetail(ref GpuPushConstants pushConstants) { - BindPipelineWithMesh(encoder, detailPipeline, mesh); pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index; pushConstants.ParamA = _buildingDetail.Tiling; - pushConstants.ParamB = 1f; - DrawIndirectRangeRhi( - encoder, - ref pushConstants, - commandBuffer, - commandBase, - firstCommand, - commandCount); - ClearDetailPushConstants(ref pushConstants); } private static void ClearDetailPushConstants( @@ -1139,11 +1073,6 @@ public sealed unsafe partial class WbDrawDispatcher totalMs += transparentMs; any = true; } - if (_device.Timers.TryResolve(DetailTimerScope, out double detailMs)) - { - totalMs += detailMs; - any = true; - } if (!any) return; @@ -1174,8 +1103,6 @@ public sealed unsafe partial class WbDrawDispatcher pipelines.AlphaBlend.Dispose(); pipelines.AlphaAdditive.Dispose(); pipelines.AlphaInverse.Dispose(); - pipelines.RetailDetail.Dispose(); - pipelines.RetailDetailTransparent.Dispose(); } private sealed class NullRhiTimerScope : IDisposable diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index af9cabe6..e9dbdba8 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -241,6 +241,7 @@ public sealed partial class WbDrawDispatcher batch.FirstIndex, (int)batch.BaseVertex, batch.IndexCount, texture.Slot, texture.Layer, translucency, FoliageFlags: foliageFlags, + SurfaceOpacity: batch.SurfaceOpacity, CullMode: batch.CullMode); return true; } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 8fb6b34d..dd34acb5 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -617,8 +617,8 @@ public sealed partial class WbDrawDispatcher : IDisposable private int _transparentDrawCount; private int _transparentByteOffset; - // Campaign V slice V2 (2026-07-27): std430 layout: uint TextureIndex at - // offset 0, uint Reserved (pad) at offset 4, uint TextureLayer at offset 8, + // std430 layout: uint TextureIndex at offset 0, float SurfaceOpacity at + // offset 4, uint TextureLayer at offset 8, // uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so // every existing CPU writer's offsets are unchanged (see // GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a @@ -630,7 +630,7 @@ public sealed partial class WbDrawDispatcher : IDisposable private struct BatchData { public uint TextureIndex; // slot into the device texture table - public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12 + public float SurfaceOpacity; public uint TextureLayer; public uint Flags; } @@ -1947,6 +1947,7 @@ public sealed partial class WbDrawDispatcher : IDisposable _batchData[i] = new BatchData { TextureIndex = _batchPublicScratch[i].TextureIndex, + SurfaceOpacity = _batchPublicScratch[i].SurfaceOpacity, TextureLayer = _batchPublicScratch[i].TextureLayer, Flags = _batchPublicScratch[i].Flags, }; @@ -2049,6 +2050,7 @@ public sealed partial class WbDrawDispatcher : IDisposable TextureIndex: g.TextureSlot.Index, TextureLayer: g.TextureLayer, Translucency: g.Translucency, + SurfaceOpacity: g.SurfaceOpacity, CullMode: g.CullMode, FoliageFlags: g.FoliageFlags); @@ -2122,6 +2124,7 @@ public sealed partial class WbDrawDispatcher : IDisposable g.TextureLayer, g.Translucency, g.FoliageFlags, + g.SurfaceOpacity, g.CullMode); /// @@ -2143,6 +2146,7 @@ public sealed partial class WbDrawDispatcher : IDisposable Translucency = key.Translucency, CullMode = key.CullMode, FoliageFlags = key.FoliageFlags, + SurfaceOpacity = key.SurfaceOpacity, Registration = registration, LastUsedFrame = frame, }; @@ -2478,7 +2482,8 @@ public sealed partial class WbDrawDispatcher : IDisposable { byte mask = RetailAlphaMeshRouter.MaskFromTranslucencyKind(kind); bool detailSurfaceActive = isBuildingShell - && RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _buildingDetail); + && RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _buildingDetail) + && _buildingDetail.Tiling != 0f; RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route( currentlyDrawingSky: false, delayMask: RetailAlphaMeshRouter.DefaultDelayMask, @@ -2558,11 +2563,12 @@ public sealed partial class WbDrawDispatcher : IDisposable { // Campaign V slice V2: table slot, not the raw handle. TextureIndex = key.TextureSlot.Index, + SurfaceOpacity = key.SurfaceOpacity, TextureLayer = key.TextureLayer, // DrawMesh invokes RenderMeshSubset with detail enabled for // every built-mesh material subset while curr_detail_surface // is installed. The per-instance category still rejects - // ordinary objects in mesh_detail. Review fix round 2 (F2): + // ordinary objects in the one-pass fragment arm. Review fix round 2 (F2): // bit 0 (the #226 built-mesh marker) is unconditional here — // every delayed-alpha entry IS a built-mesh alpha submission // — but the previous hardcoded `1` silently dropped bits 1/2 @@ -3431,6 +3437,7 @@ public sealed partial class WbDrawDispatcher : IDisposable uint TextureIndex, uint TextureLayer, TranslucencyKind Translucency, + float SurfaceOpacity = 1f, CullMode CullMode = CullMode.CounterClockwise, // Campaign VM VM6: FoliageWindClassification.CutoutFoliageFlag / // TrunkFlag, OR'd into BatchDataPublic.Flags alongside #226's bit 0 @@ -3445,7 +3452,7 @@ public sealed partial class WbDrawDispatcher : IDisposable public struct BatchDataPublic { public uint TextureIndex; - public uint Reserved; + public float SurfaceOpacity; public uint TextureLayer; public uint Flags; } @@ -3496,7 +3503,7 @@ public sealed partial class WbDrawDispatcher : IDisposable var bd = new BatchDataPublic { TextureIndex = g.TextureIndex, - Reserved = 0, + SurfaceOpacity = g.SurfaceOpacity, TextureLayer = g.TextureLayer, // #226: this is the built-mesh path. Retail DrawMesh passes // curr_detail_surface through RenderMeshSubset for opaque, @@ -3668,6 +3675,7 @@ public sealed partial class WbDrawDispatcher : IDisposable AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned; public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas. public TranslucencyKind Translucency; + public float SurfaceOpacity = 1f; public CullMode CullMode; public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) public int InstanceCount; @@ -3717,7 +3725,7 @@ public sealed partial class WbDrawDispatcher : IDisposable public readonly List IndoorFlags = new(); // #226: 1 for a building-shell instance, 0 otherwise. Parallel to - // Matrices and uploaded at storage binding 9 for the detail replay. + // Matrices and uploaded at storage binding 9 for one-pass eligibility. public readonly List DetailCategories = new(); // #188: per-instance opacity multiplier, parallel to Matrices. diff --git a/src/AcDream.App/Rendering/WorldDepthContract.cs b/src/AcDream.App/Rendering/WorldDepthContract.cs index e0c260be..215ec4cd 100644 --- a/src/AcDream.App/Rendering/WorldDepthContract.cs +++ b/src/AcDream.App/Rendering/WorldDepthContract.cs @@ -32,10 +32,8 @@ namespace AcDream.App.Rendering; /// /// Scope: WORLD-SPACE geometry only (terrain, EnvCell shells, entity /// meshes, particles, portal punches, the directional shadow caster/receiver -/// pair). The two RetailDetail pipelines -/// ('s Equal/LessOrEqual -/// pair, used by the building-detail overlay replay) are a documented -/// exception with their own citation and are untouched by this contract. +/// pair). Detail-active subsets retain those same base pipeline depth states; +/// their material combine occurs inside the original fragment shader. /// internal static class WorldDepthContract { diff --git a/src/AcDream.App/Rendering/WorldPassSurface.cs b/src/AcDream.App/Rendering/WorldPassSurface.cs index 005e3402..fd671293 100644 --- a/src/AcDream.App/Rendering/WorldPassSurface.cs +++ b/src/AcDream.App/Rendering/WorldPassSurface.cs @@ -65,7 +65,7 @@ internal interface IWorldPassSurface /// world pass now: portal_depth.vert (the exit seals and punch /// fans, planes handed per draw through the TerrainClip UBO). S3 review /// fix round 1 (F5) deleted the mesh/particle vertex shaders' - /// (mesh_modern, mesh_atmospheric, mesh_detail, + /// (mesh_modern, mesh_atmospheric, /// particle, particle_mesh) own per-cell fetch and /// gl_ClipDistance loop outright: the per-cell clip-region table /// used to publish for them had no path diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index 8bc7c2fd..91277a95 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -125,7 +125,7 @@ namespace AcDream.App.UI.Layout; /// Distance are LIVE through S5-c3's shared building-degrade owner. Landscape /// Draw Distance is LIVE through ApplyLandscapeDrawDistance. Building /// Detail Textures is LIVE: #226 consumes it directly in the retail -/// building/EnvCell detail pass; +/// one-pass building/EnvCell detail material; /// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look /// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no /// persistent mouse-turning camera mode exists for ANY of these six to @@ -1294,7 +1294,7 @@ public static class ConfigOptionsPageController // Every row except Building Detail Textures is still store-only: // #226 consumes that existing preference in the retail building and - // EnvCell detail replay. The other rows remain aggregate-preset gaps. + // EnvCell one-pass detail material. The other rows remain aggregate-preset gaps. BuildMenuRow( listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString, read: () => bindings.LoadDisplay().LandscapeTextureDetail, @@ -1336,7 +1336,7 @@ public static class ConfigOptionsPageController listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, read: () => bindings.LoadDisplay().BuildingDetailTextures, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }), - storeOnly: false); // LIVE — #226 retail building/EnvCell detail pass + storeOnly: false); // LIVE — #226 retail building/EnvCell detail material BuildToggleRow( listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString, diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index 59f7e8d1..9d6a0c78 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -599,6 +599,10 @@ public sealed class MeshExtractor { Translucency = TranslucencyKindExtensions.FromSurfaceType( surface.Type), + SurfaceOpacity = + TranslucencyKindExtensions.OpacityFromSurfaceTranslucency( + surface.Type, + surface.Translucency), IsTransparent = isTransparent, IsAdditive = isAdditive }; @@ -1072,6 +1076,10 @@ public sealed class MeshExtractor { UploadPixelFormat = uploadPixelFormat, UploadPixelType = uploadPixelType, Translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type), + SurfaceOpacity = + TranslucencyKindExtensions.OpacityFromSurfaceTranslucency( + surface.Type, + surface.Translucency), IsTransparent = isTransparent, IsAdditive = isAdditive, }; diff --git a/src/AcDream.Content/ObjectMeshData.cs b/src/AcDream.Content/ObjectMeshData.cs index afa0ef71..1966df3f 100644 --- a/src/AcDream.Content/ObjectMeshData.cs +++ b/src/AcDream.Content/ObjectMeshData.cs @@ -174,6 +174,13 @@ public class TextureBatchData { public bool IsAdditive { get; set; } public bool HasWrappingUVs { get; set; } + /// + /// Retail material alpha derived from the resolved source Surface. This is + /// 1 - Surface.Translucency when the Translucent bit is set, and 1 + /// otherwise. It is distinct from decoded base-texture alpha. + /// + public float SurfaceOpacity { get; set; } = 1f; + /// /// Retail source surface-array index this subset was constructed from /// (contract §3.6) — the CellStruct subset/material OWNER, not the diff --git a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs index 03fc7c2b..f8e740ca 100644 --- a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs +++ b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs @@ -228,6 +228,9 @@ public static class ObjectMeshDataSerializer { w.Write(batch.RetailSurfaceMask); w.Write(batch.RawSurfaceType); w.Write(batch.IsCellShell); + // OVERHAUL S5-c4: recipe 9 appends authored material opacity. Recipe + // identity rejects older payloads before this serializer is entered. + w.Write(batch.SurfaceOpacity); } private static TextureBatchData ReadTextureBatchData( @@ -252,6 +255,7 @@ public static class ObjectMeshDataSerializer { batch.RetailSurfaceMask = r.ReadByte(); batch.RawSurfaceType = r.ReadUInt32(); batch.IsCellShell = r.ReadBoolean(); + batch.SurfaceOpacity = r.ReadSingle(); return batch; } diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs index 4b20b9d3..43171d9f 100644 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -43,10 +43,11 @@ public static class PakFormat { /// source surface-array index rather than TextureKey/stippling/sides, /// and built-EnvCell admission is /// (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 applied after - /// surface resolution. The binary format remains version 2, but every - /// prepared CellStruct/EnvCell render record must be regenerated. + /// surface resolution. Version 9 appends authored surface opacity to + /// every prepared mesh texture batch. The binary format remains version + /// 2, but every prepared render record must be regenerated. /// - public const uint CurrentBakeToolVersion = 8; + public const uint CurrentBakeToolVersion = 9; } /// diff --git a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs index 9d77f786..3dae6459 100644 --- a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs +++ b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs @@ -57,6 +57,10 @@ public static class ContentMigrationCatalog 7, 8, "exact CellStruct surface-index subset construction"), + [9] = FullRebuild( + 8, + 9, + "authored surface opacity in prepared mesh batches"), }; public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion) diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index 25e36958..349ef622 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -38,9 +38,10 @@ public sealed class LauncherInstallRecordStore // Version 7 keeps pak format 2 and regenerates every GfxObj render record // with retail's authored DrawingBSP view sphere. Version 8 (OH2/S1) keeps // pak format 2 and regenerates every CellStruct/EnvCell render record - // with retail's exact surface-array-index subset construction. Both are - // mandatory full rebuilds from their predecessor recipe. - public const uint CurrentBakeToolVersion = 8; + // with retail's exact surface-array-index subset construction. Version 9 + // carries authored surface opacity in prepared mesh batches. Each is a + // mandatory full rebuild from its predecessor recipe. + public const uint CurrentBakeToolVersion = 9; private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs index 7dbbec94..945aaf2c 100644 --- a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs @@ -90,11 +90,12 @@ public static class RenderPackSpirvValidator new([0u, 1u], [], true, true, true, false, false), RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver => new([], [1u, 2u, 3u], true, false, true, false, true), - // Campaign VM VM6: mesh_atmospheric.vert now reads - // uAtmosphereClockWind/uAtmosphereWindAmplitude for the same - // foliage-wind displacement the casters apply. + // Campaign VM VM6: mesh_atmospheric.vert reads the atmospheric + // clock/wind inputs for the same foliage displacement the casters + // apply. Campaign OVERHAUL S5-c4 adds binding 9 for the per-instance + // detail category used by the retail one-pass material combine. RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver => - new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u], [1u], + new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u], [1u], true, true, true, false, true), _ => new( [], diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs index a4fd0af4..1a813742 100644 --- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs @@ -102,7 +102,7 @@ public sealed class DirectionalShadowReceiverTests Assert.Contains("instanceIndoor[instanceIndex]", vertex, StringComparison.Ordinal); Assert.Contains("vSelectionLighting", fragment, StringComparison.Ordinal); Assert.Contains("vOpacityMultiplier", fragment, StringComparison.Ordinal); - Assert.Contains("if (color.a < 0.05) discard", fragment, StringComparison.Ordinal); + Assert.Contains("color.a < alphaCutoff", fragment, StringComparison.Ordinal); Assert.Contains("ACDREAM_SAMPLE_ARRAY", fragment, StringComparison.Ordinal); Assert.Contains("combineOverlays", terrain, StringComparison.Ordinal); Assert.Contains("combineRoad", terrain, StringComparison.Ordinal); @@ -115,12 +115,13 @@ public sealed class DirectionalShadowReceiverTests Assert.Contains("radius = clamp(radius, 0, 2)", receiver, StringComparison.Ordinal); Assert.Contains("float softness = max(uShadowControl.y, 1.0)", receiver, StringComparison.Ordinal); - string detailVertex = File.ReadAllText(Path.Combine( - shaderRoot, - "mesh_detail.vert")); - Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", detailVertex, StringComparison.Ordinal); - Assert.Contains("Instances[transformIndex].transform", detailVertex, StringComparison.Ordinal); - Assert.Contains("instanceDetailCategory[instanceIndex]", detailVertex, StringComparison.Ordinal); + foreach (string worldVertexName in new[] { "mesh_modern.vert", "mesh_atmospheric.vert" }) + { + string worldVertex = File.ReadAllText(Path.Combine(shaderRoot, worldVertexName)); + Assert.Contains("Instances[", worldVertex, StringComparison.Ordinal); + Assert.Contains("].transform", worldVertex, StringComparison.Ordinal); + Assert.Contains("instanceDetailCategory[instanceIndex]", worldVertex, StringComparison.Ordinal); + } } private static string RepositoryRoot() diff --git a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs index 5e5c2df4..3d5b7b6f 100644 --- a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu.Vk; @@ -74,34 +75,53 @@ public sealed class EnvCellAlphaDrawSourceTests DrawPipelineNames(fixture.Device)); } - [Fact] - public void DetailOn_DrawsAtCellTurnWithDetail_AndQueuesNothing() + [Theory] + [InlineData(0, "envcell-opaque", 0f)] + [InlineData(1, "envcell-alpha", 0f)] + [InlineData(2, "envcell-additive", 0f)] + [InlineData(3, "envcell-clip", 200f / 255f)] + [InlineData(4, "envcell-clip", 100f / 255f)] + public void DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity( + int specIndex, + string expectedPipeline, + float expectedReference) { + BatchSpec spec = specIndex switch + { + 0 => BatchSpec.Opaque, + 1 => BatchSpec.Alpha, + 2 => BatchSpec.Additive, + 3 => BatchSpec.ClipDds, + 4 => BatchSpec.ClipPaletted, + _ => throw new ArgumentOutOfRangeException(nameof(specIndex)), + }; using var fixture = new ProductionEnvCellFixture( detailSurfaceActive: true, - BatchSpec.ClipDds); + spec); fixture.Queue.BeginFrame(); fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId); - Assert.Equal(2, fixture.Device.Calls.OfType().Count()); + Assert.Single(fixture.Device.Calls.OfType()); Assert.Equal( - ["envcell-clip", "envcell-retail-detail-clip"], + [expectedPipeline], DrawPipelineNames(fixture.Device)); Assert.All( DrawPushConstants(fixture.Device), - constants => Assert.Equal(200f / 255f, constants.ParamB)); - GpuPipelineDescription detailPipeline = fixture.Device.CreatedPipelines - .Single(static pipeline => - pipeline.Description.Name == "envcell-retail-detail-clip") - .Description; - Assert.Equal(GpuBlendMode.RetailDetail, detailPipeline.Blend); - Assert.True(detailPipeline.Depth.Test); - Assert.True(detailPipeline.Depth.Write); - Assert.Equal(GpuCompareOp.Equal, detailPipeline.Depth.Compare); + constants => Assert.Equal(expectedReference, constants.ParamB)); + GpuPushConstants armed = Assert.Single( + DrawPushConstants(fixture.Device), + constants => constants.ParamA != 0f); + Assert.NotEqual(0u, armed.TextureIndexA); Assert.Equal(0, fixture.Queue.PendingCount); + GpuRecordedStorageBind batchBind = fixture.Device.Calls + .OfType() + .Last(call => call.Binding == GpuBindingModel.StorageBatches); + ReadOnlySpan gpuBatches = MemoryMarshal.Cast( + fixture.Device.RingBytes.Slice((int)batchBind.OffsetBytes, (int)batchBind.SizeBytes)); + Assert.Equal(0.25f, Assert.Single(gpuBatches.ToArray()).SurfaceOpacity); fixture.Queue.EndFrame(); - Assert.Equal(2, fixture.Device.Calls.OfType().Count()); + Assert.Single(fixture.Device.Calls.OfType()); } /// Two cell tokens from the SAME , @@ -232,28 +252,23 @@ public sealed class EnvCellAlphaDrawSourceTests string root = RepositoryRoot(); string modern = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.frag")); - string detail = File.ReadAllText(Path.Combine( - root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.frag")); - string detailVertex = File.ReadAllText(Path.Combine( - root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.vert")); + string atmospheric = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_atmospheric.frag")); - Assert.Contains("if (color.a < alphaCutoff) discard;", modern, StringComparison.Ordinal); - Assert.DoesNotContain("if (color.a <= alphaCutoff) discard;", modern, StringComparison.Ordinal); - Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", modern, StringComparison.Ordinal); - Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", detail, StringComparison.Ordinal); - foreach (string shader in new[] { modern, detail }) + foreach (string shader in new[] { modern, atmospheric }) { + Assert.Contains( + "if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff)", + shader, + StringComparison.Ordinal); + Assert.DoesNotContain("alpha <= alphaCutoff", shader, StringComparison.Ordinal); + Assert.DoesNotContain("color.a <= alphaCutoff", shader, StringComparison.Ordinal); + Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", shader, StringComparison.Ordinal); Assert.Contains("abs(value - (100.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal); Assert.Contains("abs(value - (200.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal); Assert.DoesNotContain("uParamB > 0.0", shader, StringComparison.Ordinal); Assert.DoesNotContain("value - 0.5", shader, StringComparison.Ordinal); } - Assert.Contains("if (base.a < alphaCutoff)", detail, StringComparison.Ordinal); - Assert.DoesNotContain("if (base.a <= alphaCutoff)", detail, StringComparison.Ordinal); - Assert.Contains( - "vDetailCategory = uParamB == 1.0", - detailVertex, - StringComparison.Ordinal); } [Theory] @@ -438,6 +453,12 @@ public sealed class EnvCellAlphaDrawSourceTests IsTransparent: true, AcDream.Core.Meshing.TranslucencyKind.AlphaBlend, PaletteId: 0); + + internal static BatchSpec Additive { get; } = new( + RetailAlphaMeshRouter.MaskAlphaFamily, + IsTransparent: true, + AcDream.Core.Meshing.TranslucencyKind.Additive, + PaletteId: 0); } /// @@ -501,7 +522,9 @@ public sealed class EnvCellAlphaDrawSourceTests TextureData = new byte[8 * 8 * 4], Indices = [0, 1, 2], IsTransparent = spec.IsTransparent, + IsAdditive = spec.Translucency == AcDream.Core.Meshing.TranslucencyKind.Additive, Translucency = spec.Translucency, + SurfaceOpacity = 0.25f, RetailSurfaceMask = spec.Mask, CullMode = CullMode.Clockwise, IsCellShell = true, diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs index d51f1742..c42b9cd7 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -37,9 +37,8 @@ public sealed class GpuContractTests [Fact] public void StorageBindingsMatchTheShaderSources() { - // mesh_modern.vert declares std430 bindings 0..8 in exactly this order; - // #226's mesh_detail.vert additionally declares binding 9 for the - // per-instance building category. + // Both world vertex families declare std430 bindings 0..9 in exactly + // this order; binding 9 carries per-instance detail eligibility. Assert.Equal(0u, GpuBindingModel.StorageInstances); Assert.Equal(1u, GpuBindingModel.StorageBatches); Assert.Equal(2u, GpuBindingModel.StorageClipRegions); @@ -79,15 +78,14 @@ public sealed class GpuContractTests // WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT // surface's TranslucencyKind. Retail also selects ONE/INVSRCALPHA for a // pure Base1ClipMap with override=false (SetSurface @0x0059c72a), while - // the detail combine is a separate renderer state. Collapsing any of + // detail is now computed inside those same pipelines. Collapsing any of // these modes changes the corresponding subset's composition. - Assert.Equal(6, Enum.GetValues().Length); + Assert.Equal(5, Enum.GetValues().Length); Assert.Contains(GpuBlendMode.None, Enum.GetValues()); Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues()); Assert.Contains(GpuBlendMode.PremultipliedAlpha, Enum.GetValues()); Assert.Contains(GpuBlendMode.Additive, Enum.GetValues()); Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues()); - Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues()); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 0c644465..2a1f7c09 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -41,23 +41,15 @@ public sealed class VulkanShaderManifestTests { ["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df", ["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140", - // Re-pinned 2026-09-04 (Campaign OVERHAUL S4-c2 owner-authorized - // repair): Vulkan expresses SetSurface's pure-ClipMap alpha test - // through uParamB, recognizing only the paletted 100/255 and DDS - // 200/255 references and using discard-on-less so equality passes. - // The paired binary selects those globals and - // D3DCMP_GREATEREQUAL at 0x0059c73c..0x0059c838. - ["mesh_modern.frag.spv"] = "1b3a59d0cc7c5bb6acf793d57d9673d860a7eb50bdd51770781f59feac4604a1", - // mesh_modern.vert re-pinned 2026-09-03 (S3 review fix round 1, - // F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its - // gl_ClipDistance loop, and the gl_PerVertex redeclaration are - // deleted outright — the ONE WbDrawDispatcher API that could - // ever arm per-instance clip routing had ZERO production - // callers, so every instance has always mapped to slot - // 0 (no-clip) in every shipped build; the shader-side clip test - // clipped nothing. binding=3 instanceClipSlot[] stays declared, - // unread (fed 0; the instance-buffer layout is S5's). - ["mesh_modern.vert.spv"] = "f9ed4ee7140ccd136130559dbea68545f733f6022f52b085b6df4bcb787223c6", + // Re-pinned 2026-09-04 (Campaign OVERHAUL S5-c4): retail's + // single-pass building detail material now runs inside the base + // fragment. Final alpha is authoredOpacity*liveFade*detail.a^2; + // CLIP tests that value while detail-off retains base-alpha logic. + ["mesh_modern.frag.spv"] = "576af229b96a2a5e9b3b1ff16ab686d96578c2d9d1762b0e225df31bfd7aa86f", + // S5-c4 replaces the padding word at batch offset 4 with authored + // surface opacity and forwards it plus exact detail category/flags + // flat to the fragment shader. The 16-byte ABI is unchanged. + ["mesh_modern.vert.spv"] = "1ff1b7118153ba0fddfcdfca39bd973a4b8f6b46db6db772e9886061d15561d8", ["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b", // Re-pinned 2026-08-27: portal-view clip slots now travel with // deferred billboard particles, matching retail PortalList draws. diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs index 099b23a4..a19c0c79 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs @@ -149,12 +149,6 @@ public sealed class VulkanViewportMappingTests Assert.Equal( (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), 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( - (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), - VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs index a1fb54e8..4b4237fc 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs @@ -113,7 +113,6 @@ public sealed class AtmosphericShaderAbiTests [InlineData("terrain_atmospheric.vert")] [InlineData("directional_shadow_terrain.vert")] [InlineData("directional_shadow_terrain_multiview.vert")] - [InlineData("mesh_detail.vert")] public void RetailAndTerrainShadersNeverCallAcdreamFoliageDisplace(string fileName) { string text = File.ReadAllText(Path.Combine( diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs index 422e6a55..0a1ca089 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs @@ -177,6 +177,23 @@ public sealed class RenderPackSpirvValidatorTests Assert.True(result.Success, result.Reason); } + [Fact] + public void WorldDirectionalShadowReceiver_AllowsOnePassDetailCategoryBinding() + { + PipelineVariantDeclaration variant = BuiltInAtmosphericRenderPack.Descriptor + .PipelineVariants.Single(static value => + value.Semantic + == RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver); + + RenderPackSpirvValidationResult result = + RenderPackSpirvValidator.ValidatePipelineVariantShader( + Shader("mesh_atmospheric.vert.spv"), + RenderPackShaderStage.Vertex, + variant); + + Assert.True(result.Success, result.Reason); + } + [Fact] public void DirectionalShadowUniformRequiresSixMembersAndSelectedSourceAtOffset320() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs index 33630fd8..e2ef0eac 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs @@ -1,148 +1,339 @@ using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; +using AcDream.Core.Meshing; +using DatReaderWriter.Enums; +using Xunit; namespace AcDream.App.Tests.Rendering; public sealed class RetailDetailTextureContractTests { - private static readonly TerrainAtlas.RetailDetailTextureBinding Available = new( - new GpuTextureSlot(7), - Tiling: 4f, - SurfaceTextureId: 0x05001787, - RenderSurfaceId: 0x06006D58, - Width: 256, - Height: 256); + private static readonly Vector4 Base = new(0.31f, 0.57f, 0.83f, 0.19f); + private static readonly Vector3 Diffuse = new(0.73f, 0.41f, 0.67f); + private static readonly Vector4 Detail = new(0.91f, 0.23f, 0.49f, 0.62f); + private static readonly Vector4 Destination = new(0.17f, 0.37f, 0.71f, 0.29f); - [Fact] - public void ExistingBuildingDetailSettingIsTheLivePassGate() + public static TheoryData PolicyRows() { - Assert.False(RetailDetailTextureContract.ShouldRender(false, Available)); - Assert.True(RetailDetailTextureContract.ShouldRender(true, Available)); - Assert.False(RetailDetailTextureContract.ShouldRender(true, default)); - } - - [Fact] - public void OpaqueDetailUsesDepthEqualityWhileTransparentDetailDoesNot() - { - Assert.Equal( - GpuCompareOp.Equal, - RetailDetailTextureContract.DetailDepthCompare(transparent: false)); - Assert.Equal( - GpuCompareOp.LessOrEqual, - RetailDetailTextureContract.DetailDepthCompare(transparent: true)); + var rows = new TheoryData(); + (SurfaceType Type, bool Paletted, TranslucencyKind Kind, + RetailDetailTextureContract.FramebufferFamily Family, float Reference)[] materials = + [ + (SurfaceType.Base1Image, false, TranslucencyKind.Opaque, + RetailDetailTextureContract.FramebufferFamily.Opaque, 0.05f), + (SurfaceType.Base1Image | SurfaceType.Alpha, false, TranslucencyKind.AlphaBlend, + RetailDetailTextureContract.FramebufferFamily.Alpha, 0.05f), + (SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Additive, false, + TranslucencyKind.Additive, + RetailDetailTextureContract.FramebufferFamily.AlphaAdditive, 0.05f), + (SurfaceType.Base1Image | SurfaceType.InvAlpha, false, TranslucencyKind.InvAlpha, + RetailDetailTextureContract.FramebufferFamily.InverseAlpha, 0.05f), + (SurfaceType.Base1Image | SurfaceType.Base1ClipMap, false, TranslucencyKind.ClipMap, + RetailDetailTextureContract.FramebufferFamily.Clip, 200f / 255f), + (SurfaceType.Base1Image | SurfaceType.Base1ClipMap, true, TranslucencyKind.ClipMap, + RetailDetailTextureContract.FramebufferFamily.Clip, 100f / 255f), + ]; + foreach (string consumer in new[] { "Building", "EnvCell" }) + foreach (var material in materials) + foreach (bool detailEnabled in new[] { false, true }) + { + RetailDetailTextureContract.FramebufferFamily family = + consumer == "Building" && material.Kind == TranslucencyKind.ClipMap + ? RetailDetailTextureContract.FramebufferFamily.Opaque + : material.Family; + rows.Add(consumer, material.Type, material.Paletted, detailEnabled, + material.Kind, (int)family, material.Reference); + } + return rows; } [Theory] - [InlineData(0.1f, 0.2f, 0.3f)] - [InlineData(0.0f, 0.0f, 0.0f)] - [InlineData(1.0f, 1.0f, 1.0f)] - [InlineData(0.5f, 0.9f, 0.05f)] - public void ZeroDetailAlphaIsExactNoOp(float r, float g, float b) + [MemberData(nameof(PolicyRows))] + public void BuildingAndEnvCellPolicyTable_KeepsOneDrawAndOriginalFramebufferFamily( + string consumer, + SurfaceType surfaceType, + bool paletted, + bool detailEnabled, + TranslucencyKind expectedKind, + int familyValue, + float expectedReference) { - var baseColour = new Vector3(r, g, b); - var transparentDetail = new Vector4(0.9f, 0.1f, 0.7f, 0f); + var family = (RetailDetailTextureContract.FramebufferFamily)familyValue; + Assert.True(consumer is "Building" or "EnvCell"); + Assert.Equal(expectedKind, TranslucencyKindExtensions.FromSurfaceType(surfaceType)); + Assert.Equal(["source-subset"], PhysicalDrawSequence(detailEnabled)); + PipelineState state = ExpectedPipelineState( + consumer, expectedKind, paletted, expectedReference); + Assert.Equal(expectedReference, state.AlphaReference); Assert.Equal( - baseColour, - RetailDetailTextureContract.Expected(baseColour, transparentDetail, opacity: 1f)); - Assert.True(RetailDetailTextureContract.IsNeutral(transparentDetail, opacity: 1f)); - } - - [Fact] - public void ZeroOpacityIsExactNoOp() - { - var baseColour = new Vector3(0.5f, 0.5f, 0.5f); - var derethCategory = new Vector4(0.165f, 0.165f, 0.165f, 0.132f); - - // opacity 0 models a translucency fade that has finished disappearing: - // even a fully-opaque detail texel contributes nothing. - 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); - } - - [Fact] - public void FogAppliedPerDrawMatchesRetailsFogAfterCombineOrder() - { - // Review fix (post-05970306): retail fogs the pixel AFTER the - // texture-stage combine (mix(lerp(base,detail,a), fog, f)), but - // acdream draws the combine as two separate passes, each fogging its - // own colour before the fixed-function blend recombines them - // (lerp(mix(base,fog,f), mix(detail,fog,f), a)). This pins that the - // two orders are the same pixel for arbitrary inputs — see - // ExpectedFogged's doc comment and mesh_detail.frag's header comment - // for the algebra. - var random = new Random(0x226226); - for (int i = 0; i < 200; i++) + expectedKind == TranslucencyKind.ClipMap + ? paletted ? 100f / 255f : 200f / 255f + : 0.05f, + state.AlphaReference); + Assert.True(state.AlphaTest); + Assert.Equal(expectedKind is TranslucencyKind.Opaque or TranslucencyKind.ClipMap, + state.DepthWrite); + Assert.Equal(expectedKind switch { - var baseColour = NextColour(random); - var detail = new Vector4(NextColour(random), random.NextSingle()); - float opacity = random.NextSingle(); - var fog = NextColour(random); - float fogFactor = random.NextSingle(); + TranslucencyKind.Opaque => GpuBlendMode.None, + TranslucencyKind.AlphaBlend => GpuBlendMode.StraightAlpha, + TranslucencyKind.Additive => GpuBlendMode.Additive, + TranslucencyKind.InvAlpha => GpuBlendMode.InverseAlpha, + TranslucencyKind.ClipMap when consumer == "EnvCell" => + GpuBlendMode.PremultipliedAlpha, + TranslucencyKind.ClipMap => GpuBlendMode.None, + _ => throw new ArgumentOutOfRangeException(nameof(expectedKind)), + }, state.Blend); + Assert.Equal(detailEnabled, DetailIsArmed(detailEnabled)); + if (consumer == "Building" && expectedKind == TranslucencyKind.ClipMap) + Assert.Equal("AP-240 immediate A2C", state.Placement); + else + Assert.Equal("source FIFO position", state.Placement); - Vector3 twoDrawOrder = RetailDetailTextureContract.ExpectedFogged( - baseColour, detail, opacity, fog, fogFactor); - Vector3 retailOrder = Vector3.Lerp( - RetailDetailTextureContract.Expected(baseColour, detail, opacity), - fog, - fogFactor); - - Assert.True( - MathF.Abs(twoDrawOrder.X - retailOrder.X) < 1e-6f, - $"R: two-draw {twoDrawOrder.X} vs retail {retailOrder.X} (sample {i})"); - Assert.True( - MathF.Abs(twoDrawOrder.Y - retailOrder.Y) < 1e-6f, - $"G: two-draw {twoDrawOrder.Y} vs retail {retailOrder.Y} (sample {i})"); - Assert.True( - MathF.Abs(twoDrawOrder.Z - retailOrder.Z) < 1e-6f, - $"B: two-draw {twoDrawOrder.Z} vs retail {retailOrder.Z} (sample {i})"); - } + Vector4 expectedSource = detailEnabled + ? IndependentCombine(Base, Diffuse, Detail, 0.75f, 0.4f) + : new Vector4( + new Vector3(Base.X, Base.Y, Base.Z) * Diffuse, + Base.W * 0.4f); + Vector4 source = detailEnabled + ? RetailDetailTextureContract.Combine(Base, Diffuse, Detail, 0.75f, 0.4f) + : expectedSource; + AssertVector(expectedSource, source); + AssertVector(IndependentComposite(source, Destination, family), + RetailDetailTextureContract.Composite(source, Destination, family)); } - private static Vector3 NextColour(Random random) => - new(random.NextSingle(), random.NextSingle(), random.NextSingle()); + [Theory] + [InlineData(SurfaceType.Translucent, TranslucencyKind.AlphaBlend, false)] + [InlineData(SurfaceType.Translucent | SurfaceType.Base1ClipMap, + TranslucencyKind.AlphaBlend, true)] + [InlineData(SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Alpha, + TranslucencyKind.AlphaBlend, false)] + [InlineData(SurfaceType.Translucent | SurfaceType.Additive, + TranslucencyKind.Additive, false)] + [InlineData(SurfaceType.Translucent | SurfaceType.InvAlpha, + TranslucencyKind.InvAlpha, false)] + public void RawTranslucentOverride_PinsBlendAndClipTestPolicy( + SurfaceType type, + TranslucencyKind expectedKind, + bool rawClipWithoutAlphaFamily) + { + Assert.Equal(expectedKind, TranslucencyKindExtensions.FromSurfaceType(type)); + byte mask = RetailAlphaMeshRouter.ConstructSubsetMask( + hasAlphaFamilyBit: (type & (SurfaceType.Alpha | SurfaceType.InvAlpha | SurfaceType.Additive)) != 0, + hasClipMapBit: (type & SurfaceType.Base1ClipMap) != 0, + hasTranslucentBit: true, + hasPositiveStippling: false); + Assert.Equal(rawClipWithoutAlphaFamily, (mask & RetailAlphaMeshRouter.MaskClipMap) != 0); + if ((type & SurfaceType.Translucent) != 0 && (type & SurfaceType.Base1ClipMap) != 0) + Assert.False(expectedKind == TranslucencyKind.ClipMap); + } + + [Fact] + public void CombinePinsSquaredFinalAlphaAndExcludesBaseAlpha() + { + Vector4 actual = RetailDetailTextureContract.Combine( + Base, Diffuse, Detail, authoredOpacity: 0.75f, liveOpacity: 0.4f); + float a = 0.75f * 0.4f; + float w = a * Detail.W; + Vector3 expectedRgb = new(Detail.X, Detail.Y, Detail.Z); + expectedRgb = expectedRgb * w + + new Vector3(Base.X, Base.Y, Base.Z) * Diffuse * (1f - w); + float expectedAlpha = a * Detail.W * Detail.W; + + AssertVector(new Vector4(expectedRgb, expectedAlpha), actual); + Assert.NotEqual(a * Detail.W, actual.W); + Assert.NotEqual(Base.W * a * Detail.W * Detail.W, actual.W); + Vector3 oldTwoDrawRgb = new Vector3(Base.X, Base.Y, Base.Z) * Diffuse + * (new Vector3(Detail.X, Detail.Y, Detail.Z) + + Vector3.One - new Vector3(Detail.W)); + Assert.NotEqual(oldTwoDrawRgb.X, actual.X); + Assert.NotEqual(oldTwoDrawRgb.Y, actual.Y); + Assert.NotEqual(oldTwoDrawRgb.Z, actual.Z); + + Vector4 changedBaseAlpha = new(Base.X, Base.Y, Base.Z, 0.97f); + AssertVector(actual, RetailDetailTextureContract.Combine( + changedBaseAlpha, Diffuse, Detail, 0.75f, 0.4f)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public void EveryFramebufferEquationUsesFinalX(int familyValue) + { + var family = (RetailDetailTextureContract.FramebufferFamily)familyValue; + Vector4 source = RetailDetailTextureContract.Combine( + Base, Diffuse, Detail, 0.75f, 0.4f); + float x = source.W; + Vector4 expected = family switch + { + RetailDetailTextureContract.FramebufferFamily.Opaque => source, + RetailDetailTextureContract.FramebufferFamily.Alpha => + source * x + Destination * (1f - x), + RetailDetailTextureContract.FramebufferFamily.AlphaAdditive => + source * x + Destination, + RetailDetailTextureContract.FramebufferFamily.Additive => + source + Destination, + RetailDetailTextureContract.FramebufferFamily.InverseAlpha => + source * (1f - x) + Destination * x, + RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive => + source * (1f - x) + Destination, + RetailDetailTextureContract.FramebufferFamily.Clip => + source + Destination * new Vector4(1f - x), + _ => throw new ArgumentOutOfRangeException(nameof(family)), + }; + AssertVector(expected, RetailDetailTextureContract.Composite(source, Destination, family)); + } + + [Theory] + [InlineData(100f / 255f)] + [InlineData(200f / 255f)] + public void ClipBoundaryIsFinalAlphaGreaterEqual(float reference) + { + Assert.False(RetailDetailTextureContract.SurvivesClip( + MathF.BitDecrement(reference), reference)); + Assert.True(RetailDetailTextureContract.SurvivesClip(reference, reference)); + Assert.True(RetailDetailTextureContract.SurvivesClip( + MathF.BitIncrement(reference), reference)); + } + + [Fact] + public void FogRunsAfterCombineAndLeavesAlphaUntouched() + { + Vector4 source = RetailDetailTextureContract.Combine( + Base, Diffuse, Detail, 0.5f, 0.65f); + Vector3 fog = new(0.13f, 0.29f, 0.47f); + Vector4 fogged = RetailDetailTextureContract.ApplyFog(source, fog, 0.38f); + AssertVector(new Vector4(Vector3.Lerp( + new Vector3(source.X, source.Y, source.Z), fog, 0.38f), source.W), fogged); + } + + [Fact] + public void BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample() + { + string ordinary = Shader("mesh_modern.frag"); + string atmospheric = Shader("mesh_atmospheric.frag"); + string shared = Shader("retail_detail_material.glsl"); + + Assert.Contains("#include \"retail_detail_material.glsl\"", ordinary, StringComparison.Ordinal); + Assert.Contains("#include \"retail_detail_material.glsl\"", atmospheric, StringComparison.Ordinal); + Assert.Contains("materialAlpha * detail.a * detail.a", shared, StringComparison.Ordinal); + Assert.DoesNotContain("baseTexel.a", shared, StringComparison.Ordinal); + foreach (string shader in new[] { ordinary, atmospheric }) + { + Assert.Contains("if (detailActive)", shader, StringComparison.Ordinal); + Assert.True(shader.IndexOf("if (detailActive)", StringComparison.Ordinal) + < shader.IndexOf("vec4 detail =", StringComparison.Ordinal)); + Assert.Contains("vSurfaceOpacity * vOpacityMultiplier", shader, StringComparison.Ordinal); + } + Assert.True(ordinary.IndexOf("if (uLightDebug == 3)", StringComparison.Ordinal) + < ordinary.IndexOf("vec4 detail =", StringComparison.Ordinal)); + Assert.True(atmospheric.IndexOf("if (uLightDebug == 3)", StringComparison.Ordinal) + < atmospheric.IndexOf("vec4 detail =", StringComparison.Ordinal)); + } + + private static string Shader(string file) => File.ReadAllText(Path.Combine( + RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", file)); + + private static string RepositoryRoot() => Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + + private static void AssertVector(Vector4 expected, Vector4 actual) + { + Assert.Equal(expected.X, actual.X, 6); + Assert.Equal(expected.Y, actual.Y, 6); + Assert.Equal(expected.Z, actual.Z, 6); + Assert.Equal(expected.W, actual.W, 6); + } + + private static string[] PhysicalDrawSequence(bool detailEnabled) + { + _ = detailEnabled; + return ["source-subset"]; + } + + private static bool DetailIsArmed(bool detailEnabled) => detailEnabled; + + private static PipelineState ExpectedPipelineState( + string consumer, + TranslucencyKind kind, + bool paletted, + float reference) + { + _ = paletted; + GpuBlendMode blend = kind switch + { + TranslucencyKind.Opaque => GpuBlendMode.None, + TranslucencyKind.AlphaBlend => GpuBlendMode.StraightAlpha, + TranslucencyKind.Additive => GpuBlendMode.Additive, + TranslucencyKind.InvAlpha => GpuBlendMode.InverseAlpha, + TranslucencyKind.ClipMap when consumer == "EnvCell" => + GpuBlendMode.PremultipliedAlpha, + TranslucencyKind.ClipMap => GpuBlendMode.None, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + return new PipelineState( + blend, + kind is TranslucencyKind.Opaque or TranslucencyKind.ClipMap, + AlphaTest: true, + reference, + consumer == "Building" && kind == TranslucencyKind.ClipMap + ? "AP-240 immediate A2C" + : "source FIFO position"); + } + + private static Vector4 IndependentCombine( + Vector4 baseTexel, + Vector3 diffuse, + Vector4 detail, + float authoredOpacity, + float liveOpacity) + { + float a = authoredOpacity * liveOpacity; + float w = a * detail.W; + Vector3 rgb = new(detail.X, detail.Y, detail.Z); + rgb = rgb * w + + new Vector3(baseTexel.X, baseTexel.Y, baseTexel.Z) + * diffuse * (1f - w); + return new Vector4(rgb, a * detail.W * detail.W); + } + + private readonly record struct PipelineState( + GpuBlendMode Blend, + bool DepthWrite, + bool AlphaTest, + float AlphaReference, + string Placement); + + private static Vector4 IndependentComposite( + Vector4 source, + Vector4 destination, + RetailDetailTextureContract.FramebufferFamily family) + { + float x = source.W; + return family switch + { + RetailDetailTextureContract.FramebufferFamily.Opaque => source, + RetailDetailTextureContract.FramebufferFamily.Alpha => + source * x + destination * (1f - x), + RetailDetailTextureContract.FramebufferFamily.AlphaAdditive => + source * x + destination, + RetailDetailTextureContract.FramebufferFamily.Additive => + source + destination, + RetailDetailTextureContract.FramebufferFamily.InverseAlpha => + source * (1f - x) + destination * x, + RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive => + source * (1f - x) + destination, + RetailDetailTextureContract.FramebufferFamily.Clip => + source + destination * new Vector4(1f - x), + _ => throw new ArgumentOutOfRangeException(nameof(family)), + }; + } } diff --git a/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs index 49459697..28ae2a32 100644 --- a/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs +++ b/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs @@ -23,7 +23,7 @@ namespace AcDream.App.Tests.Rendering; /// .TryCreateDetailTexture (~TerrainAtlas.cs:488-556) must upload a FULL /// mip chain, and it must be sampled with the repeat/linear world sampler, /// or the "attenuation is the sampler's linear mip chain" claim in -/// mesh_detail.vert's header comment and the VM1 commit is unverified. +/// the one-pass world material contract and the VM1 commit is unverified. /// Drives the private method directly (reflection, same pattern as /// EnvCellRendererTests' private-method tests) against a synthetic /// PFID_A8R8G8B8 RenderSurface, so no installed DAT is required — this lane diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs index 1108d0e3..9e5419ba 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs @@ -285,19 +285,38 @@ public sealed class OrderPreservingSubmitterTests } [Fact] - public void PrepareThenDraw_ADetailCategoryCommandRecordsItsOwnSoloDraw() + public void PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace() { - using var fx = new DispatcherFixture(); + using var fx = new DispatcherFixture(detailAvailable: true, detailEnabled: true); using DrawScope draw = fx.BeginDraw(); OrderedDrawStream stream = StreamOf( MakeCommand(0), MakeCommand(1, detailCategory: 1), - MakeCommand(2)); + MakeCommand(2, translucency: TranslucencyKind.ClipMap, detailCategory: 1), + MakeCommand(3)); PrepareAndDrawWhole(fx.Dispatcher, draw, stream); - Assert.Equal([(0, 1), (1, 1), (2, 1)], DecodeDrawRanges(fx.Device)); + List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device); + Assert.Equal([(0, 1), (1, 1), (2, 1), (3, 1)], + runs.Select(run => (run.Start, run.Count)).ToList()); + Assert.Equal((0u, 0f), (runs[0].Constants.TextureIndexA, runs[0].Constants.ParamA)); + Assert.Equal((77u, 3.5f), (runs[1].Constants.TextureIndexA, runs[1].Constants.ParamA)); + Assert.Equal((77u, 3.5f), (runs[2].Constants.TextureIndexA, runs[2].Constants.ParamA)); + Assert.Equal((0u, 0f), (runs[3].Constants.TextureIndexA, runs[3].Constants.ParamA)); + Assert.Equal( + [ + "wb-mesh-opaque-1x", + "wb-mesh-opaque-a2c-1x", + "wb-mesh-opaque-a2c-1x", + "wb-mesh-opaque-a2c-1x", + "wb-mesh-opaque-a2c-1x", + ], + fx.Device.Calls.OfType() + .Select(call => call.PipelineName) + .ToArray()); + Assert.Equal(4, fx.Device.Calls.OfType().Count()); } [Fact] @@ -590,7 +609,7 @@ public sealed class OrderPreservingSubmitterTests private readonly WbMeshAdapter _meshAdapter; private readonly TextureCache _textures; - public DispatcherFixture() + public DispatcherFixture(bool detailAvailable = false, bool detailEnabled = false) { Device = new RecordingGpuDevice(); FrameLifetime = new GpuDeviceFrameLifetime(Device); @@ -615,7 +634,12 @@ public sealed class OrderPreservingSubmitterTests _meshAdapter, entitySpawnAdapter, new EntityClassificationCache(), - new AcDream.Core.Rendering.TranslucencyFadeManager()); + new AcDream.Core.Rendering.TranslucencyFadeManager(), + buildingDetail: detailAvailable + ? new TerrainAtlas.RetailDetailTextureBinding( + new GpuTextureSlot(77), 3.5f, 0x05000001, 0x08000001, 16, 16) + : default, + buildingDetailEnabled: () => detailEnabled); } public RecordingGpuDevice Device { get; } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs index f60725ca..77add57c 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs @@ -316,7 +316,7 @@ public sealed class WalkStaticStreamPopulatorTests Assert.DoesNotContain( "one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase); - Assert.Contains("161 active rows", register, StringComparison.Ordinal); + Assert.Contains("160 active rows", register, StringComparison.Ordinal); int apSectionStart = register.IndexOf( "## 3. Documented approximation (AP)", StringComparison.Ordinal); int apSectionEnd = register.IndexOf( @@ -338,7 +338,7 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Single(File.ReadLines(registerPath), static line => line.StartsWith("| AP-243 |", StringComparison.Ordinal)); Assert.Equal( - 161, + 160, File.ReadLines(registerPath).Count(static line => line.StartsWith("| AP-", StringComparison.Ordinal))); Assert.Contains("greater than 50 m", register, StringComparison.Ordinal); @@ -1120,9 +1120,9 @@ public sealed class WalkStaticStreamPopulatorTests /// /// S4-c2 final M3 production pin for SubmitWalkAlphaInstance: - /// building shell + detail ON executes the immediate base+detail draws at + /// building shell + detail ON executes one armed original-pipeline draw at /// the submit mark and queues nothing; detail OFF queues, then drains the - /// base draw without a detail pass; an ordinary batch remains deferred + /// neutral base draw; an ordinary batch remains deferred /// even while the global detail option is on. Mutation check: hardcoding /// detailSurfaceActive=false at the production submit makes the /// first case's pending-count assertion fail with 1. @@ -1145,9 +1145,11 @@ public sealed class WalkStaticStreamPopulatorTests on.Device.Calls.OfType(), call => call.PipelineName.Contains("alpha", StringComparison.Ordinal) && !call.PipelineName.Contains("detail", StringComparison.Ordinal)); - Assert.Contains( - on.Device.Calls.OfType(), - call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal)); + Assert.Single(on.Device.Calls.OfType()); + Assert.DoesNotContain(on.Device.Calls.OfType(), + call => call.PipelineName.Contains("detail", StringComparison.Ordinal)); + Assert.Contains(on.Device.Calls.OfType(), + call => call.Constants.TextureIndexA == 99u && call.Constants.ParamA == 2f); } using (var off = new DispatcherFixture( @@ -1164,9 +1166,8 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Equal(1, off.AlphaQueue!.PendingCount); Assert.Empty(off.Device.Calls.OfType()); off.AlphaQueue.EndFrame(); - Assert.DoesNotContain( - off.Device.Calls.OfType(), - call => call.PipelineName.Contains("retail-detail", StringComparison.Ordinal)); + Assert.DoesNotContain(off.Device.Calls.OfType(), + call => call.Constants.TextureIndexA == 99u || call.Constants.ParamA != 0f); } using (var ordinary = new DispatcherFixture( @@ -1212,12 +1213,41 @@ public sealed class WalkStaticStreamPopulatorTests defer.Invoke(fx.Dispatcher, [Matrix4x4.Identity]); Assert.Equal(0, fx.AlphaQueue!.PendingCount); - Assert.Contains( - fx.Device.Calls.OfType(), - call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal)); + Assert.Single(fx.Device.Calls.OfType()); + Assert.DoesNotContain(fx.Device.Calls.OfType(), + call => call.PipelineName.Contains("detail", StringComparison.Ordinal)); + Assert.Contains(fx.Device.Calls.OfType(), + call => call.Constants.TextureIndexA == 99u && call.Constants.ParamA == 2f); } - private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch(uint detailCategory) => + [Theory] + [InlineData(TranslucencyKind.AlphaBlend, "wb-mesh-alpha-1x")] + [InlineData(TranslucencyKind.Additive, "wb-mesh-additive-1x")] + [InlineData(TranslucencyKind.InvAlpha, "wb-mesh-inverse-1x")] + public void ImmediateBuildingDetail_RetainsOriginalFramebufferFamily( + TranslucencyKind kind, + string expectedPipeline) + { + using var fx = new DispatcherFixture( + withAlphaQueue: true, + detailAvailable: true, + detailEnabled: true); + using DrawScope draw = fx.BeginDraw(beginAlpha: true); + WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch( + detailCategory: 1u, + translucency: kind); + + fx.Dispatcher.SubmitWalkAlphaInstance(in building, Matrix4x4.Identity); + + Assert.Equal(0, fx.AlphaQueue!.PendingCount); + Assert.Single(fx.Device.Calls.OfType()); + Assert.Contains(fx.Device.Calls.OfType(), + call => call.PipelineName == expectedPipeline); + } + + private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch( + uint detailCategory, + TranslucencyKind translucency = TranslucencyKind.AlphaBlend) => new( new GroupKey( 0, @@ -1225,7 +1255,7 @@ public sealed class WalkStaticStreamPopulatorTests 3, new GpuTextureSlot(1), 0, - TranslucencyKind.AlphaBlend, + translucency, FoliageFlags: 0), Matrix4x4.Identity, ClipSlot: 0, diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index 1b7467e0..f95917ed 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -101,16 +101,16 @@ public class EnvCellRendererTests Vector4.Zero, sampleCount: 1)); - EnvCellRenderer.BindEnvironmentDetailCategory(pass, frame); + using EnvCellRenderer renderer = CreateRenderer(); + renderer.BindEnvironmentDetailCategory(pass, frame, instanceCount: 3); GpuRecordedStorageBind storageBind = Assert.Single( device.Calls.OfType()); Assert.Equal(GpuBindingModel.StorageInstanceDetailCategory, storageBind.Binding); - Assert.Equal((uint)sizeof(uint), storageBind.SizeBytes); - Assert.Equal( - 1u, - MemoryMarshal.Read( - device.RingBytes.Slice((int)storageBind.OffsetBytes, sizeof(uint)))); + Assert.Equal((uint)(3 * sizeof(uint)), storageBind.SizeBytes); + ReadOnlySpan categories = MemoryMarshal.Cast( + device.RingBytes.Slice((int)storageBind.OffsetBytes, (int)storageBind.SizeBytes)); + Assert.Equal([1u, 1u, 1u], categories.ToArray()); } /// @@ -118,17 +118,14 @@ public class EnvCellRendererTests /// StorageInstances/StorageBatches/StorageClipSlots/StorageGlobalLights/ /// StorageInstanceLightSets every frame but never /// GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO both - /// mesh_modern.vert (interior shells) and mesh_detail.vert (interior - /// detail replay) read as instanceAlpha[instanceIndex]. Predates VM1 - /// (6c79d35c has the same omission); with mesh_detail.vert now reading - /// that binding too, an unfixed gap here would have made the interior - /// detail overlay read whatever section a DIFFERENT renderer (or a + /// both world vertex families read as instanceAlpha[instanceIndex]. + /// Predates VM1 (6c79d35c has the same omission); an unfixed gap here + /// would have made the interior detail material read whatever section a DIFFERENT renderer (or a /// stale ring slot) last left in binding 7, indexed by these cells' /// instance ids. This pins that SubmitRhi binds its own constant-1.0f /// section, sized to the live instance count, before it records any /// draw in the pass — one bind serves every subsequent draw in that - /// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's - /// replay alike. + /// pass and every shell pipeline. /// [Theory] [InlineData(WbRenderPass.Opaque)] @@ -242,7 +239,7 @@ public class EnvCellRendererTests } [Fact] - public void RetailDetailPipelinesPreserveOpaqueAndTransparentDepthWriteContracts() + public void DetailUsesOnlyTheOriginalFourShellPipelines() { using var device = new RecordingGpuDevice(); using var meshManager = CreateMeshManager(device); @@ -253,23 +250,9 @@ public class EnvCellRendererTests meshManager, new WbFrustum()); - GpuPipelineDescription opaqueDetail = Assert.Single( - device.CreatedPipelines, - pipeline => pipeline.Description.Name == "envcell-retail-detail") - .Description; - GpuPipelineDescription transparentDetail = Assert.Single( - device.CreatedPipelines, - pipeline => pipeline.Description.Name == "envcell-retail-detail-alpha") - .Description; - - Assert.Equal(GpuBlendMode.RetailDetail, opaqueDetail.Blend); - Assert.True(opaqueDetail.Depth.Write); - Assert.Equal(GpuCompareOp.Equal, opaqueDetail.Depth.Compare); - Assert.False(opaqueDetail.AlphaToCoverage); - Assert.Equal(GpuBlendMode.RetailDetail, transparentDetail.Blend); - Assert.False(transparentDetail.Depth.Write); - Assert.Equal(GpuCompareOp.LessOrEqual, transparentDetail.Depth.Compare); - Assert.False(transparentDetail.AlphaToCoverage); + Assert.Equal(4, device.CreatedPipelines.Count); + Assert.DoesNotContain(device.CreatedPipelines, + pipeline => pipeline.Description.Name.Contains("detail", StringComparison.Ordinal)); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs index b8e78b3b..ef374b0f 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs @@ -20,7 +20,7 @@ namespace AcDream.App.Tests.Rendering.Wb; /// /// Layout under test (std430, 16 bytes total): /// offset 0 : uint TextureTableIndex -/// offset 4 : uint Reserved (pad) +/// offset 4 : float SurfaceOpacity /// offset 8 : uint TextureIndex (layer within the texture array) /// offset 12 : uint Flags /// @@ -36,7 +36,7 @@ public class ModernBatchDataLayoutTests public void FieldOffsets_MatchStd430Layout() { Assert.Equal(0, (int)Marshal.OffsetOf(nameof(ModernBatchData.TextureTableIndex))); - Assert.Equal(4, (int)Marshal.OffsetOf(nameof(ModernBatchData.Reserved))); + Assert.Equal(4, (int)Marshal.OffsetOf(nameof(ModernBatchData.SurfaceOpacity))); Assert.Equal(8, (int)Marshal.OffsetOf(nameof(ModernBatchData.TextureIndex))); Assert.Equal(12, (int)Marshal.OffsetOf(nameof(ModernBatchData.Flags))); } @@ -47,11 +47,12 @@ public class ModernBatchDataLayoutTests var data = new ModernBatchData { TextureTableIndex = 7u, + SurfaceOpacity = 0.25f, TextureIndex = 3u, }; Assert.Equal(7u, data.TextureTableIndex); - Assert.Equal(0u, data.Reserved); + Assert.Equal(0.25f, data.SurfaceOpacity); Assert.Equal(3u, data.TextureIndex); Assert.Equal(0u, data.Flags); } diff --git a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs index d45ea7d1..6600f621 100644 --- a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs +++ b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs @@ -207,7 +207,7 @@ public sealed class MeshExtractorSolidFaceExtractionTests var dats = new FakeMeshExtractorDats(); RegisterTexturedQuad( dats, - SurfaceType.Base1Image, + SurfaceType.Base1Image | SurfaceType.Translucent, PixelFormat.PFID_DXT1, new byte[8], translucency: 0.25f); @@ -219,7 +219,40 @@ public sealed class MeshExtractorSolidFaceExtractionTests KeyValuePair<(int Width, int Height, TextureFormat Format), List> group = Assert.Single(mesh.TextureBatches); Assert.Equal(TextureFormat.RGBA8, group.Key.Format); - Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length); + TextureBatchData batch = Assert.Single(group.Value); + Assert.Equal(4 * 4 * 4, batch.TextureData.Length); + Assert.Equal(0.75f, batch.SurfaceOpacity); + } + + [Theory] + [InlineData(0f, 1f)] + [InlineData(0.25f, 0.75f)] + [InlineData(0.5f, 0.5f)] + [InlineData(0.75f, 0.25f)] + [InlineData(1f, 0f)] + public void PrepareCellStructMeshData_CarriesAuthoredSurfaceOpacity( + float translucency, + float expectedOpacity) + { + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface( + dats, + SurfaceType.Base1Image | SurfaceType.Translucent, + translucency); + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, + BuildQuadCellStruct(RetailCullMode.Landblock), + surfaceOverrides: [2], + Matrix4x4.Identity, + CancellationToken.None)); + + TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); + Assert.Equal( + BitConverter.SingleToInt32Bits(expectedOpacity), + BitConverter.SingleToInt32Bits(batch.SurfaceOpacity)); } /// @@ -232,12 +265,16 @@ public sealed class MeshExtractorSolidFaceExtractionTests /// not change what these tests actually pin (verified by hand against /// CellStructSideCandidates.TriangleFanIndices before this change). /// - private static void RegisterCellTexturedSurface(FakeMeshExtractorDats dats) + private static void RegisterCellTexturedSurface( + FakeMeshExtractorDats dats, + SurfaceType type = SurfaceType.Base1Image, + float translucency = 0f) { dats.Register(TexturedSurfaceId, new Surface { - Type = SurfaceType.Base1Image, + Type = type, OrigTextureId = SurfaceTextureId, + Translucency = translucency, }); dats.Register(SurfaceTextureId, new SurfaceTexture { diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs index 9bcdb6b7..2acda9ae 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs @@ -103,6 +103,9 @@ public static class ObjectMeshDataEquality { Assert.True(expected.SourceSurfaceIndex == actual.SourceSurfaceIndex, $"{path}.SourceSurfaceIndex: expected {expected.SourceSurfaceIndex}, got {actual.SourceSurfaceIndex}"); Assert.True(expected.RetailSurfaceMask == actual.RetailSurfaceMask, $"{path}.RetailSurfaceMask: expected {expected.RetailSurfaceMask}, got {actual.RetailSurfaceMask}"); Assert.True(expected.RawSurfaceType == actual.RawSurfaceType, $"{path}.RawSurfaceType: expected {expected.RawSurfaceType}, got {actual.RawSurfaceType}"); + Assert.True(BitConverter.SingleToInt32Bits(expected.SurfaceOpacity) + == BitConverter.SingleToInt32Bits(actual.SurfaceOpacity), + $"{path}.SurfaceOpacity: expected {expected.SurfaceOpacity:R}, got {actual.SurfaceOpacity:R}"); Assert.True(expected.IsCellShell == actual.IsCellShell, $"{path}.IsCellShell: expected {expected.IsCellShell}, got {actual.IsCellShell}"); } diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs index c974125d..863958d5 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs @@ -198,6 +198,7 @@ public class ObjectMeshDataSerializerTests { SourceSurfaceIndex = 7, RetailSurfaceMask = 0b0000_1101, RawSurfaceType = 0x11u, + SurfaceOpacity = 0.25f, IsCellShell = true, }, }; @@ -265,6 +266,30 @@ public class ObjectMeshDataSerializerTests { ObjectMeshDataEquality.AssertEqual(original, readBack); } + [Theory] + [InlineData(1f)] + [InlineData(0.75f)] + [InlineData(0.5f)] + [InlineData(0.25f)] + [InlineData(0f)] + public void SurfaceOpacity_RoundTripsBitExactlyAndDeterministically(float opacity) { + ObjectMeshData data = WithCellShellSubset(); + TextureBatchData batch = Assert.Single(Assert.Single(data.TextureBatches).Value); + batch.SurfaceOpacity = opacity; + + using var first = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, first); + using var second = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, second); + + Assert.Equal(first.ToArray(), second.ToArray()); + TextureBatchData read = Assert.Single(Assert.Single( + ObjectMeshDataSerializer.Read(first.ToArray()).TextureBatches).Value); + Assert.Equal( + BitConverter.SingleToInt32Bits(opacity), + BitConverter.SingleToInt32Bits(read.SurfaceOpacity)); + } + // ---- determinism ----------------------------------------------------- [Fact] diff --git a/tests/AcDream.Content.Tests/PakFormatTests.cs b/tests/AcDream.Content.Tests/PakFormatTests.cs index 1d425385..fa663f4c 100644 --- a/tests/AcDream.Content.Tests/PakFormatTests.cs +++ b/tests/AcDream.Content.Tests/PakFormatTests.cs @@ -10,15 +10,13 @@ public class PakFormatTests { } /// - /// OH2/S1 contract §8.3 point 5: the recipe 7 -> 8 bump (exact - /// CellStruct surface-index subset construction) is a serialized - /// PAYLOAD/recipe change, not a container framing change — this pin - /// makes that decision explicit and test-visible rather than implicit. + /// S5-c4: recipe 9 carries authored surface opacity in every prepared + /// texture batch. It remains a payload recipe change, not pak framing. /// [Fact] - public void FormatVersion_StaysAtTwo_AcrossTheOH2RecipeBump() { + public void FormatVersion_StaysAtTwo_AcrossTheS5C4RecipeBump() { Assert.Equal(2u, PakFormat.CurrentFormatVersion); - Assert.Equal(8u, PakFormat.CurrentBakeToolVersion); + Assert.Equal(9u, PakFormat.CurrentBakeToolVersion); } [Fact] diff --git a/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs index ee5dfbbe..02c99918 100644 --- a/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs +++ b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs @@ -136,12 +136,11 @@ public sealed class PreparedAssetSourceTests : IDisposable } /// - /// OH2/S1 contract §10.5: an OLDER-recipe (7) package must be rejected - /// by a CURRENT-recipe (8) consumer through the catalog-identity check + /// S5-c4: an older recipe-8 package must be rejected by a recipe-9 + /// consumer through the catalog-identity check /// alone, BEFORE any TextureBatchData payload field is ever /// deserialized -- so an older pak can never be silently misread as - /// carrying the new SourceSurfaceIndex/RetailSurfaceMask/RawSurfaceType/ - /// IsCellShell fields. + /// carrying authored surface opacity. /// [Fact] public void Constructor_RejectsOlderRecipePackageBeforePayloadDecode() @@ -161,7 +160,7 @@ public sealed class PreparedAssetSourceTests : IDisposable fs.Position = 36; Span buf = stackalloc byte[4]; System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( - buf, PakFormat.CurrentBakeToolVersion - 1); + buf, 8u); fs.Write(buf); } @@ -169,6 +168,7 @@ public sealed class PreparedAssetSourceTests : IDisposable () => new PakPreparedAssetSource(path, Identity)); Assert.Contains("does not match the installed DAT set", error.Message); Assert.Contains("Re-bake", error.Message); + Assert.Contains("bake tool 8 != 9", error.Message); } [Fact] diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs index 5a3fdde1..5ae679d4 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs @@ -20,9 +20,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests // Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances) var groups = new List { - new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureIndex: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque), - new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureIndex: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend), - new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureIndex: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque), + new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureIndex: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, SurfaceOpacity: 0.75f), + new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureIndex: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend, SurfaceOpacity: 0.25f), + new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureIndex: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, SurfaceOpacity: 0.5f), }; var indirect = new DrawElementsIndirectCommand[16]; @@ -60,6 +60,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests Assert.Equal(0xAAu, batch[0].TextureIndex); Assert.Equal(0xCCu, batch[1].TextureIndex); Assert.Equal(0xBBu, batch[2].TextureIndex); + Assert.Equal(0.75f, batch[0].SurfaceOpacity); + Assert.Equal(0.5f, batch[1].SurfaceOpacity); + Assert.Equal(0.25f, batch[2].SurfaceOpacity); Assert.Equal(CullMode.CounterClockwise, cull[0]); Assert.Equal(CullMode.CounterClockwise, cull[1]); Assert.Equal(CullMode.CounterClockwise, cull[2]); @@ -184,8 +187,8 @@ public sealed class WbDrawDispatcherIndirectBuilderTests ]; // Command 1 is mixed: instance 2 is ordinary and instance 3 is a - // building. It must be submitted once, then mesh_detail filters the - // ordinary instance. Commands 0 and 2 must never reach the detail pipe. + // building. It must form a solo one-pass detail run. Commands 0 and 2 + // remain neutral ordinary runs. uint[] mixedCategories = [0u, 0u, 0u, 1u, 0u]; Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun( commands, @@ -238,17 +241,13 @@ public sealed class WbDrawDispatcherIndirectBuilderTests // without copying. The cast is only safe if the structs have identical // layout (size, field offsets). // - // Campaign V slice V2 (2026-07-27): TextureHandle (ulong, an - // ARB_bindless_texture handle) became TextureIndex (uint) plus an - // explicit Reserved pad word — a slot into the GL-only binding=9 handle - // table instead of the raw handle (that binding, GpuBindingModel's - // former StorageTextureTable, was deleted at Campaign V slice V11). - // The struct stays 16 bytes and TextureLayer/Flags keep their offsets - // (8/12), matching GpuBindingModel.GpuBatchDataStrideBytes and every - // existing CPU writer, so both structs only need 4-byte packing now. + // S5-c4 consumes the former pad word as authored SurfaceOpacity. The + // struct stays 16 bytes and TextureLayer/Flags keep their offsets + // (8/12), matching GpuBindingModel.GpuBatchDataStrideBytes and both + // world shader families. Assert.Equal(16, System.Runtime.CompilerServices.Unsafe.SizeOf()); Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureIndex))); - Assert.Equal(4, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.Reserved))); + Assert.Equal(4, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.SurfaceOpacity))); Assert.Equal(8, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureLayer))); Assert.Equal(12, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.Flags))); } diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs index 21a73592..ee8c347d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs @@ -53,4 +53,28 @@ public sealed class ContentMigrationCatalogTests Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase); Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void RecipeEightToNineRequiresOneExplicitFullRebuild() + { + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(8, 9); + + Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); + Assert.Equal(8u, plan.FromRecipeVersion); + Assert.Equal(9u, plan.TargetRecipeVersion); + Assert.Contains("surface opacity", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Empty(plan.EffectiveDatIds); + Assert.Empty(plan.EffectiveLandblocks); + } + + [Fact] + public void AnyOlderRecipeToNineCollapsesToOneFullRebuild() + { + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 9); + + Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); + Assert.Equal(9u, plan.TargetRecipeVersion); + Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("surface opacity", plan.Reason, StringComparison.OrdinalIgnoreCase); + } }