From 85530c0b7e84550287c1818b08292e0337dd9096 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 29 Aug 2026 11:44:19 +0200 Subject: [PATCH 1/6] fix(render): feed look-in cells to particle and light visibility Look-in rooms (cells reached through building portals) drew their geometry but never entered the visible-cell set consumed by the particle gate and the point-light candidacy scope, so their emitters stayed frozen and their lanterns dark until the player entered or the camera left the building. Retail has no look-in split: ShouldDrawParticles @0x0050FE60 gates on cell->IsInView(), and a cell installed by DrawBuilding @0x0059F2A0 -> PView::ConstructView @0x005A57B0 is drawn by the same DrawCells traversal as any flooded cell, so it is in view identically (per-cell in_view stamped by PView::InitCell @0x005A4B70). RetailPViewFrameResult now carries InViewCells (main flood + look-in cells, the existing prepareCells union; the borrowed-scratch contract is unchanged because RecycleLookInFrames runs at the start of the next DrawInside). WorldSceneRenderer feeds InViewCells to ParticleVisibilityController and the point-light scope. DrawableCells is untouched for seals, the outside-stage predicate, diagnostics, and the packed-product referee. The legacy 4-arg Reset defaults InViewCells = DrawableCells for standalone callers. User-gated 2026-08-29 at the Sanctuary Cathedral: adjacent look-in rooms show particles and lantern lights from the root cell without entry or an outside camera; recall/waterfall/Holtburg/paperdoll unregressed. The pre-existing #132-family candle-behind-opening report remains open and is unchanged by this fix. Co-Authored-By: Claude Fable 5 --- .../Rendering/RetailPViewRenderer.cs | 23 ++++++++ .../Rendering/WorldSceneRenderer.cs | 10 +++- src/AcDream.Core/Lighting/LightManager.cs | 6 ++- .../Rendering/WorldSceneRendererTests.cs | 52 +++++++++++++++++-- 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index bc582a3d..0b1c565a 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -251,10 +251,13 @@ public sealed class RetailPViewRenderer RenderProjectionCounts sourceCounts = frameViewBorrowed ? frameView.SourceDigest.Counts : LegacySourceCounts(partition!); + // prepareCells is exactly "main flood ∪ look-in cells" — the cells + // this traversal actually reached, i.e. retail's in-view set. RetailPViewFrameResult result = _frameResultScratch.Reset( pvFrame, clipAssembly, drawableCells, + prepareCells, counts, sourceCounts, partition); @@ -1910,6 +1913,23 @@ public sealed class RetailPViewFrameResult public PortalVisibilityFrame PortalFrame { get; private set; } = null!; public ClipFrameAssembly ClipAssembly { get; private set; } = null!; public HashSet DrawableCells { get; private set; } = null!; + + /// + /// Every cell this completed view actually reached: the main flood + /// () plus the building look-in cells. This is + /// retail's per-cell in_view answer for effect consumers — + /// CPhysicsObj::ShouldDrawParticles @0x0050FE60 gates on + /// cell->IsInView(), and a cell entered through a building portal + /// (PView::ConstructView @0x005A57B0, installed by + /// RenderDeviceD3D::DrawBuilding @0x0059F2A0) is drawn by the same + /// PView::DrawCells traversal as a flooded cell, so retail marks it + /// in view identically. acdream's look-in adaptation keeps those cells out + /// of (seals / outside-stage predicate stay + /// main-flood scoped, #124); particle and light visibility must consume + /// THIS set or look-in rooms render with frozen emitters and dark lights. + /// + public HashSet InViewCells { get; private set; } = null!; + internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; } internal RenderProjectionCounts SourceCounts { get; private set; } internal InteriorEntityPartition.Result? DiagnosticPartition @@ -1919,6 +1939,7 @@ public sealed class RetailPViewFrameResult PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, + HashSet inViewCells, RenderFrameDiagnosticCounts diagnosticCounts, RenderProjectionCounts sourceCounts, InteriorEntityPartition.Result? diagnosticPartition) @@ -1926,6 +1947,7 @@ public sealed class RetailPViewFrameResult PortalFrame = portalFrame; ClipAssembly = clipAssembly; DrawableCells = drawableCells; + InViewCells = inViewCells; DiagnosticCounts = diagnosticCounts; SourceCounts = sourceCounts; DiagnosticPartition = diagnosticPartition; @@ -1941,6 +1963,7 @@ public sealed class RetailPViewFrameResult portalFrame, clipAssembly, drawableCells, + drawableCells, RetailPViewRenderer.LegacyDiagnosticCounts( diagnosticPartition), RetailPViewRenderer.LegacySourceCounts( diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs index 9d7ec0d1..39ea601d 100644 --- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -254,8 +254,14 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase camera.Camera.View, _diagnostics.CameraCellResolution)); - _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); - _frames.ObserveDrawableCells(pviewResult.DrawableCells); + // Effect visibility consumes InViewCells (main flood ∪ look-in + // cells), not the flood-only DrawableCells: retail's + // ShouldDrawParticles @0x0050FE60 asks cell->IsInView(), and a + // look-in cell drawn via DrawBuilding @0x0059F2A0 is in view + // exactly like a flooded cell. Flood-only scoping froze + // emitters and darkened lights in visible adjacent rooms. + _particleVisibility.MarkVisibleCells(pviewResult.InViewCells); + _frames.ObserveDrawableCells(pviewResult.InViewCells); _diagnostics.EmitPViewInput( pviewResult.PortalFrame, camera.ViewProjection, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index c25a476d..7a63e96c 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -271,8 +271,10 @@ public sealed class LightManager /// geometrically closer than the player's own room's torches and win the cap, /// leaving the visible room dark. Scoping candidacy to the frame's actual /// visible cells (the render already computes this — callers pass last frame's - /// RetailPViewFrameResult.DrawableCells, one frame of latency, to avoid - /// re-threading a mid-render callback) removes those from contention before the + /// RetailPViewFrameResult.InViewCells, the main flood PLUS building + /// look-in cells, one frame of latency, to avoid re-threading a mid-render + /// callback; flood-only scoping darkened look-in rooms' lanterns) removes + /// non-visible cells from contention before the /// cap ever applies. The distance-sort anchor stays the PLAYER either way — this /// parameter only narrows candidacy, it does not change the sort (the #176 /// correction: CAMERA anchoring, not cell scoping itself, caused the earlier diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index a8baa7b9..c67028eb 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -251,6 +251,29 @@ public sealed class WorldSceneRendererTests Assert.Equal(4, rig.PView.LastInput.RenderRadius); } + [Fact] + public void PViewWorld_PublishesLookInCellsToParticleAndLightVisibility() + { + var root = new LoadedCell + { + CellId = 0x01010001u, + IsOutdoorNode = false, + }; + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root); + + rig.Renderer.Render(default); + + // Retail gates effects on cell->IsInView() (ShouldDrawParticles + // @0x0050FE60), and a cell entered through a building portal + // (DrawBuilding @0x0059F2A0 -> PView::ConstructView @0x005A57B0) is + // drawn by the same traversal as a flooded cell. The particle gate and + // the light-candidate scope must therefore receive InViewCells (flood + // plus look-ins), not the flood-only DrawableCells. + Assert.Contains(0x01010003u, rig.Visibility.MarkedCells); + Assert.NotNull(rig.Frames.ObservedCells); + Assert.Contains(0x01010003u, rig.Frames.ObservedCells!); + } + [Fact] public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames() { @@ -536,7 +559,8 @@ public sealed class WorldSceneRendererTests Frames = new FrameBuilder(Calls, frame); Selection = new SelectionFrame(Calls); var alpha = new AlphaFrame(Calls); - var visibility = new ParticleVisibility(Calls); + Visibility = new ParticleVisibility(Calls); + var visibility = Visibility; PView = new PViewRenderer(Calls); Passes = new PassExecutor(Calls); var diagnostics = new Diagnostics(Calls); @@ -573,6 +597,8 @@ public sealed class WorldSceneRendererTests public SelectionFrame Selection { get; } + public ParticleVisibility Visibility { get; } + public PViewRenderer PView { get; } public PassExecutor Passes { get; } @@ -621,8 +647,13 @@ public sealed class WorldSceneRendererTests return frame; } - public void ObserveDrawableCells(IReadOnlySet drawableCells) => + public IReadOnlySet? ObservedCells { get; private set; } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) + { calls.Add("frame:observe-cells"); + ObservedCells = new HashSet(drawableCells); + } public void ClearDrawableCells() => calls.Add("frame:clear-cells"); } @@ -684,8 +715,13 @@ public sealed class WorldSceneRendererTests private sealed class ParticleVisibility(List calls) : IWorldSceneParticleVisibility { - public void MarkVisibleCells(HashSet cellIds) => + public HashSet MarkedCells { get; } = []; + + public void MarkVisibleCells(HashSet cellIds) + { calls.Add("visibility:mark"); + MarkedCells.UnionWith(cellIds); + } public void CompleteFrame() => calls.Add("visibility:complete"); @@ -701,11 +737,17 @@ public sealed class WorldSceneRendererTests public PViewRenderer(List calls) { _calls = calls; + // Distinct flood-only vs in-view sets: 0x01010003 is a look-in + // cell that is drawn but never part of the main flood. + var interiorPartition = new InteriorEntityPartition.Result(); _interiorResult = new RetailPViewFrameResult().Reset( new PortalVisibilityFrame(), new ClipFrameAssembly(), - [], - new InteriorEntityPartition.Result()); + [0x01010001u], + [0x01010001u, 0x01010003u], + RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition), + RetailPViewRenderer.LegacySourceCounts(interiorPartition), + interiorPartition); var outdoorPortalFrame = new PortalVisibilityFrame(); outdoorPortalFrame.OutsideView.Add(new ViewPolygon( [ From 684380d4218afec077af89feb434685b13ae0a49 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 29 Aug 2026 12:35:30 +0200 Subject: [PATCH 2/6] fix(render): particles draw unclipped, once, in their retail stage Retail never clips a particle to a portal view: each emitter's polys join the ONE alpha list during its owner cell's far-to-near walk turn (LScape::draw @0x00506330 iterates block_draw_list reversed; DrawBlock @0x005A17C0 walks cells; ShouldDrawParticles @0x0050FE60 gates by cell and distance), and occlusion is the depth test at FlushAlphaList @0x0059D2E0 (its float is a COUNT threshold - 0f = flush all). The 1d2f2f73 architecture instead re-submitted particles once per OutsideView slice under that slice's hardware clip slot, which cut effects at aperture boundaries and drew nothing when no outside slice was in view (the cathedral look-north disappearance). Now: unattached emitters submit once per frame by owner-cell kind (outdoor landcells in the landscape stage, interior EnvCells in the final world scope - new UnattachedEmitterCellScope filter); cell, shell-route, barrier-static, and late-stage owners submit their per-slice cone-cull UNION once with clipSlot 0; and particles emit in the stage matching their PARENT CELL - an interior dynamic whose sphere straddles an exit-portal plane keeps its mesh in both stages (#118) but its particles move to the final pass, so the interior stage can no longer repaint over them (the aperture-band star cut). Also lands the inert Change-2 primitives for the AP-236 retirement (candle-behind-door): RetailAlphaQueue.FlushFartherThan drains only the far prefix without resetting sources, plus the executor passthrough and the conservative look-in threshold helper - nothing calls them yet. User-gated 2026-08-29 round 2 at the Sanctuary Cathedral: spell and recall stars cover the whole room at every camera direction including north; waterfall containment holds on retail's depth/seal mechanism; adjacent-room particles/lights, walls, Holtburg, recall unregressed (paperdoll remains pre-existing intermittent #443). Register: AP-236 filed for the remaining barrier-order divergence. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 1 + src/AcDream.App/Rendering/ParticleRenderer.cs | 6 +- src/AcDream.App/Rendering/RetailAlphaQueue.cs | 100 ++++++++ .../Rendering/RetailPViewPassExecutor.cs | 51 ++-- .../Rendering/RetailPViewRenderer.cs | 236 ++++++++++++++---- src/AcDream.Core/Vfx/ParticleSystem.cs | 31 ++- src/AcDream.Core/Vfx/VfxModel.cs | 14 ++ .../Rendering/RetailAlphaQueueTests.cs | 74 ++++++ .../Rendering/RetailPViewPassExecutorTests.cs | 38 ++- .../Vfx/ParticleSystemTests.cs | 52 ++++ 10 files changed, 518 insertions(+), 85 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a7716216..88d8fda8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -257,6 +257,7 @@ research and is no longer active. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | 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-236 | **Filed 2026-08-29 with the particle-composition Change 1 (unclipped once-per-stage particle submission).** Retail accumulates every translucent submission in ONE alpha list in far→near WALK order — `block_draw_list` is built viewer-block-first and `LScape::draw @0x00506330` iterates it reversed; `RenderDeviceD3D::DrawBlock @0x005A17C0` walks each block per cell — and `DrawBuilding @0x0059F2A0` calls `FlushAlphaList(0f)` (the float is a COUNT threshold, 0 = flush all, `@0x0059D2E0`) BEFORE its opaque passes, so only content from cells FARTHER along the walk has been inserted when a building flushes; a nearer emitter (candle in front of a Holtburg door) inserts later and composites after that door. acdream's batched landscape has no per-cell walk: scene-particle owners submit at a few fixed points (pre-building barrier, late stage, cell stage) into the CYpt-sorted `RetailAlphaQueue`, and the pre-building barrier drains the WHOLE queue — a nearer-than-building emitter already queued is composited early, then the building/late-stage opaques overpaint it (the reopened #132 candle/creature-at-opening class). | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (barrier/late/cell submission points); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`FlushLandscapeAlpha`); `src/AcDream.App/Rendering/RetailAlphaQueue.cs` | Change 1 removed the invented per-slice clip-slot re-submission (particles are never view-clipped in retail); restoring the walk-order OUTCOME needs a farther-than-building partial drain at the pre-building barrier (planned Change 2), because the batched landscape cannot reproduce retail's insertion order directly and the queue's far→near CYpt sort is the established reconstruction (see AP-34) | A translucent effect NEARER than a building that is flushed at the pre-building barrier gets overpainted by that building's (or the late stage's) opaques — visible as the #132 candle/creature-at-opening class until Change 2 lands | `LScape::draw @0x00506330`; `RenderDeviceD3D::DrawBlock @0x005A17C0`; `RenderDeviceD3D::DrawBuilding @0x0059F2A0`; `D3DPolyRender::FlushAlphaList @0x0059D2E0`; `PView::DrawCells @0x005A4872` | | AP-234 | **Filed 2026-08-23 at the #426 solid-face extraction fix.** Cell-wall (EnvCell/CellStruct) geometry approximates retail's "skip untextured subsets inside a cell interior" with the polygon's own `Stippling.NoPos` flag rather than resolving the Surface's own `Type` (`Base1Image`/`Base1ClipMap`) before the per-polygon draw decision — the same NoPos-vs-surface-type conflation #426 fixed for ordinary GfxObj extraction (`PrepareGfxObjMeshData`/`GfxObjMesh.Build`), deliberately LEFT in place here | `src/AcDream.Core/Meshing/CellMesh.cs:45`; `src/AcDream.Content/MeshExtractor.cs`'s `PrepareCellStructMeshData` `hasPos` gate carries the identical rule | Cells are the one retail context that genuinely skips untextured subsets (`DrawEnvCell`), so approximating "untextured" with NoPos is directionally correct for the common case — a solid-colour polygon always carries NoPos since it has no UVs to carry; resolving Surface.Type first would need a per-polygon dat lookup this code doesn't currently perform before the emit/skip decision | A textured polygon whose author left NoPos set (no positive UVs authored despite a real texture) would be wrongly skipped as if untextured, or an untextured polygon whose author left NoPos unset would wrongly draw — either edge case shows as a cell wall gaining or losing a face relative to retail | `RenderDeviceD3D::DrawEnvCell` @0x0059f170 → `D3DPolyRender::DrawMesh(..., arg4=1)`; `RetailUntexturedSurfacePolicy`/`RetailUntexturedSubsetPolicy` (`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) | | 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` | diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 6a963121..9766089b 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -234,7 +234,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable IReadOnlySet attachedOwnerIds, bool includeUnattached = false, IReadOnlySet? excludedAttachedOwnerIds = null, - uint clipSlot = 0) + uint clipSlot = 0, + UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any) { if (camera is null) return; @@ -244,7 +245,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable attachedOwnerIds, includeUnattached, _scopedEmitterScratch, - excludedAttachedOwnerIds); + excludedAttachedOwnerIds, + unattachedCellScope); Matrix4x4.Invert(camera.View, out Matrix4x4 invView); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs index 9fea6157..3f828f0a 100644 --- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs +++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs @@ -239,6 +239,106 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame } } + /// + /// Drains only the entries at or beyond + /// and keeps every nearer entry queued with the frame open. This is the + /// pre/inter-building barrier semantics: retail's far→near land walk means + /// DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only + /// flush content from cells FARTHER than that building — a nearer emitter + /// has not been inserted yet and composites after the building at a later + /// flush (the float there is a COUNT threshold, not a depth). The batched + /// landscape has no per-cell walk, so the same outcome is restored by + /// draining the far prefix of the established far→near order (AP-236). + /// Sources are deliberately NOT reset: retained tokens must stay valid + /// for the remaining entries' later . + /// + public void FlushFartherThan(float minViewerDistance) + { + if (!IsCollecting) + throw new InvalidOperationException("Retail alpha flush requires an active frame."); + if (_submissions.Count == 0) + return; + + float threshold = NormalizeDistance(minViewerDistance); + SortRetailOrder(); + int prefix = 0; + while (prefix < _submissions.Count + && _submissions[prefix].ViewerDistance >= threshold) + { + prefix++; + } + + if (prefix == 0) + return; + + try + { + EnsureTokenCapacity(prefix); + EnsureSourceCapacity(_sources.Count); + Array.Clear(_sourceDrawOffsets, 0, _sources.Count); + + for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++) + { + IRetailAlphaDrawSource source = _sources[sourceIndex]; + int sourceCount = 0; + for (int i = 0; i < prefix; i++) + { + RetailAlphaSubmission submission = _submissions[i]; + if (ReferenceEquals(submission.Source, source)) + _tokenScratch[sourceCount++] = submission.Token; + } + + if (sourceCount > 0) + source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount)); + } + + int start = 0; + while (start < prefix) + { + IRetailAlphaDrawSource source = _submissions[start].Source; + int end = start + 1; + while (end < prefix + && ReferenceEquals(_submissions[end].Source, source)) + end++; + + int count = end - start; + int sourceIndex = FindSourceIndex(source); + int firstPreparedDraw = _sourceDrawOffsets[sourceIndex]; + source.DrawPreparedAlphaBatch(firstPreparedDraw, count); + _sourceDrawOffsets[sourceIndex] += count; + start = end; + } + } + catch + { + // Converge to the full-drain failure shape: the retained suffix + // cannot be trusted once a source threw mid-prepare/draw. + _submissions.Clear(); + List? resetFailures = null; + for (int i = 0; i < _sources.Count; i++) + { + try + { + _sources[i].ResetAlphaSubmissions(); + } + catch (Exception error) + { + (resetFailures ??= []).Add(error); + } + } + _sources.Clear(); + if (resetFailures is { Count: > 0 }) + { + throw new AggregateException( + "Retail alpha partial drain failed and its submissions could not be fully reset.", + resetFailures); + } + throw; + } + + _submissions.RemoveRange(0, prefix); + } + public void EndFrame() { if (!IsCollecting) diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 5b90654a..037c9f17 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -444,20 +444,9 @@ internal sealed class RetailPViewPassExecutor : animatedEntityIds: frame.AnimatedEntityIds); } - _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); - - if (_particleClassifications.Outdoor.Count > 0 - && _particles is not null - && _particleRenderer is not null) - { - _particleRenderer.DrawForOwners( - frame.Camera, - frame.CameraWorldPosition, - ParticleRenderPass.Scene, - _particleClassifications.Outdoor, - clipSlot: (uint)context.Slice.Slot); - } - + // Late-stage particle owners submit ONCE per frame through + // DrawLandscapeStaticParticles after the slice loop (retail: one + // unclipped alpha-list insertion per emitter), not per slice here. EnableClipDistances(); if (frame.RenderSky && frame.RenderWeather) { @@ -492,8 +481,12 @@ internal sealed class RetailPViewPassExecutor : RetailPViewFrameInput frame, RetailPViewLandscapeStaticParticleContext context) { - bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); - _surface.BindTerrainClip(); + // One unclipped submission per owner per frame. Retail never clips a + // particle to a portal view — its polys join the one alpha list during + // the owner cell's walk turn and the depth test at the flush decides + // occlusion (FlushAlphaList @0x0059D2E0). The former per-slice call + // with the slice's clip slot both hardware-cut effects at aperture + // boundaries and double-submitted owners visible in two slices. DisableClipDistances(); _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); @@ -506,11 +499,9 @@ internal sealed class RetailPViewPassExecutor : frame.CameraWorldPosition, ParticleRenderPass.Scene, _particleClassifications.Outdoor, - clipSlot: (uint)context.Slice.Slot); + clipSlot: 0); } - if (scissor) - _surface.EndScissor(); _entities.ClearClipRouting(); DisableClipDistances(); } @@ -573,11 +564,19 @@ internal sealed class RetailPViewPassExecutor : public void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, - ClipViewSlice slice) + bool outdoorCells) { if (_particles is null || _particleRenderer is null) return; + // Retail draws an unattached emitter once, during its owner CELL's + // walk turn, with NO portal-view clip (CPhysicsObj::ShouldDrawParticles + // @0x0050FE60 gates by cell in-view + distance; occlusion is the depth + // test at FlushAlphaList @0x0059D2E0). Outdoor-cell emitters submit in + // the landscape stage, interior-cell emitters in the final world stage. + // The former once-per-OutsideView-slice submission with that slice's + // hardware clip slot made effects vanish by view direction (zero + // outside slices in view = zero submissions) — invented behavior. DisableClipDistances(); _particleRenderer.DrawForOwners( frame.Camera, @@ -585,11 +584,17 @@ internal sealed class RetailPViewPassExecutor : ParticleRenderPass.Scene, _noSceneParticleEntityIds, includeUnattached: true, - clipSlot: (uint)slice.Slot); + clipSlot: 0, + unattachedCellScope: outdoorCells + ? UnattachedEmitterCellScope.OutdoorCells + : UnattachedEmitterCellScope.InteriorCells); } public void FlushLandscapeAlpha() => _alpha.Flush(); + public void FlushLandscapeAlphaFartherThan(float minViewerDistance) => + _alpha.FlushFartherThan(minViewerDistance); + public void DrawCellParticles( RetailPViewFrameInput frame, RetailPViewCellSliceContext context) @@ -608,12 +613,14 @@ internal sealed class RetailPViewPassExecutor : return; DisableClipDistances(); + // Retail never clips cell particles to a portal view: the owner + // cell's walls own occlusion via the depth test at the alpha flush. _particleRenderer.DrawForOwners( frame.Camera, frame.CameraWorldPosition, ParticleRenderPass.Scene, visible, - clipSlot: (uint)context.Slice.Slot); + clipSlot: 0); DisableClipDistances(); } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 0b1c565a..d1ab0dc3 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -28,6 +28,12 @@ public sealed class RetailPViewRenderer private static readonly IReadOnlySet NoParticleOwners = new HashSet(); + // Frame unions for the once-per-frame particle submissions (retail: one + // unclipped alpha-list insertion per emitter; occlusion by depth at the + // flush). Per-slice owner culls still run — these accumulate their union. + private readonly HashSet _staticParticleUnionScratch = new(); + private readonly HashSet _cellParticleUnionScratch = new(); + private readonly HashSet _oneCell = new(1); // Shell-batch scratch: all of a pass's cells collected for ONE batched // opaque Render call (instead of one heavy Render per cell). Reused across @@ -320,6 +326,13 @@ public sealed class RetailPViewRenderer frameEntityPasses, in frameView); + // Interior-cell UNATTACHED emitters (spell ground effects and + // swirls anchored in EnvCells) draw in this final world scope — + // the cells' walls and the seals already own the depth buffer, so + // one unclipped submission matches retail's cell-walk insertion. + // Outdoor-cell unattached emitters drew in the landscape stage. + passes.DrawUnattachedSceneParticles(ctx, outdoorCells: false); + if (entityFrameOpen) { frameEntityPasses!.CompleteEntityFrame(in frameView); @@ -431,6 +444,37 @@ public sealed class RetailPViewRenderer } } + /// + /// Conservative barrier drain threshold for one look-in frame: the viewer + /// distance to the frame's nearest anchor-cell ORIGIN. Cell origins sit + /// inside the building, so this over-estimates the building's + /// nearest-point distance and under-drains; anything conservatively + /// retained still composites correctly at the later depth-tested drains. + /// Retail needs no threshold — its far→near walk guarantees only farther + /// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0 + /// (full drain, today's behavior) when no cell resolves. + /// + internal static float LookInBarrierDrainDistance( + PortalVisibilityFrame frame, + IRetailPViewCellSource cells, + Vector3 viewerPosition) + { + float best = float.PositiveInfinity; + for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) + { + LoadedCell? cell = cells.Find(frame.OrderedVisibleCells[i]); + if (cell is null) + continue; + float distance = Vector3.Distance( + cell.WorldTransform.Translation, + viewerPosition); + if (distance < best) + best = distance; + } + + return float.IsFinite(best) ? best : 0f; + } + private void RecycleLookInFrames() { for (int i = 0; i < _lookInFrames.Count; i++) @@ -553,6 +597,8 @@ public sealed class RetailPViewRenderer _cellStaticScratch.Add(e); } + bool cellDrewObjects = false; + _cellParticleUnionScratch.Clear(); foreach (ClipViewSlice slice in cellSlices) { int routeIndex = lookInRouteIndex++; @@ -598,12 +644,21 @@ public sealed class RetailPViewRenderer _cellStaticScratch, _oneCell); - // The nested DrawCells object pass includes emitters and - // retains the exact setup_view clip until alpha playback. - passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( - cellId, slice, _cellParticleOwnerScratch)); + cellDrewObjects = true; + _cellParticleUnionScratch.UnionWith( + _cellParticleOwnerScratch); } } + + // The nested DrawCells object pass includes emitters: ONE + // unclipped submission per look-in cell (retail draws a + // particle during its cell's walk turn; the cell walls own + // occlusion by depth at alpha playback — never a view clip). + if (cellDrewObjects) + { + passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( + cellId, NoClipSlice, _cellParticleUnionScratch)); + } } // The ordinary exterior building shell is clipped by the outer @@ -615,6 +670,7 @@ public sealed class RetailPViewRenderer // anchor EnvCell; never let an unrelated building repaint a // look-in merely because both happen to be nearby. int sliceIndex = 0; + _staticParticleUnionScratch.Clear(); foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) { int shellRouteIndex = LookInBuildingShellRouteIndex( @@ -693,14 +749,20 @@ public sealed class RetailPViewRenderer _lateParticleOwnerScratch, _buildingShellScratch); } - passes.DrawLandscapeStaticParticles( - ctx, - new RetailPViewLandscapeStaticParticleContext( - slice, - _lateParticleOwnerScratch)); + _staticParticleUnionScratch.UnionWith( + _lateParticleOwnerScratch); } sliceIndex++; } + + // ONE unclipped submission for this look-in frame's shell-route + // owners (retail: one alpha-list insertion per emitter, + // depth-occluded at the flush — never re-drawn per outside view). + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext( + _staticParticleUnionScratch)); + _staticParticleUnionScratch.Clear(); } } @@ -798,14 +860,19 @@ public sealed class RetailPViewRenderer bool hasBuildingLookIns = _lookInFrames.Count > 0; if (hasBuildingLookIns) { - int barrierSliceIndex = 0; - foreach (var slice in clipAssembly.OutsideViewSlices) - { - // Ownerless outdoor emitters cannot ride an entity route. Retail - // draws their meshes once for every installed outside_view; - // retain that slot through deferred alpha playback. - passes.DrawUnattachedSceneParticles(ctx, slice); + // Ownerless OUTDOOR-cell emitters cannot ride an entity route. + // Retail inserts each one into the single alpha list once, during + // its cell's landscape walk turn, with no portal-view clip; the + // interior-cell ownerless emitters submit in the final world + // scope instead (see DrawDynamicsLast). + passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); + _staticParticleUnionScratch.Clear(); + int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length; + for (int barrierSliceIndex = 0; + barrierSliceIndex < outsideSliceTotal; + barrierSliceIndex++) + { _lateParticleOwnerScratch.Clear(); if (partition is not null) { @@ -838,14 +905,17 @@ public sealed class RetailPViewRenderer barrierSliceIndex, 0); } - - passes.DrawLandscapeStaticParticles( - ctx, - new RetailPViewLandscapeStaticParticleContext( - slice, - _lateParticleOwnerScratch)); - barrierSliceIndex++; + _staticParticleUnionScratch.UnionWith( + _lateParticleOwnerScratch); } + + // ONE unclipped submission for the union of every slice's cone + // survivors, then retail's pre-building barrier flush. + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext( + _staticParticleUnionScratch)); + _staticParticleUnionScratch.Clear(); passes.FlushLandscapeAlpha(); } @@ -865,8 +935,10 @@ public sealed class RetailPViewRenderer // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn // pre-clear so the seal protects their aperture pixels; AFTER the // look-ins so a translucent portal mesh blends over a far interior - // instead of being overpainted) + the scene-particle owners (statics + - // dynamics cone survivors — flames ride here for the same reason). + // instead of being overpainted). The scene-particle owners (statics + + // dynamics cone survivors) accumulate across the slices and submit + // ONCE, unclipped, after the loop. + _staticParticleUnionScratch.Clear(); probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { @@ -894,7 +966,15 @@ public sealed class RetailPViewRenderer if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) { _outdoorStaticScratch.Add(e); - _lateParticleOwnerScratch.Add(e.Id); + // Particles emit in the stage matching the PARENT CELL: + // an INTERIOR dynamic whose sphere merely straddles an + // exit-portal plane keeps its mesh in both stages (#118) + // but its particles belong to the final pass — draining + // them at the pre-clear boundary lets the interior stage + // repaint over them except on seal-protected aperture + // pixels (the cathedral middle-cell spell-star cut). + if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) + _lateParticleOwnerScratch.Add(e.Id); } } if (frameEntityPasses is not null) @@ -934,32 +1014,55 @@ public sealed class RetailPViewRenderer 0, ctx.PlayerLandblockId ?? 0); probeSliceIndex++; + _staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch); passes.DrawLandscapeSliceLate( ctx, new RetailPViewLandscapeLateSliceContext( slice, - _outdoorStaticScratch, - _lateParticleOwnerScratch) + _outdoorStaticScratch) { EntityDraw = entityDraw, }); } + // ONE unclipped submission for every late-stage particle owner — + // OUTDOOR-parented outside-stage dynamics' emitters plus, without + // look-ins, the outdoor statics' emitters (retail: one alpha-list + // insertion per emitter during the landscape walk; per-slice + // re-submission with clip slots was the direction-dependent + // disappearance class). Interior-parented straddlers appear in BOTH + // the LandscapeOutsideDynamic and DynamicLast routes; their particles + // emit only in the final pass, so remove them here. + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.ExceptRoute( + _staticParticleUnionScratch, + in frameView, + RenderFrameCandidateRoute.DynamicLast); + } + if (_staticParticleUnionScratch.Count > 0) + { + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext( + _staticParticleUnionScratch)); + _staticParticleUnionScratch.Clear(); + } + // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls, // campfires, ground effects anchored at a position) have no owner id - // to ride any of the id-filtered particle passes. Draw once per - // installed outside_view for BOTH root kinds, matching retail's - // landscape-stage placement and preserving the slot in each deferred - // draw. The former outdoor-root post-world tail ran after building - // cells and let exterior alpha repaint the cathedral transition. - // With no look-ins they drain at the end of the landscape stage; the - // look-in path submits them at its pre-building barrier so later opaque - // cell floors can cover them. + // to ride any of the id-filtered particle passes. OUTDOOR-cell ones + // submit ONCE in the landscape stage, unclipped — retail inserts each + // particle into the single alpha list during its owner cell's walk + // turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance; + // FlushAlphaList @0x0059D2E0 depth-tests at composition). The former + // once-per-outside-slice submission with that slice's clip slot cut + // effects at aperture boundaries and drew NOTHING when no outside + // slice was in view. Interior-cell unattached emitters submit in the + // final world scope (DrawDynamicsLast) — in the landscape stage the + // upcoming depth clear + interior repaint would erase them. if (!hasBuildingLookIns) - { - foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) - passes.DrawUnattachedSceneParticles(ctx, slice); - } + passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // immediately after LScape::draw and before the optional depth clear. @@ -1137,13 +1240,13 @@ public sealed class RetailPViewRenderer Array.Empty(), visibleCellIds: null); - // An owner routed through any pre-clear outside slice already had - // its alpha particles drawn there. Meshes may be submitted in both - // stages, but particles must be emitted exactly once. - RenderFrameRouteOwnerSelector.ExceptRoute( - _dynamicParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LandscapeOutsideDynamic); + // Particles emit exactly once, in the stage matching the parent + // cell. Pure-outdoor dynamics are absent from the DynamicLast + // route (they draw only in the outside stage), and interior + // straddlers — present in BOTH routes — emit their particles + // HERE so the interior stage cannot repaint over them; the late + // landscape submission excludes DynamicLast owners for the same + // reason. if (_dynamicParticleOwnerScratch.Count > 0) { passes.DrawDynamicsParticles( @@ -1240,8 +1343,12 @@ public sealed class RetailPViewRenderer else { _dynamicParticleOwnerScratch.Clear(); + // Interior-parented dynamics — INCLUDING exit-portal straddlers + // whose mesh also drew in the outside stage — emit particles in + // this final pass; outdoor-parented ones emitted in the late + // landscape submission (parent-cell stage split). foreach (var e in _dynamicsScratch) - if (!_outsideStageDynamics.Contains(e)) + if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) _dynamicParticleOwnerScratch.Add(e.Id); } if (_dynamicParticleOwnerScratch.Count > 0) @@ -1633,10 +1740,29 @@ public interface IRetailPViewPassExecutor RetailPViewFrameInput frame, RetailPViewCellSliceContext context, int portalIndex); + /// + /// One unclipped submission for every renderable UNATTACHED emitter whose + /// owner cell matches the scope: outdoor landcells in the landscape stage, + /// interior EnvCells in the final world scope. Retail inserts each such + /// particle into the single alpha list during its owner cell's walk turn + /// and never clips it to a portal view. + /// void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, - ClipViewSlice slice); + bool outdoorCells); void FlushLandscapeAlpha(); + + /// + /// Pre/inter-building barrier drain: composites only the queued alpha at + /// or beyond and retains nearer + /// entries for the later boundary flush — retail's far→near walk outcome + /// (DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only ever flush + /// content from cells farther than that building; AP-236). The default + /// falls back to a full flush so non-production executors keep today's + /// behavior until they opt in. + /// + void FlushLandscapeAlphaFartherThan(float minViewerDistance) => + FlushLandscapeAlpha(); void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet ownerIds); void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result); @@ -1979,12 +2105,13 @@ public readonly record struct RetailPViewLandscapeSliceContext( } /// -/// Outdoor-static emitters submitted at retail's pre-building alpha barrier. -/// Mesh alpha for the same owners is already queued by the early landscape -/// entity route. +/// Scene-particle owners for ONE unclipped landscape-stage submission (the +/// union of every outside slice's cone survivors). Mesh alpha for the same +/// owners is already queued by the entity routes; retail inserts each +/// emitter's polys into the single alpha list once, during its owner cell's +/// walk turn, with no portal-view clip. /// public readonly record struct RetailPViewLandscapeStaticParticleContext( - ClipViewSlice Slice, IReadOnlySet ParticleOwnerIds); /// Retail DrawBuilding's ordinary exterior-shell pass, issued after @@ -2001,8 +2128,7 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext( /// submitted at a pre-building barrier. public readonly record struct RetailPViewLandscapeLateSliceContext( ClipViewSlice Slice, - IReadOnlyList Dynamics, - IReadOnlySet ParticleOwnerIds) + IReadOnlyList Dynamics) { internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index 4345c6f3..818587e6 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -526,7 +526,8 @@ public sealed class ParticleSystem : IParticleSystem IReadOnlySet attachedOwnerIds, bool includeUnattached, List destination, - IReadOnlySet? excludedAttachedOwnerIds = null) + IReadOnlySet? excludedAttachedOwnerIds = null, + UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any) { ArgumentNullException.ThrowIfNull(attachedOwnerIds); ArgumentNullException.ThrowIfNull(destination); @@ -539,8 +540,11 @@ public sealed class ParticleSystem : IParticleSystem foreach (int handle in _renderableUnattachedHandlesByPass[passIndex]) { LastRenderScopeEmitterVisitCount++; - if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) + if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter) + && MatchesUnattachedCellScope(emitter, unattachedCellScope)) + { destination.Add(emitter); + } } } @@ -566,6 +570,29 @@ public sealed class ParticleSystem : IParticleSystem destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle)); } + /// + /// Splits unattached emitters by their owner cell kind so each draws once + /// in its retail stage: an outdoor landcell emitter belongs to the + /// landscape stage (before the depth clear), an interior EnvCell emitter + /// to the final world stage (after the seals). Retail gets this for free + /// because a particle draws during its owner CELL's walk turn + /// (CPhysicsObj::ShouldDrawParticles @0x0050FE60 reads the one cell). + /// AC cell convention: low word < 0x0100 is an outdoor landcell, + /// 0x0100..0xFFFD is an interior EnvCell. Cell 0 matches neither scoped + /// mode — such an emitter cannot pass the world in-view gate anyway. + /// + private static bool MatchesUnattachedCellScope( + ParticleEmitter emitter, + UnattachedEmitterCellScope scope) + { + if (scope == UnattachedEmitterCellScope.Any) + return true; + uint low = emitter.OwnerCellId & 0xFFFFu; + return scope == UnattachedEmitterCellScope.OutdoorCells + ? low != 0 && low < 0x0100u + : low >= 0x0100u; + } + public readonly struct LiveEmitterEnumerable : IEnumerable { private readonly ParticleSystem _owner; diff --git a/src/AcDream.Core/Vfx/VfxModel.cs b/src/AcDream.Core/Vfx/VfxModel.cs index bdeb661c..a019a304 100644 --- a/src/AcDream.Core/Vfx/VfxModel.cs +++ b/src/AcDream.Core/Vfx/VfxModel.cs @@ -46,6 +46,20 @@ public enum ParticleRenderPass SkyPostScene = 2, } +/// +/// Which unattached emitters a scoped render copy admits, by owner cell kind. +/// Retail draws every particle during its owner CELL's walk turn, so an +/// outdoor-cell emitter renders in the landscape stage and an interior-cell +/// emitter in the final world stage; acdream draws each group once in the +/// matching stage instead of per portal slice. +/// +public enum UnattachedEmitterCellScope +{ + Any = 0, + OutdoorCells = 1, + InteriorCells = 2, +} + /// /// Authority used by retail's particle presentation gate. World-owned /// emitters follow CPhysicsObj::ShouldDrawParticles; examination and diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs index 2141e611..d60cc655 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs @@ -75,6 +75,80 @@ public sealed class RetailAlphaQueueTests Assert.Equal(2, source.ResetCount); } + [Fact] + public void FlushFartherThan_DrainsOnlyTheFarPrefixAndRetainsNearerEntries() + { + // The pre-building barrier: retail's far→near walk means DrawBuilding's + // FlushAlphaList(0f) @0x0059F2A0 can only flush content from cells + // farther than that building; a nearer candle flame is not inserted + // yet and composites after the building at a later flush (AP-236). + var log = new List(); + var objects = new RecordingSource("object", log); + var particles = new RecordingSource("particle", log); + var queue = new RetailAlphaQueue(); + + queue.BeginFrame(); + queue.Submit(particles, 0, 30f); // far waterfall + queue.Submit(objects, 0, 25f); // far translucent part + queue.Submit(particles, 1, 10f); // exactly at the building threshold + queue.Submit(particles, 2, 5f); // near candle flame — must be kept + queue.FlushFartherThan(10f); + + Assert.True(queue.IsCollecting); + Assert.Equal(1, queue.PendingCount); + Assert.Equal(new[] { "particle:0", "object:0", "particle:1" }, log); + Assert.Equal(0, objects.ResetCount); + Assert.Equal(0, particles.ResetCount); + + queue.EndFrame(); + + Assert.Equal( + new[] { "particle:0", "object:0", "particle:1", "particle:2" }, + log); + Assert.Equal(1, objects.ResetCount); + Assert.Equal(1, particles.ResetCount); + Assert.Equal(2, particles.PrepareCount); + } + + [Fact] + public void FlushFartherThan_WithNoFarEntries_LeavesTheQueueUntouched() + { + var log = new List(); + var source = new RecordingSource("alpha", log); + var queue = new RetailAlphaQueue(); + + queue.BeginFrame(); + queue.Submit(source, 1, 4f); + queue.FlushFartherThan(10f); + + Assert.Empty(log); + Assert.Equal(1, queue.PendingCount); + Assert.Equal(0, source.PrepareCount); + Assert.Equal(0, source.ResetCount); + queue.EndFrame(); + Assert.Equal(new[] { "alpha:1" }, log); + } + + [Fact] + public void FlushFartherThan_DegenerateThreshold_DrainsAllWithoutResettingSources() + { + var log = new List(); + var source = new RecordingSource("alpha", log); + var queue = new RetailAlphaQueue(); + + queue.BeginFrame(); + queue.Submit(source, 1, 8f); + queue.Submit(source, 2, 2f); + queue.FlushFartherThan(0f); + + Assert.Equal(new[] { "alpha:1", "alpha:2" }, log); + Assert.Equal(0, queue.PendingCount); + Assert.Equal(0, source.ResetCount); + Assert.True(queue.IsCollecting); + queue.EndFrame(); + Assert.Equal(1, source.ResetCount); + } + [Fact] public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 71068650..27ceefea 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -34,13 +34,14 @@ public sealed class RetailPViewPassExecutorTests "terrain-clip", "clear-routing", "landscape-late", - "unattached-particles", + "unattached-particles-outdoor", "landscape-alpha", "indoor-routing", "indoor-routing", "exit-mask", "indoor-routing", "opaque-shells", + "unattached-particles-interior", ], executor.Operations); } @@ -65,7 +66,7 @@ public sealed class RetailPViewPassExecutorTests string.Join('|', executor.Operations), "landscape-early", "landscape-late", - "unattached-particles", + "unattached-particles-outdoor", "landscape-alpha", "interior-depth-clear", "indoor-routing", @@ -93,6 +94,32 @@ public sealed class RetailPViewPassExecutorTests Assert.DoesNotContain("interior-depth-clear", executor.Operations); } + [Fact] + public void DrawInside_interior_without_an_outside_slice_still_draws_interior_unattached_particles() + { + // Repro (Sanctuary middle cell, looking north): spell ground effects + // vanished whenever no exit portal was in view, because unattached + // emitters submitted once PER outside slice under that slice's + // hardware clip slot — zero slices meant zero submissions. Retail + // draws such an emitter during its owner cell's walk turn + // (ShouldDrawParticles @0x0050FE60) and never clips it to a view. + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + var root = new LoadedCell + { + CellId = 0xA9B40100u, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + renderer.DrawInside(Frame(root), executor); + + Assert.Contains("unattached-particles-interior", executor.Operations); + Assert.DoesNotContain( + "unattached-particles-outdoor", + executor.Operations); + } + [Fact] public void Particle_classifications_reset_before_an_empty_following_frame() { @@ -277,7 +304,7 @@ public sealed class RetailPViewPassExecutorTests AssertAppearsInOrder( string.Join('|', executor.Operations), "landscape-early", - "unattached-particles", + "unattached-particles-outdoor", "landscape-static-particles", "landscape-alpha", "look-in-punch", @@ -763,7 +790,10 @@ public sealed class RetailPViewPassExecutorTests int portalIndex) => Operations.Add("look-in-punch"); public void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, - ClipViewSlice slice) => Operations.Add("unattached-particles"); + bool outdoorCells) => Operations.Add( + outdoorCells + ? "unattached-particles-outdoor" + : "unattached-particles-interior"); public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); public void DrawDynamicsParticles( diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs index 0d6f4a52..466958b4 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs @@ -742,6 +742,58 @@ public sealed class ParticleSystemTests Assert.Equal(new[] { ownerNine }, destination.Select(emitter => emitter.Handle)); } + [Fact] + public void UnattachedCellScope_SplitsEmittersByOwnerCellKind() + { + // One submission per retail stage: outdoor-landcell unattached + // emitters ride the landscape stage and interior-EnvCell ones the + // final world stage, because retail draws each particle during its + // owner CELL's walk turn (ShouldDrawParticles @0x0050FE60). AC cell + // convention: low word < 0x0100 is a landcell, >= 0x0100 an EnvCell; + // cell 0 matches neither scoped mode. + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32000083u, + Type = ParticleType.Still, + MaxParticles = 1, + }; + int outdoor = sys.SpawnEmitter(desc, Vector3.Zero); + sys.UpdateEmitterOwnerCell(outdoor, 0xA9B40021u); + int interior = sys.SpawnEmitter(desc, Vector3.Zero); + sys.UpdateEmitterOwnerCell(interior, 0xA9B40100u); + int cellLess = sys.SpawnEmitter(desc, Vector3.Zero); + + var destination = new List(); + var none = new HashSet(); + + sys.CopyRenderableEmittersForOwners( + ParticleRenderPass.Scene, + none, + includeUnattached: true, + destination, + unattachedCellScope: UnattachedEmitterCellScope.OutdoorCells); + Assert.Equal(new[] { outdoor }, destination.Select(e => e.Handle)); + + sys.CopyRenderableEmittersForOwners( + ParticleRenderPass.Scene, + none, + includeUnattached: true, + destination, + unattachedCellScope: UnattachedEmitterCellScope.InteriorCells); + Assert.Equal(new[] { interior }, destination.Select(e => e.Handle)); + + sys.CopyRenderableEmittersForOwners( + ParticleRenderPass.Scene, + none, + includeUnattached: true, + destination, + unattachedCellScope: UnattachedEmitterCellScope.Any); + Assert.Equal( + new[] { outdoor, interior, cellLess }, + destination.Select(e => e.Handle)); + } + [Fact] public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable() { From fc30285fd70cde2524ff8807d1103f4fc8627568 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 29 Aug 2026 16:06:04 +0200 Subject: [PATCH 3/6] fix(render): drawn-once look-in dynamics + farther-than-building alpha drains Two retail-parity corrections on top of 684380d4, user-gated together (no regressions; door/creature-through-opening rendering verified live at Holtburg; cathedral waterfall and look-north casts pass): 1. Drawn-once look-in dynamics. Retail marks every drawn non-player part for the frame (DrawMeshInternal @0x0059F360 GetDrawnThisFrame), so an object draws once, with its cell. acdream drew a look-in cell's dynamics twice under an outdoor root - once correctly with the look-in, then again in dynamics-last after the boundary alpha drain, where the second draw overpainted nearer flames. Both the accepted path (_lookInCellIds) and the frame product (BuildDynamicLastRoute) now exclude dynamics whose parent cell drew as a look-in. 2. Pre/inter-building barriers drain only content FARTHER than the building they precede (FlushLandscapeAlphaFartherThan + RetailAlphaQueue.FlushFartherThan + conservative anchor-origin threshold). Retail's far-to-near walk guarantees a building's FlushAlphaList(0f) @0x0059F2A0 has only farther content queued; a nearer emitter composites at a later flush. AP-236 documents the remaining barrier-order divergence. The #132 candle-before-door overdraw is NOT yet fixed by these steps and stays open: the current suspect is that houses without a constructed look-in still draw their interior-parented door in dynamics-last after the outdoor candle's boundary drain (see the 2026-08-29 ledger for the retail flush-after-objects hypothesis). Co-Authored-By: Claude Fable 5 --- .../Rendering/RetailPViewRenderer.cs | 67 ++++++++++++++++--- .../Scene/RenderScenePViewFrameProduct.cs | 24 +++++++ .../Rendering/RetailPViewPassExecutorTests.cs | 6 +- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index d1ab0dc3..56f0a987 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -34,6 +34,13 @@ public sealed class RetailPViewRenderer private readonly HashSet _staticParticleUnionScratch = new(); private readonly HashSet _cellParticleUnionScratch = new(); + // Every cell drawn as a building look-in this frame. Retail marks each + // drawn non-player part for the frame (DrawMeshInternal @0x0059F360, + // GetDrawnThisFrame), so an object whose cell drew with a look-in cannot + // draw again in a later pass; dynamics-last consults this set to honor + // the same drawn-once contract. + private readonly HashSet _lookInCellIds = new(); + private readonly HashSet _oneCell = new(1); // Shell-batch scratch: all of a pass's cells collected for ONE batched // opaque Render call (instead of one heavy Render per cell). Reused across @@ -172,13 +179,19 @@ public sealed class RetailPViewRenderer // contains them). drawableCells itself stays the MAIN flood: it feeds the // seals, the outside-stage predicate, and the frame result. var prepareCells = drawableCells; + _lookInCellIds.Clear(); if (_lookInFrames.Count > 0) { _lookInPrepareScratch.Clear(); _lookInPrepareScratch.UnionWith(drawableCells); foreach (var f in _lookInFrames) + { foreach (uint c in f.OrderedVisibleCells) + { _lookInPrepareScratch.Add(c); + _lookInCellIds.Add(c); + } + } prepareCells = _lookInPrepareScratch; } @@ -532,12 +545,22 @@ public sealed class RetailPViewRenderer { PortalVisibilityFrame frame = _lookInFrames[frameIndex]; - // Retail enters DrawBuilding once per building and drains every - // alpha submission accumulated by the preceding building before - // punching the next building's portals. The first building uses - // the pre-look-in barrier in DrawLandscapeThroughOutsideView. + // Retail enters DrawBuilding once per building and drains the + // alpha accumulated by the preceding building before punching the + // next building's portals — and because retail's far→near walk + // has only inserted FARTHER content by then, that drain can never + // composite an emitter nearer than this building + // (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236). + // The first building uses the pre-look-in barrier in + // DrawLandscapeThroughOutsideView. if (frameIndex > 0) - passes.FlushLandscapeAlpha(); + { + passes.FlushLandscapeAlphaFartherThan( + LookInBarrierDrainDistance( + frame, + ctx.Cells, + ctx.CameraWorldPosition)); + } // Pass 1: far-Z punch every aperture of this building. foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) @@ -910,13 +933,25 @@ public sealed class RetailPViewRenderer } // ONE unclipped submission for the union of every slice's cone - // survivors, then retail's pre-building barrier flush. + // survivors, then retail's pre-building barrier drain. Under + // retail's far→near walk, DrawBuilding's FlushAlphaList(0f) + // @0x0059F2A0 can only ever flush content from cells FARTHER + // than the building it precedes — a nearer emitter (the Holtburg + // candle in front of a door) has not been inserted yet and + // composites at a later flush, after that building's opaques. + // Drain the far prefix only; nearer entries stay queued for the + // DrawCells-boundary flush, which runs after the late dynamics + // (AP-236 retirement). passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext( _staticParticleUnionScratch)); _staticParticleUnionScratch.Clear(); - passes.FlushLandscapeAlpha(); + passes.FlushLandscapeAlphaFartherThan( + LookInBarrierDrainDistance( + _lookInFrames[0], + ctx.Cells, + ctx.CameraWorldPosition)); } // #124: far-building look-ins draw HERE — still inside the landscape @@ -1283,6 +1318,14 @@ public sealed class RetailPViewRenderer $"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)"); continue; } + // Drawn-once (retail DrawMeshInternal @0x0059F360 marks every + // non-player part for the frame): a dynamic whose cell drew as a + // building LOOK-IN already rendered with that cell inside the + // landscape stage (#131). Redrawing it here would land AFTER the + // boundary alpha drain and overpaint nearer flames — the Holtburg + // door repainting the candle in front of it. + if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value)) + continue; bool visible = indoor ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) : viewcone.SphereVisibleOutside(c, r); @@ -1329,16 +1372,18 @@ public sealed class RetailPViewRenderer // particles must not double-draw, unlike the depth-idempotent meshes). if (frameEntityPasses is not null) { + // Parent-cell stage split: every DynamicLast owner emits its + // particles here. Pure-outdoor dynamics are absent from this + // route (outside stage only), and interior straddlers — whose + // meshes drew in both stages — must emit HERE so the interior + // stage cannot repaint over them (matches the production + // partition-null path above). RenderFrameRouteOwnerSelector.Replace( _dynamicParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.DynamicLast, 0, 0); - RenderFrameRouteOwnerSelector.ExceptRoute( - _dynamicParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LandscapeOutsideDynamic); } else { diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 61f2d3e7..10bb06e3 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1128,6 +1128,12 @@ internal sealed class RenderScenePViewFrameBuilder private RenderProjectionRecord[] _cell = []; private RenderProjectionRecord[] _dirty = []; private RenderProjectionRecord[] _survivors = []; + + // Cells drawn as building look-ins this frame — the DynamicLast route + // honors retail's drawn-once contract (DrawMeshInternal @0x0059F360 + // marks every non-player part): an object whose cell drew with a look-in + // must not enter the final dynamics route again. + private readonly HashSet _lookInCellScratch = new(); private RenderProjectionRecord[] _cellRoute = []; private readonly Dictionary _outdoorPositions = []; @@ -1528,6 +1534,16 @@ internal sealed class RenderScenePViewFrameBuilder RenderFrameWriter writer, in RenderScenePViewBuildInput input) { + _lookInCellScratch.Clear(); + for (int frameIndex = 0; + frameIndex < input.LookInFrames.Count; + frameIndex++) + { + PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; + for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) + _lookInCellScratch.Add(frame.OrderedVisibleCells[i]); + } + int count = 0; EnsureCapacity(ref _survivors, _dynamicCount); for (int i = 0; i < _dynamicCount; i++) @@ -1539,6 +1555,14 @@ internal sealed class RenderScenePViewFrameBuilder if (!input.RootIsOutdoor && !indoor) continue; + // Drawn-once (retail DrawMeshInternal @0x0059F360): a dynamic + // whose cell drew as a building look-in already rendered with + // that cell in the landscape stage; re-entering the final route + // would draw it after the boundary alpha drain and overpaint + // nearer flames (the Holtburg candle-behind-door class). + if (indoor && _lookInCellScratch.Contains(parentCellId!.Value)) + continue; + Sphere(in record, out Vector3 center, out float radius); bool visible = indoor ? input.Viewcone.SphereVisibleInCell( diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 27ceefea..f288b96d 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -306,7 +306,7 @@ public sealed class RetailPViewPassExecutorTests "landscape-early", "unattached-particles-outdoor", "landscape-static-particles", - "landscape-alpha", + "landscape-alpha-farther", "look-in-punch", "landscape-late", "landscape-alpha", @@ -380,7 +380,7 @@ public sealed class RetailPViewPassExecutorTests "look-in-punch", "landscape-building-shell", "landscape-static-particles", - "landscape-alpha", + "landscape-alpha-farther", "look-in-punch", "landscape-building-shell"); } @@ -795,6 +795,8 @@ public sealed class RetailPViewPassExecutorTests ? "unattached-particles-outdoor" : "unattached-particles-interior"); public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); + public void FlushLandscapeAlphaFartherThan(float minViewerDistance) => + Operations.Add("landscape-alpha-farther"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); public void DrawDynamicsParticles( RetailPViewFrameInput frame, From cd1cdee0e50bb5e783e6c9884295f11c9f91e9e1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 29 Aug 2026 16:51:32 +0200 Subject: [PATCH 4/6] fix(render) #443: private viewports take the ring transform path The paperdoll was visible only in portal space. Root cause: the classic WbDrawDispatcher.Draw path appended its transforms into the SHARED world transform frame (WorldTransformFrameArena.Append) with a non-zero base instance, but the default mesh shaders index every parallel per-instance array - clip slots, light sets, indoor, OPACITY, selection lighting, detail category - zero-based; only the packed world submission's shader convention subtracts the shared-arena prefix. With a world frame active the doll drew all instances at per-instance opacity 0 into a cleared target: counted draws, blank pixels, deterministic. Portal space worked because no world transform frame is active there, so the same code took the ring path with base 0. The private viewports are the only production consumers of the classic path, hiding the defect everywhere else. Fix: WbDrawDispatcher.NextClassicDrawIsPrivatePass - the private viewport renderer marks its draw and WriteWorldTransformSection routes private passes onto the plain ring path unconditionally (self-contained render state: the private pass owns its own camera, lighting, and target, and must not depend on the world frame's pose address space). Also landed, each independently justified: - Per-GPU-flight-slot private targets (PrivateViewportFlightTargets), restoring the pre-f6fe0f2a design: that revert's claim that frame submission order protects the single target's write->sample transition is not guaranteed across Vulkan command buffers. Per-slot completed scenes fix the cleared-sibling-after-reveal wart the old attempt had. - Paperdoll resource preparation moved to the frame resource phase (IPrivateEntityViewportResourcePreparation) before world draws consume the bounded composite-upload budget. - The presenter redresses on every dirty edge (an appearance-equal clone can pin retired readiness across generations; the renderer's two-phase promote keeps the last completed image visible during replacement), publishes only non-zero handles, and clears the viewport exactly once at the explicit character-session boundary. Verified live on the clean build: doll visible in the NORMAL world, visible through portal space, and still visible after arrival - the exact reported repro cycle. 26 paperdoll/private-viewport/preparation tests plus 60 renderer-suite tests pass; owner visual gate pending. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 24 +- .../Composition/FrameRootComposition.cs | 3 +- .../Rendering/PaperdollFramePresenter.cs | 124 +++---- .../PrivateEntityViewportRenderer.cs | 310 ++++++++++++------ .../Rendering/PrivatePresentationRenderer.cs | 13 + .../RenderFramePreparationController.cs | 11 +- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 22 +- .../Rendering/PaperdollFramePresenterTests.cs | 76 ++++- .../PrivateViewportFlightTargetsTests.cs | 65 ++++ 9 files changed, 439 insertions(+), 209 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7744316e..0c0dab2f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -431,7 +431,29 @@ authoritative 8→8 reconciliation. Owner check is in ## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing") -**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. +**Status:** FIXED 2026-08-29 — root cause found; owner visual gate pending. + +**2026-08-29 root cause (the "only visible in portal space" recurrence):** +the classic `WbDrawDispatcher.Draw` path appended its transforms into the +SHARED world transform frame with a non-zero base instance, but the default +mesh shaders index every parallel per-instance array (clip slots, light +sets, indoor, OPACITY, selection lighting, detail category) zero-based — +only the packed world submission's shader convention subtracts the +shared-arena prefix. The doll therefore drew all of its instances with +per-instance opacity 0 into a cleared target whenever a world transform +frame was active: counted draws, blank pixels, deterministic. Portal space +worked because the arena is inactive there (ring path, base 0). The +private viewports are the only production consumers of the classic path, +which is why nothing else ever showed the defect. Fix: +`WbDrawDispatcher.NextClassicDrawIsPrivatePass` — private passes always +take the ring transform path (self-contained render state). The same round +also restored per-flight-slot private targets (the f6fe0f2a single-slot +revert relied on cross-command-buffer ordering Vulkan does not guarantee), +moved paperdoll resource preparation into the frame's resource phase ahead +of world composite-budget consumption, made the presenter redress on every +dirty edge, and stopped transient zero handles from erasing a completed +image (session reset is the one explicit clear). Verified live: doll +visible in the normal world, through portal space, and after arrival. **Previous status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner acceptance. Reopened after the owner again observed a missing paperdoll that diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index cf0bf2d1..dc94659c 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -762,7 +762,8 @@ internal sealed class FrameRootCompositionPhase var framePreparation = new RenderFramePreparationController( renderFrameResources, devTools: null, - renderWeatherFrame); + renderWeatherFrame, + live.PaperdollPresenter); IRenderFramePostDiagnosticsPhase postDiagnostics = renderSceneShadowComparison is not null && lifecycleAutomation is not null diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index 815a794c..b19eba1d 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -24,6 +24,8 @@ internal interface IPaperdollFrameView bool TryGetVisibleSize(out int width, out int height); void SetTextureHandle(uint textureHandle); + + void ClearTextureHandle(); } internal interface IPaperdollInventoryVisibility @@ -51,7 +53,9 @@ internal interface IPaperdollPoseApplicator /// presentation edge. The renderer remains a borrowed resource disposed by /// the existing window shutdown transaction. /// -internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame +internal sealed class PaperdollFramePresenter : + IPrivateEntityViewportFrame, + IPrivateEntityViewportResourcePreparation { private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollFrameView _view; @@ -73,22 +77,30 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame public void MarkDirty() => _dirty = true; - public void Render() + /// + /// Pre-world resource phase: rebuild/redress the private clone and advance + /// its mesh/composite readiness BEFORE world draws can consume the + /// bounded per-frame composite-upload budget. In a dense scene the late + /// presentation phase never wins that budget, which is why the doll was + /// visible only while portal space quiesced the world (#443). + /// + public void PrepareResources() { if (_dirty) { if (_factory.TryBuild(out WorldEntity? doll)) { - // Same-generation CreateObject refreshes can repeat the exact - // player ObjDesc at a portal boundary. Retail redresses its - // private inventory object in place; releasing and reacquiring - // an identical synthetic owner briefly blanks the viewport and - // churns its texture composites. - if (!HasEquivalentAppearance(_doll, doll)) - { - _renderer.SetDoll(doll); - _doll = doll; - } + // Redress every accepted live-player refresh, including an + // appearance-equivalent ObjDesc after portal/relogin. The + // private clone belongs to the current presentation + // generation; retaining the old object merely because its + // pixels compare equal can leave it attached to retired + // mesh/composite readiness that never completes again. + // PrivateEntityViewportRenderer promotes replacements in two + // phases, so the last completed target remains visible until + // this fresh clone is completely drawable. + _renderer.SetDoll(doll); + _doll = doll; _dirty = false; } else @@ -96,16 +108,25 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame // gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its // private m_pInventoryObject intact when the SmartBox player // is temporarily unavailable. Keep the successful doll and - // retry this dirty redress on the next visible frame. + // retry this dirty redress on the next frame. } } _renderer.Prepare(); + } + public void Render() + { if (!_view.TryGetVisibleSize(out int width, out int height)) return; - _view.SetTextureHandle(_renderer.Render(width, height)); + // Zero is a transient not-ready result, not a request to erase a + // previously completed paperdoll. Session reset clears explicitly in + // ResetSession; ordinary mesh/composite upload latency keeps the last + // good image instead of intermittently blanking the viewport. + uint textureHandle = _renderer.Render(width, height); + if (textureHandle != 0u) + _view.SetTextureHandle(textureHandle); } /// @@ -115,80 +136,10 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame public void ResetSession() { _renderer.SetDoll(null); + _view.ClearTextureHandle(); _doll = null; _dirty = true; } - - private static bool HasEquivalentAppearance( - WorldEntity? current, - WorldEntity? candidate) - { - if (current is null || candidate is null) - return ReferenceEquals(current, candidate); - if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId - || current.Scale != candidate.Scale - || current.HiddenPartsMask != candidate.HiddenPartsMask - || current.MeshRefs.Count != candidate.MeshRefs.Count - || current.PartOverrides.Count != candidate.PartOverrides.Count) - { - return false; - } - - for (int i = 0; i < current.MeshRefs.Count; i++) - { - MeshRef left = current.MeshRefs[i]; - MeshRef right = candidate.MeshRefs[i]; - if (left.GfxObjId != right.GfxObjId - || left.PartTransform != right.PartTransform - || !DictionaryEquals( - left.SurfaceOverrides, - right.SurfaceOverrides)) - { - return false; - } - } - - for (int i = 0; i < current.PartOverrides.Count; i++) - { - if (current.PartOverrides[i] != candidate.PartOverrides[i]) - return false; - } - - PaletteOverride? leftPalette = current.PaletteOverride; - PaletteOverride? rightPalette = candidate.PaletteOverride; - if (leftPalette is null || rightPalette is null) - return leftPalette is null && rightPalette is null; - if (leftPalette.BasePaletteId != rightPalette.BasePaletteId - || leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count) - { - return false; - } - for (int i = 0; i < leftPalette.SubPalettes.Count; i++) - { - if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i]) - return false; - } - return true; - } - - private static bool DictionaryEquals( - IReadOnlyDictionary? left, - IReadOnlyDictionary? right) - { - if (left is null || right is null) - return left is null && right is null; - if (left.Count != right.Count) - return false; - foreach ((uint key, uint value) in left) - { - if (!right.TryGetValue(key, out uint rightValue) - || rightValue != value) - { - return false; - } - } - return true; - } } /// Retained-UI visibility and texture publication for the doll view. @@ -226,6 +177,9 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView /// public void SetTextureHandle(uint textureHandle) => _viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle); + + public void ClearTextureHandle() => + _viewport.TextureSlot = GpuTextureSlot.Unassigned; } /// Narrow visibility adapter for the paperdoll's inventory host. diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index 84961aab..c395af3f 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera /// /// Shared render-to-texture implementation for the private 3-D creature /// viewports used by paperdoll and examination UI. Each instance owns one -/// , one synthetic render identity, and one -/// balanced texture-owner lease. +/// per encountered GPU flight slot, one +/// synthetic render identity, and one balanced texture-owner lease. /// /// Campaign V slice V6k (V4g's first half). The target used to be a /// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL @@ -65,7 +65,6 @@ internal sealed class PrivateEntityViewportRenderer : { private const uint PrivateLandblockId = 0u; - private readonly IGpuDevice _device; private readonly ICurrentGpuFrameSource _frames; /// @@ -94,17 +93,12 @@ internal sealed class PrivateEntityViewportRenderer : /// feature does not exist for them, not just "unused". private readonly EntitySlot? _backdropSlot; - // One stable sampled texture-table slot is part of the retained viewport's - // presentation contract. Rotating the slot with the Vulkan flight index - // made the UI sample a freshly-created/cleared sibling after world reveal. - // The frame submission order already protects this target's write -> sample - // transition; keep its identity stable until resize or disposal. - private IGpuRenderTarget? _target; - private IGpuSampler? _sampler; - private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; - private int _fbW; - private int _fbH; - private bool _hasRenderedScene; + // A target written by frame N cannot also be sampled by an unretired frame + // N-1. Vulkan permits those command buffers to overlap, so one shared image + // is a cross-frame write/read race. The current frame slot selects one + // bounded target + texture-table handle; the retained UI samples that exact + // handle later in the same command buffer. + private readonly PrivateViewportFlightTargets _flightTargets; public PrivateEntityViewportRenderer( IWorldPassScope scope, @@ -127,7 +121,7 @@ internal sealed class PrivateEntityViewportRenderer : _scope = scope ?? throw new ArgumentNullException( nameof(scope), "The viewport must publish a world pass scope to draw into."); - _device = device ?? throw new ArgumentNullException(nameof(device)); + ArgumentNullException.ThrowIfNull(device); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); @@ -137,6 +131,9 @@ internal sealed class PrivateEntityViewportRenderer : _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) ? "creature viewport" : diagnosticName; + _flightTargets = new PrivateViewportFlightTargets( + device, + _diagnosticName); IEntityTextureLifetime textureLifetimeChecked = textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)); @@ -173,7 +170,17 @@ internal sealed class PrivateEntityViewportRenderer : /// public bool TextureIsBottomUp => false; - public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); + public void SetEntity(WorldEntity? entity) + { + _mainSlot.Set(entity); + if (entity is null) + { + // A character-session reset explicitly invalidates the sampled + // scenes. Do not let a replacement that is still uploading expose + // the previous character through any flight target. + _flightTargets.InvalidateCompletedScenes(); + } + } /// /// Advances the private entity's mesh and texture-composite readiness @@ -226,51 +233,50 @@ internal sealed class PrivateEntityViewportRenderer : /// public uint Render(int width, int height) { + if (width <= 0 || height <= 0) + return 0u; + + IGpuFrame frame = _frames.CurrentFrame + ?? throw new InvalidOperationException( + $"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + int frameSlot = frame.SlotIndex; + // #443: acquiring a synthetic mesh reference only schedules CPU // preparation/GPU upload; it does not make the mesh drawable. Keep the - // last completed private scene intact until every drawable mesh in the - // replacement has crossed that upload barrier. On first open there is - // no completed scene, so return zero and let the authored panel art - // show through instead of publishing a freshly-cleared black target. + // current flight slot's last completed private scene intact until every + // drawable mesh in the replacement has crossed that upload barrier. + // On first open there is no completed scene, so return zero and let the + // authored panel art show through instead of publishing a cleared target. bool mainReady = _mainSlot.PrepareForDraw(); bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true; if (!mainReady || !backdropReady) { return _mainSlot.Entity is not null - && _hasRenderedScene - && _slot.IsAssigned - ? UiTextureTableHandle.FromSlot(_slot) + ? _flightTargets.CompletedHandle(frameSlot) : 0u; } WorldEntity? entity = _mainSlot.Entity; - if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) + if (entity is null || entity.MeshRefs.Count == 0) return 0u; IReadOnlyList drawEntities = BuildDrawEntities( _backdropSlot?.Entity, entity); if (!_dispatcher.PreparePrivateEntityResources(drawEntities)) - { - return _hasRenderedScene && _slot.IsAssigned - ? UiTextureTableHandle.FromSlot(_slot) - : 0u; - } + return _flightTargets.CompletedHandle(frameSlot); - EnsureRenderTarget(width, height); - if (_target is null) + PrivateViewportFlightTargets.TargetSlot? targetSlot = + _flightTargets.Ensure(frameSlot, width, height); + if (targetSlot is null) return 0u; _camera.Aspect = width / (float)height; - IGpuFrame frame = _frames.CurrentFrame - ?? throw new InvalidOperationException( - $"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); - using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription { Name = _diagnosticName, Color = new GpuColorAttachment( - Target: _target, + Target: targetSlot.Target, Load: GpuLoadOp.Clear, Store: GpuStoreOp.Store, ClearColor: Vector4.Zero), @@ -304,6 +310,12 @@ internal sealed class PrivateEntityViewportRenderer : null), }; + // #443: a private pass must not append its transforms into the shared + // world transform frame — the default mesh shaders index parallel + // per-instance arrays zero-based, so a non-zero arena base zeroes the + // doll's per-instance opacity and the target stays blank whenever a + // world frame is active. See NextClassicDrawIsPrivatePass. + _dispatcher.NextClassicDrawIsPrivatePass = true; _dispatcher.Draw( _camera, entries, @@ -311,8 +323,8 @@ internal sealed class PrivateEntityViewportRenderer : neverCullLandblockId: PrivateLandblockId, visibleCellIds: null, animatedEntityIds: _animatedIds); - _hasRenderedScene = true; - return UiTextureTableHandle.FromSlot(_slot); + targetSlot.HasRenderedScene = true; + return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot); } /// @@ -363,69 +375,6 @@ internal sealed class PrivateEntityViewportRenderer : }); } - private void EnsureRenderTarget(int width, int height) - { - if (_target is not null && width == _fbW && height == _fbH) - return; - ReleaseRenderTarget(); - - IGpuRenderTarget target; - try - { - target = _device.CreateRenderTarget(new GpuRenderTargetDescription( - _diagnosticName, - width, - height, - GpuTextureFormat.Rgba8UnormRenderTarget, - // Depth24Stencil8, as the hand-rolled renderbuffer was: nothing - // samples it, and the stencil aspect keeps the attachment shape - // the depth/stencil renderers already expect. - GpuTextureFormat.Depth24Stencil8, - SampleCount: 1)); - } - catch (Exception failure) - { - Console.WriteLine( - $"[{_diagnosticName}] render target unavailable ({width}x{height}): {failure.Message}"); - return; - } - - try - { - // The retained UI blits this attachment as an ordinary table entry. - // Linear/clamped is the filtering the hand-rolled colour texture set - // on itself before the §7.1 seam registered it. - _sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp); - _slot = _device.RegisterTexture(target.ColorTexture, _sampler); - } - catch - { - target.Dispose(); - _sampler = null; - _slot = GpuTextureSlot.Unassigned; - throw; - } - - _target = target; - _fbW = width; - _fbH = height; - } - - private void ReleaseRenderTarget() - { - if (_slot.IsAssigned) - { - _device.ReleaseTextureSlot(_slot); - _slot = GpuTextureSlot.Unassigned; - } - _sampler = null; - _target?.Dispose(); - _target = null; - _fbW = 0; - _fbH = 0; - _hasRenderedScene = false; - } - public void Dispose() { List? failures = null; @@ -447,7 +396,7 @@ internal sealed class PrivateEntityViewportRenderer : } try { - ReleaseRenderTarget(); + _flightTargets.Dispose(); } catch (Exception error) { @@ -462,6 +411,163 @@ internal sealed class PrivateEntityViewportRenderer : } } + /// + /// Bounded render-target ownership keyed by . + /// A frame slot is reopened only after its previous submission retires, so + /// the target selected here can be written and sampled within that frame + /// without racing a different in-flight command buffer. + /// + internal sealed class PrivateViewportFlightTargets : IDisposable + { + internal sealed class TargetSlot( + IGpuRenderTarget target, + GpuTextureSlot textureSlot) + { + internal IGpuRenderTarget Target { get; } = target; + internal GpuTextureSlot TextureSlot { get; } = textureSlot; + internal bool HasRenderedScene { get; set; } + } + + private readonly IGpuDevice _device; + private readonly string _diagnosticName; + private readonly List _slots = []; + private int _width; + private int _height; + private bool _disposed; + + internal PrivateViewportFlightTargets( + IGpuDevice device, + string diagnosticName) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) + ? "creature viewport" + : diagnosticName; + } + + internal int AllocatedSlotCount => + _slots.Count(static slot => slot is not null); + + internal TargetSlot? Ensure(int frameSlot, int width, int height) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentOutOfRangeException.ThrowIfNegative(frameSlot); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + + if (_width != 0 && (_width != width || _height != height)) + ReleaseAll(); + + while (_slots.Count <= frameSlot) + _slots.Add(null); + if (_slots[frameSlot] is { } existing) + return existing; + + IGpuRenderTarget target; + try + { + target = _device.CreateRenderTarget( + new GpuRenderTargetDescription( + $"{_diagnosticName}-flight-{frameSlot}", + width, + height, + GpuTextureFormat.Rgba8UnormRenderTarget, + // Depth24Stencil8, as the original private viewport + // renderbuffer was. Nothing samples this attachment. + GpuTextureFormat.Depth24Stencil8, + SampleCount: 1)); + } + catch (Exception failure) + { + Console.WriteLine( + $"[{_diagnosticName}] render target unavailable " + + $"({width}x{height}, flight {frameSlot}): {failure.Message}"); + return null; + } + + try + { + // The device de-duplicates immutable samplers. Retained UI + // blits this target through its ordinary texture-table entry. + IGpuSampler sampler = _device.CreateSampler( + GpuSamplerDescription.WorldClamp); + GpuTextureSlot textureSlot = _device.RegisterTexture( + target.ColorTexture, + sampler); + var created = new TargetSlot(target, textureSlot); + _slots[frameSlot] = created; + _width = width; + _height = height; + return created; + } + catch + { + target.Dispose(); + throw; + } + } + + internal uint CompletedHandle(int frameSlot) + { + if ((uint)frameSlot >= (uint)_slots.Count + || _slots[frameSlot] is not { HasRenderedScene: true } slot) + { + return 0u; + } + + return UiTextureTableHandle.FromSlot(slot.TextureSlot); + } + + internal void InvalidateCompletedScenes() + { + for (int i = 0; i < _slots.Count; i++) + { + if (_slots[i] is { } slot) + slot.HasRenderedScene = false; + } + } + + private void ReleaseAll() + { + List? failures = null; + for (int i = 0; i < _slots.Count; i++) + { + TargetSlot? slot = _slots[i]; + if (slot is null) + continue; + try + { + _device.ReleaseTextureSlot(slot.TextureSlot); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + try + { + slot.Target.Dispose(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + _slots.Clear(); + _width = 0; + _height = 0; + if (failures is { Count: > 0 }) + throw new AggregateException(failures); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + ReleaseAll(); + } + } + /// /// One private entity's mesh-reference/texture-owner lifetime, independent /// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh diff --git a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs index 1a3d5db8..fa59bf91 100644 --- a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs +++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs @@ -16,6 +16,19 @@ internal interface IPrivateEntityViewportFrame void Render(); } +/// +/// Optional pre-world resource preparation for a private viewport whose +/// visibility must not compete with world composite uploads. The composite +/// upload budget opens with the frame's resource phase; a dense world can +/// consume all of it every frame, so a viewport that only prepares during +/// late presentation can starve indefinitely (#443's paperdoll: visible in +/// portal space — where the world is quiesced — and nowhere busy). +/// +internal interface IPrivateEntityViewportResourcePreparation +{ + void PrepareResources(); +} + internal interface IRetainedGameplayUiFrame { void Render(double deltaSeconds, int width, int height); diff --git a/src/AcDream.App/Rendering/RenderFramePreparationController.cs b/src/AcDream.App/Rendering/RenderFramePreparationController.cs index 82306d67..820ddaa1 100644 --- a/src/AcDream.App/Rendering/RenderFramePreparationController.cs +++ b/src/AcDream.App/Rendering/RenderFramePreparationController.cs @@ -29,20 +29,29 @@ internal sealed class RenderFramePreparationController : IRenderFrameResourcePha private readonly IRenderFrameResourcePhase _resources; private readonly IDevToolsFrameLifecycle? _devTools; private readonly IRenderWeatherFramePhase _weather; + private readonly IPrivateEntityViewportResourcePreparation? _privateViewports; public RenderFramePreparationController( IRenderFrameResourcePhase resources, IDevToolsFrameLifecycle? devTools, - IRenderWeatherFramePhase weather) + IRenderWeatherFramePhase weather, + IPrivateEntityViewportResourcePreparation? privateViewports = null) { _resources = resources ?? throw new ArgumentNullException(nameof(resources)); _devTools = devTools; _weather = weather ?? throw new ArgumentNullException(nameof(weather)); + _privateViewports = privateViewports; } public void Prepare(RenderFrameInput input) { _resources.Prepare(input); + // The composite upload budget opens with the resource phase. Give the + // paperdoll's private object its prewarm slot before the world can + // consume the complete per-frame budget (#443 — the doll rendered + // only while portal space quiesced the world); presentation samples + // the result later, after the world pass has closed. + _privateViewports?.PrepareResources(); _devTools?.BeginFrame((float)input.DeltaSeconds); _weather.Tick(input.DeltaSeconds); } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index 30b22946..2d9fac4b 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -924,6 +924,24 @@ public sealed unsafe partial class WbDrawDispatcher return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount); } + /// + /// #443 — the next classic draw is a PRIVATE pass (paperdoll, appraisal, + /// chargen preview) and must take the plain ring transform path with + /// firstInstance = 0, never an append into the shared world + /// transform frame. The default mesh shaders index every parallel + /// per-instance array (clip slots, light sets, indoor, opacity, selection, + /// detail category) zero-based — only the packed world submission's + /// shader convention subtracts a shared-arena prefix — so an + /// arena-appended classic draw with a non-zero base reads zeroed + /// per-instance data (opacity 0 ⇒ an invisible doll whenever a world + /// frame is active; portal space worked only because the arena was + /// inactive there). The private pass owns its own camera, lighting, and + /// target; per the self-contained-render-state rule it must not depend on + /// the world frame's pose address space at all. Consumed and cleared by + /// the next . + /// + internal bool NextClassicDrawIsPrivatePass; + private RhiSection WriteWorldTransformSection( IGpuFrame frame, ReadOnlySpan matrixFloats, @@ -939,7 +957,9 @@ public sealed unsafe partial class WbDrawDispatcher ObserveOrdinaryTransformDemand( frame.Serial, checked((uint)(matrixFloats.Length / 16))); - if (!_worldTransformFrames.IsActive) + bool privatePass = NextClassicDrawIsPrivatePass; + NextClassicDrawIsPrivatePass = false; + if (privatePass || !_worldTransformFrames.IsActive) { firstInstance = 0; return WriteRingSection(frame, matrixFloats); diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs index 41f47c47..5b4ee653 100644 --- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -8,6 +8,14 @@ namespace AcDream.App.Tests.Rendering; public sealed class PaperdollFramePresenterTests { + /// One frame in production order: the pre-world resource phase + /// (build/redress + prewarm) then the late presentation phase. + private static void Frame(PaperdollFramePresenter presenter) + { + presenter.PrepareResources(); + presenter.Render(); + } + [Fact] public void HiddenView_BuildsAndPrewarmsWithoutRendering() { @@ -16,7 +24,7 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = CreateDoll() }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); Assert.False(presenter.IsDirty); Assert.Equal(1, factory.BuildCount); @@ -34,7 +42,7 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = doll }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); Assert.False(presenter.IsDirty); Assert.Equal(1, factory.BuildCount); @@ -54,20 +62,25 @@ public sealed class PaperdollFramePresenterTests }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); - presenter.Render(); + Frame(presenter); + Frame(presenter); presenter.MarkDirty(); - presenter.Render(); + Frame(presenter); Assert.Equal(2, factory.BuildCount); - Assert.Single(renderer.Dolls); + Assert.Equal(2, renderer.Dolls.Count); Assert.Equal(3, renderer.RenderCount); Assert.False(presenter.IsDirty); } [Fact] - public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner() + public void PortalRefresh_RedressesEvenAnEquivalentAppearance() { + // #443: the private clone belongs to the current presentation + // generation. Retaining an old clone because its pixels compare equal + // can pin retired mesh/composite readiness that never completes + // again; the renderer's two-phase promote keeps the last completed + // image visible while the fresh clone becomes drawable. WorldEntity first = CreateDoll(); WorldEntity repeated = CreateDoll(); var renderer = new RecordingRenderer(); @@ -75,13 +88,13 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = first }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); factory.Doll = repeated; presenter.MarkDirty(); - presenter.Render(); + Frame(presenter); Assert.Equal(2, factory.BuildCount); - Assert.Equal([first], renderer.Dolls); + Assert.Equal([first, repeated], renderer.Dolls); Assert.Equal(2, renderer.RenderCount); Assert.False(presenter.IsDirty); } @@ -96,14 +109,34 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = first }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); factory.Doll = changed; presenter.MarkDirty(); - presenter.Render(); + Frame(presenter); Assert.Equal([first, changed], renderer.Dolls); } + [Fact] + public void TransientZeroRender_NeverErasesThePublishedTexture() + { + // #443: zero is a not-ready result (mesh/composite upload latency), + // not a request to blank a completed paperdoll. + var renderer = new RecordingRenderer { TextureHandle = 91u }; + var view = new RecordingView(); + var factory = new RecordingFactory { Doll = CreateDoll() }; + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + Frame(presenter); + renderer.TextureHandle = 0u; + Frame(presenter); + renderer.TextureHandle = 91u; + Frame(presenter); + + Assert.Equal([91u, 91u], view.TextureHandles); + Assert.Equal(0, view.ClearCount); + } + private static WorldEntity CreateDoll(float scale = 1f) => new() { Id = 42u, @@ -123,11 +156,11 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = firstDoll }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); factory.CanBuild = false; presenter.MarkDirty(); - presenter.Render(); - presenter.Render(); + Frame(presenter); + Frame(presenter); Assert.True(presenter.IsDirty); Assert.Equal(3, factory.BuildCount); @@ -146,14 +179,17 @@ public sealed class PaperdollFramePresenterTests var factory = new RecordingFactory { Doll = firstDoll }; var presenter = new PaperdollFramePresenter(renderer, view, factory); - presenter.Render(); + Frame(presenter); presenter.ResetSession(); factory.Doll = secondDoll; - presenter.Render(); + Frame(presenter); Assert.False(presenter.IsDirty); Assert.Equal(2, factory.BuildCount); Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls); + // The session boundary is the ONE explicit viewport clear (#443); + // an old character must not linger while the next one uploads. + Assert.Equal(1, view.ClearCount); } [Fact] @@ -252,7 +288,7 @@ public sealed class PaperdollFramePresenterTests private sealed class RecordingRenderer : IPaperdollDollRenderer { - public uint TextureHandle { get; init; } + public uint TextureHandle { get; set; } public List Dolls { get; } = []; public List<(int Width, int Height)> RenderSizes { get; } = []; public int RenderCount => RenderSizes.Count; @@ -285,6 +321,10 @@ public sealed class PaperdollFramePresenterTests public void SetTextureHandle(uint textureHandle) => TextureHandles.Add(textureHandle); + + public int ClearCount { get; private set; } + + public void ClearTextureHandle() => ClearCount++; } private sealed class RecordingFactory : IPaperdollDollFactory diff --git a/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs b/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs new file mode 100644 index 00000000..ac2d7bf5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PrivateViewportFlightTargetsTests.cs @@ -0,0 +1,65 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering; + +public sealed class PrivateViewportFlightTargetsTests +{ + [Fact] + public void FlightSlotsOwnDistinctTargetsAndPublishOnlyCompletedScenes() + { + using var device = new RecordingGpuDevice(); + using var targets = + new PrivateEntityViewportRenderer.PrivateViewportFlightTargets( + device, + "paperdoll"); + + var first = Assert.IsType< + PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>( + targets.Ensure(0, 120, 180)); + var second = Assert.IsType< + PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>( + targets.Ensure(1, 120, 180)); + + Assert.NotSame(first.Target, second.Target); + Assert.NotEqual(first.TextureSlot, second.TextureSlot); + Assert.Equal(2, targets.AllocatedSlotCount); + Assert.Equal(0u, targets.CompletedHandle(0)); + Assert.Equal(0u, targets.CompletedHandle(1)); + + first.HasRenderedScene = true; + + Assert.NotEqual(0u, targets.CompletedHandle(0)); + Assert.Equal(0u, targets.CompletedHandle(1)); + Assert.Same(first, targets.Ensure(0, 120, 180)); + + targets.InvalidateCompletedScenes(); + + Assert.Equal(0u, targets.CompletedHandle(0)); + Assert.Equal(2, targets.AllocatedSlotCount); + } + + [Fact] + public void ResizeRetiresEveryFlightTargetBeforeCreatingTheNewExtent() + { + using var device = new RecordingGpuDevice(); + using var targets = + new PrivateEntityViewportRenderer.PrivateViewportFlightTargets( + device, + "paperdoll"); + var first = targets.Ensure(0, 120, 180)!; + var second = targets.Ensure(1, 120, 180)!; + var firstTarget = Assert.IsType(first.Target); + var secondTarget = Assert.IsType(second.Target); + + var resized = targets.Ensure(1, 160, 220)!; + + Assert.True(firstTarget.IsDisposed); + Assert.True(secondTarget.IsDisposed); + Assert.Equal(1, targets.AllocatedSlotCount); + Assert.Equal(160, resized.Target.Description.Width); + Assert.Equal(220, resized.Target.Description.Height); + Assert.Equal(3, device.CreatedRenderTargets.Count); + } +} From 8acb544d4413c2d9eac43a150410c64159f8f0b6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 29 Aug 2026 17:11:41 +0200 Subject: [PATCH 5/6] docs: file #454 barred timered quest-item icon, #455 paperdoll click-dequip Both owner-reported 2026-08-29 after the #443 fix gate. #454: a boss quest item with a reuse timer lands in the backpack with the barred (unusable) icon overlay and stays barred; expected clear immediately, timer text is chat-only. #455: clicking an equipped item on the paperdoll does nothing; the retail gesture and gmPaperDollUI click handling must come from the named decomp before implementation. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0c0dab2f..530f6afa 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -39,6 +39,60 @@ confirmed closed by the owner, 11 need a focused live gate, and 43 are safe to remain closed. See [`docs/research/2026-08-28-owner-closed-issue-validity-audit.md`](research/2026-08-28-owner-closed-issue-validity-audit.md). +## #455 — Clicking an equipped item on the paperdoll does not dequip it + +**Status:** OPEN — needs retail investigation BEFORE any implementation. +**Severity:** MEDIUM (core inventory interaction missing) +**Component:** paperdoll viewport interaction / equipped-item picking + +**Symptom (owner, 2026-08-29):** clicking an equipped item on the inventory +paperdoll does nothing. The owner expects the click to dequip / pick up the +item; the exact retail gesture (single click pickup-to-cursor, click-drag, +or double-click) is NOT yet established and must come from the named decomp, +not a guess. + +**Retail investigation owed:** how `gmPaperDollUI` maps a doll-viewport +click to the equipped object — the hit test against the private +CreatureMode render and the handler that starts the pickup (class anchor +already known: `gmPaperDollUI::RedressCreature @ 0x004A3BC0`; the +click/drag handler functions are unmapped). Compare with the shipped +world-side equipped-child picking (M4 slice 4, user-accepted 2026-07-29), +which may share the pick primitive. + +**acdream side today:** `UiViewport` supports `Clicked`/`ClickedAt` +(viewport-local pixel coords), but the paperdoll wires no equipped-item hit +path at all — the click falls through, so "does nothing" is currently +by construction. + +--- + +## #454 — Timered quest-item pickup leaves the icon barred in the backpack + +**Status:** OPEN +**Severity:** LOW-MEDIUM (icon state presentation) +**Component:** inventory icon overlay state / pickup + quest-timer response +handling + +**Symptom (owner, 2026-08-29, with screenshot):** pick up a boss quest item +that carries a completion timer — ACE replies +`You may complete this quest again in 19h 59m 59s.` The item lands in the +backpack with the BARRED (unusable) icon overlay and stays barred; second +occurrence observed by the owner. Expected: the icon is clear immediately +once the item sits in the pack — the timer belongs in chat, not on the +icon. + +**Mechanism (unestablished — candidates only, verify before fixing):** +(a) the quest-timer/use-refusal response stamps an unusable icon state that +is never cleared when the pickup transaction completes; (b) the icon +overlay state machine misses a refresh on container placement. Retail +oracle to establish: how retail presents a freshly picked-up timered quest +item's icon (expected no bar). + +**Repro:** any boss quest item with a reuse timer on the local ACE server; +pick it up, watch the backpack icon. + +--- + ## #453 — Rain and thunder audio disappears while the Rainy sky remains active **Status:** DONE — USER-ACCEPTED 2026-08-28 From e102fb363e7cf793cb760d83bcbb62ab8a589a41 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 29 Aug 2026 19:03:35 +0200 Subject: [PATCH 6/6] fix(render) #132: outdoor alpha drains follow retail walk order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail draws every cell's objects inside LScape::draw's far-to-near walk (DrawSortCell @0x005A17C0) and drains the one delayed-alpha list at the DrawCells boundary AFTER the finished walk (@0x005A4872). Our outdoor frame drained at the landscape-stage end and then drew punches, interior shells, cell objects, and ALL dynamics — every one of those opaque passes overwrote the already-composited flames (the reopened #132 candle class: "the door draws over the candle", creatures at openings). Depth and barrier A/Bs were no-ops because the eraser is opaque color painted after the drain. Two retail-cited ordering corrections, outdoor-node roots only: 1. The stage-boundary drain is skipped and FlushLandscapeAlpha() runs after DrawDynamicsLast, where the frame's opaque world depth is complete — the one far-to-near list composites over everything, exactly like retail's boundary flush relative to its finished walk. 2. Before DrawExitPortalMasks, FlushLandscapeAlphaFartherThan( ExitPortalMaskBarrierDistance(...)) drains everything at or beyond the nearest cell whose exit-portal mask is about to write far-Z — retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE its portal-only pass, so in the far-to-near walk nothing already drained can meet a punched aperture's falsified depth. Without this, the first correction let exterior waterfalls z-pass across punched apertures whose true landscape depth the punch erased (found live at the cathedral gate). Nearer content stays queued and legitimately composites in front of punched structures. Interior roots keep the pre-clear stage-boundary drain unchanged. User-gated live: Holtburg sign candle whole in front of the sign and tower door at the aligned pose; cathedral waterfalls contained at every camera zoom, inside and outside. Register row AP-236 retired (the walk-order outcome reconstruction is complete; AP-34 remains the umbrella for the CYpt-sort reconstruction itself). Filed #456 for the separate occluded-distant-building/creature admission residual this session diagnosed. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 55 ++++++++++++ .../retail-divergence-register.md | 1 - .../Rendering/RetailPViewRenderer.cs | 87 ++++++++++++++++++- .../Rendering/RetailPViewPassExecutorTests.cs | 8 +- 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 530f6afa..0922068d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -39,6 +39,43 @@ confirmed closed by the owner, 11 need a focused live gate, and 43 are safe to remain closed. See [`docs/research/2026-08-28-owner-closed-issue-validity-audit.md`](research/2026-08-28-owner-closed-issue-validity-audit.md). +## #456 — Occluded distant buildings/creatures show through structures at raised cameras + +**Status:** OPEN — mechanism established 2026-08-29; needs its own slice. +**Severity:** MEDIUM (visual correctness at pulled-out cameras; the long-standing +"camera outside shows buildings behind terrain" residual, now with a mechanism) +**Component:** pview outdoor shell drawing / dynamics distance admission + +**Symptom (owner, 2026-08-29, Sanctuary cathedral terrace `0xF4180104` +[31.1, 55.7, 169.8]):** zoom the chase camera out and a building in the next +landblock (the `0xF518` shrine, model `0x01001777` at world (47173, 4741, 130), +317 m away) plus purple creatures appear across the lake where structure/ridge +should hide them; zoom in and they vanish. Verified with the punched-cell probe: +zoomed in (root = EnvCell `0xF4180104`, `visible=1`) the shrine never enters the +frame; zoomed out (root = landcell `0xF4180003`) the per-building flood admits +`0xF5180100`/`0xF5180106` at 316.8 m into the punch/shell pipeline. DAT terrain +profile from the true eye: the sight-line is blocked by a z=160 knoll by only +−2.8 m (roof) / −10.3 m (base), and a ~9 m camera raise makes the roofline +geometrically clear — but the screenshots show the WHOLE building, so an +occluder that should cover its lower half is not being drawn. + +**Established mechanism candidates (both retail-cited):** +1. Retail `RenderDeviceD3D::DrawBuilding @0x0059F2A0` draws a building's whole + exterior unconditionally in the block walk (`CPhysicsPart::Draw(parts, 0)`); + only the interior flood is portal-gated. Our shell pass draws only + flood-admitted cells' shells, so intervening cathedral pieces whose cells the + flood did not reach leave a hole the shrine shows through (the owner's + original "wall appears transparent though its geometry is present" read). +2. Retail degrades/skips distant objects (`DrawBuilding`'s + `gfxobj[deg_level] != 0` gate; `GfxObjDegradeInfo::get_degrade` off CYpt in + `CPhysicsPart::UpdateViewerDistance @0x0050E030`) and never draws creatures + at 300 m; we draw dynamics and admit look-in structures at unlimited range. + +**Prescribed next step:** RenderDoc pixel history on an artifact pixel (which +draw owns it, what should have covered it), then port the missing admission +gates. Full evidence chain in +`C:/Users/erikn/.codex/worktrees/16ee/acdream/docs/research/2026-08-29-cathedral-seam-progress.md`. + ## #455 — Clicking an equipped item on the paperdoll does not dequip it **Status:** OPEN — needs retail investigation BEFORE any implementation. @@ -19612,6 +19649,24 @@ the post-world PView replay is deleted. **Gate:** both sides — indoors with the opening behind the candle, and outdoors at the angle that previously erased it. +**2026-08-29 recurrence fixed (register AP-236 retired in the same commit):** +the #451 correction moved outdoor-static flames into the LScape-stage drain, +which reopened this class — every opaque pass after that drain (doors, +creatures, look-in interiors, DynamicsLast) overwrote the already-composited +flames, and depth/barrier A/Bs were no-ops because the eraser is opaque color +painted later. Fix, both halves retail-cited and user-gated live at Holtburg +(candle whole in front of sign + door) and the cathedral (waterfalls +contained at every zoom): (1) an OUTDOOR root skips the stage-boundary drain +and runs `FlushLandscapeAlpha()` after `DrawDynamicsLast` — retail's walk +draws every cell's objects before `PView::DrawCells`' boundary flush +(`DrawSortCell @0x005A17C0`, flush `@0x005A4872`); (2) before +`DrawExitPortalMasks`, a partial drain flushes everything at or beyond the +nearest punched cell (`ExitPortalMaskBarrierDistance`) — retail `DrawBuilding +@0x0059F2A0` runs `FlushAlphaList(0f)` BEFORE its portal-only far-Z pass, so +in the far→near walk nothing already drained can meet a punched aperture's +falsified depth (the waterfall-through-aperture regression the first half +exposed). Interior roots keep the pre-clear stage-boundary drain unchanged. + --- # Recently closed diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 88d8fda8..a7716216 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -257,7 +257,6 @@ research and is no longer active. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | 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-236 | **Filed 2026-08-29 with the particle-composition Change 1 (unclipped once-per-stage particle submission).** Retail accumulates every translucent submission in ONE alpha list in far→near WALK order — `block_draw_list` is built viewer-block-first and `LScape::draw @0x00506330` iterates it reversed; `RenderDeviceD3D::DrawBlock @0x005A17C0` walks each block per cell — and `DrawBuilding @0x0059F2A0` calls `FlushAlphaList(0f)` (the float is a COUNT threshold, 0 = flush all, `@0x0059D2E0`) BEFORE its opaque passes, so only content from cells FARTHER along the walk has been inserted when a building flushes; a nearer emitter (candle in front of a Holtburg door) inserts later and composites after that door. acdream's batched landscape has no per-cell walk: scene-particle owners submit at a few fixed points (pre-building barrier, late stage, cell stage) into the CYpt-sorted `RetailAlphaQueue`, and the pre-building barrier drains the WHOLE queue — a nearer-than-building emitter already queued is composited early, then the building/late-stage opaques overpaint it (the reopened #132 candle/creature-at-opening class). | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (barrier/late/cell submission points); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`FlushLandscapeAlpha`); `src/AcDream.App/Rendering/RetailAlphaQueue.cs` | Change 1 removed the invented per-slice clip-slot re-submission (particles are never view-clipped in retail); restoring the walk-order OUTCOME needs a farther-than-building partial drain at the pre-building barrier (planned Change 2), because the batched landscape cannot reproduce retail's insertion order directly and the queue's far→near CYpt sort is the established reconstruction (see AP-34) | A translucent effect NEARER than a building that is flushed at the pre-building barrier gets overpainted by that building's (or the late stage's) opaques — visible as the #132 candle/creature-at-opening class until Change 2 lands | `LScape::draw @0x00506330`; `RenderDeviceD3D::DrawBlock @0x005A17C0`; `RenderDeviceD3D::DrawBuilding @0x0059F2A0`; `D3DPolyRender::FlushAlphaList @0x0059D2E0`; `PView::DrawCells @0x005A4872` | | AP-234 | **Filed 2026-08-23 at the #426 solid-face extraction fix.** Cell-wall (EnvCell/CellStruct) geometry approximates retail's "skip untextured subsets inside a cell interior" with the polygon's own `Stippling.NoPos` flag rather than resolving the Surface's own `Type` (`Base1Image`/`Base1ClipMap`) before the per-polygon draw decision — the same NoPos-vs-surface-type conflation #426 fixed for ordinary GfxObj extraction (`PrepareGfxObjMeshData`/`GfxObjMesh.Build`), deliberately LEFT in place here | `src/AcDream.Core/Meshing/CellMesh.cs:45`; `src/AcDream.Content/MeshExtractor.cs`'s `PrepareCellStructMeshData` `hasPos` gate carries the identical rule | Cells are the one retail context that genuinely skips untextured subsets (`DrawEnvCell`), so approximating "untextured" with NoPos is directionally correct for the common case — a solid-colour polygon always carries NoPos since it has no UVs to carry; resolving Surface.Type first would need a per-polygon dat lookup this code doesn't currently perform before the emit/skip decision | A textured polygon whose author left NoPos set (no positive UVs authored despite a real texture) would be wrongly skipped as if untextured, or an untextured polygon whose author left NoPos unset would wrongly draw — either edge case shows as a cell wall gaining or losing a face relative to retail | `RenderDeviceD3D::DrawEnvCell` @0x0059f170 → `D3DPolyRender::DrawMesh(..., arg4=1)`; `RetailUntexturedSurfacePolicy`/`RetailUntexturedSubsetPolicy` (`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) | | 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` | diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 56f0a987..f1c71440 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -318,6 +318,31 @@ public sealed class RetailPViewRenderer frameEntityPasses, in frameView); passes.UseIndoorMembershipOnlyRouting(); + + // Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE + // its portal-only far-Z pass. In retail's strict far→near walk + // everything queued at that instant is FARTHER than the structure + // being punched, so no already-drained poly can meet a punched + // aperture's falsified depth, and everything drained later is + // NEARER than the punched structure and legitimately composites in + // front of it. The batched outdoor frame reproduces that invariant + // here: drain the far prefix — every entry at or beyond the + // nearest cell whose exit-portal mask is about to punch far-Z — + // against still-true landscape depth. Without this, an exterior + // waterfall beyond the cathedral drains after the punches and + // z-passes across every aperture pixel whose true depth the punch + // erased (#132 regression found at the 2026-08-29 cathedral gate). + // Interior roots keep their pre-clear stage-boundary drain. + if (ctx.RootCell.IsOutdoorNode) + { + passes.FlushLandscapeAlphaFartherThan( + ExitPortalMaskBarrierDistance( + pvFrame, + drawableCells, + ctx.Cells, + ctx.CameraWorldPosition)); + } + DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); DrawEnvCellShells(passes, pvFrame); DrawCellObjectLists( @@ -339,6 +364,17 @@ public sealed class RetailPViewRenderer frameEntityPasses, in frameView); + // OUTDOOR root: the LScape-boundary alpha drain deferred from the + // landscape stage runs HERE, after punches, interior shells, cell + // objects, and the dynamics pass — the frame's complete opaque + // world. Retail's walk draws all of those before its boundary + // flush (LScape::draw includes every cell's objects, + // DrawSortCell 0x005A17C0), so this is the same one-list far→near + // composite over finished depth; draining at the stage end instead + // let every later opaque mesh overwrite the flames (#132). + if (ctx.RootCell.IsOutdoorNode) + passes.FlushLandscapeAlpha(); + // Interior-cell UNATTACHED emitters (spell ground effects and // swirls anchored in EnvCells) draw in this final world scope — // the cells' walls and the seals already own the depth buffer, so @@ -467,6 +503,40 @@ public sealed class RetailPViewRenderer /// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0 /// (full drain, today's behavior) when no cell resolves. /// + /// + /// The pre-punch barrier threshold for : + /// the nearest drawable cell whose exit-portal mask is about to write + /// far-Z. Every queued alpha entry at or beyond it must drain first + /// (retail DrawBuilding @0x0059F2A0's FlushAlphaList(0f) before the + /// portal-only pass), because after the punch those entries would z-pass + /// across aperture pixels whose true depth no longer exists. No punched + /// cells → MaxValue → the partial drain retains everything. + /// + internal static float ExitPortalMaskBarrierDistance( + PortalVisibilityFrame frame, + HashSet drawableCells, + IRetailPViewCellSource cells, + Vector3 viewerPosition) + { + float best = float.PositiveInfinity; + for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) + { + uint cellId = frame.OrderedVisibleCells[i]; + if (!drawableCells.Contains(cellId)) + continue; + LoadedCell? cell = cells.Find(cellId); + if (cell is null) + continue; + float distance = Vector3.Distance( + cell.WorldTransform.Translation, + viewerPosition); + if (distance < best) + best = distance; + } + + return float.IsFinite(best) ? best : float.MaxValue; + } + internal static float LookInBarrierDrainDistance( PortalVisibilityFrame frame, IRetailPViewCellSource cells, @@ -1102,7 +1172,22 @@ public sealed class RetailPViewRenderer // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // immediately after LScape::draw and before the optional depth clear. // The queue remains active for the post-clear/final-world scope. - passes.FlushLandscapeAlpha(); + // + // Only an INTERIOR root drains here: its full depth clear follows, and + // a flame drained after that clear would z-pass through every interior + // wall. An OUTDOOR root has no depth clear (retail gates it on + // portalsDrawnCount, pc:432731), and retail's LScape::draw walk has + // already drawn every building interior and every cell object via + // DrawSortCell 0x005A17C0 before that boundary — while our outdoor + // frame draws punches, interior shells, cell objects, and ALL dynamics + // (doors, creatures, NPCs) after this point. Draining here painted the + // flames first and let each of those later opaque meshes overwrite + // them (#132: "the door draws over the candle"); the outdoor drain + // therefore runs after DrawDynamicsLast, where world depth is complete + // and the one far-to-near list composites over everything, exactly as + // retail's boundary flush does relative to its finished walk. + if (!ctx.RootCell.IsOutdoorNode) + passes.FlushLandscapeAlpha(); // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index f288b96d..36628dbf 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -35,12 +35,18 @@ public sealed class RetailPViewPassExecutorTests "clear-routing", "landscape-late", "unattached-particles-outdoor", - "landscape-alpha", + // #132: an OUTDOOR root does NOT drain at the stage boundary. + // The far prefix drains at the pre-punch barrier (retail + // DrawBuilding @0x0059F2A0 flushes before its portal-only + // far-Z pass), and the full drain runs after the dynamics + // pass, where the frame's opaque world depth is complete. "indoor-routing", "indoor-routing", + "landscape-alpha-farther", "exit-mask", "indoor-routing", "opaque-shells", + "landscape-alpha", "unattached-particles-interior", ], executor.Operations);