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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-29 12:35:30 +02:00
parent 85530c0b7e
commit 684380d421
10 changed files with 518 additions and 85 deletions

View file

@ -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 | | # | 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<char>*) @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-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<char>*) @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-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-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` |
| AP-232 | **Filed 2026-08-22 at Campaign VM slice VM1 (the #226 single-pass re-port; deviation introduced at `05970306`, row owed since then).** Retail's single-pass detail combine produces ONE pixel per subset whose OUTPUT alpha is stage 1's `MODULATE(TEXTURE, CURRENT)` (`D3DPolyRender::SetSurface @0x0059c4d0`, op at `0x0059c549`) — for a delayed-alpha (translucent) subset that product is the framebuffer blend weight. acdream draws the base subset with its own alpha, then a second `mesh_detail` draw weighted by `detail.a * instanceOpacity` under `SRCALPHA + INVSRCALPHA`. For OPAQUE subsets (base alpha 1) the two compose to exactly `lerp(base, detail, detail.a*opacity)` and, with both draws fogged, to retail's fog-after-combine pixel (identity pinned by `RetailDetailTextureContractTests`). For TRANSLUCENT building/EnvCell subsets the destination after the base draw is `mix(behind, foggedBase, baseAlpha)`, not `foggedBase`, so the detail weight differs from retail's single product. | `src/AcDream.App/Rendering/Shaders/mesh_detail.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (transparent interleave); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (transparent interleave) | Opaque subsets are the overwhelming majority of building shells and interior walls and are exact; translucent detail-bearing subsets (ClipMap/alpha/additive/inverse-alpha glass and grates) get a bounded weight difference that never exceeds the detail texture's own alpha (mean 0.132 on the live Dereth category texture). Collapsing to one draw would require the base pipelines to sample the detail texture, i.e. a second `mesh_modern` variant on the retail path. | A translucent building/EnvCell surface with the detail preference on reads visibly different from retail against a bright background. Separate from AP-34 (queue ORDER); this row is about the blend WEIGHT. | `D3DPolyRender::SetSurface @0x0059c4d0` (stage table), `RenderMeshSubset @0x0059ca10`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md` | | AP-232 | **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` |

View file

@ -234,7 +234,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
IReadOnlySet<uint> attachedOwnerIds, IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached = false, bool includeUnattached = false,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null, IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
uint clipSlot = 0) uint clipSlot = 0,
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
{ {
if (camera is null) if (camera is null)
return; return;
@ -244,7 +245,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
attachedOwnerIds, attachedOwnerIds,
includeUnattached, includeUnattached,
_scopedEmitterScratch, _scopedEmitterScratch,
excludedAttachedOwnerIds); excludedAttachedOwnerIds,
unattachedCellScope);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView); Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));

View file

@ -239,6 +239,106 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
} }
} }
/// <summary>
/// Drains only the entries at or beyond <paramref name="minViewerDistance"/>
/// 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
/// <c>DrawBuilding</c>'s <c>FlushAlphaList(0f)</c> @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 <see cref="Flush"/>.
/// </summary>
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<Exception>? 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() public void EndFrame()
{ {
if (!IsCollecting) if (!IsCollecting)

View file

@ -444,20 +444,9 @@ internal sealed class RetailPViewPassExecutor :
animatedEntityIds: frame.AnimatedEntityIds); animatedEntityIds: frame.AnimatedEntityIds);
} }
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); // Late-stage particle owners submit ONCE per frame through
// DrawLandscapeStaticParticles after the slice loop (retail: one
if (_particleClassifications.Outdoor.Count > 0 // unclipped alpha-list insertion per emitter), not per slice here.
&& _particles is not null
&& _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot);
}
EnableClipDistances(); EnableClipDistances();
if (frame.RenderSky && frame.RenderWeather) if (frame.RenderSky && frame.RenderWeather)
{ {
@ -492,8 +481,12 @@ internal sealed class RetailPViewPassExecutor :
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context) RetailPViewLandscapeStaticParticleContext context)
{ {
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); // One unclipped submission per owner per frame. Retail never clips a
_surface.BindTerrainClip(); // 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(); DisableClipDistances();
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
@ -506,11 +499,9 @@ internal sealed class RetailPViewPassExecutor :
frame.CameraWorldPosition, frame.CameraWorldPosition,
ParticleRenderPass.Scene, ParticleRenderPass.Scene,
_particleClassifications.Outdoor, _particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot); clipSlot: 0);
} }
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting(); _entities.ClearClipRouting();
DisableClipDistances(); DisableClipDistances();
} }
@ -573,11 +564,19 @@ internal sealed class RetailPViewPassExecutor :
public void DrawUnattachedSceneParticles( public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
ClipViewSlice slice) bool outdoorCells)
{ {
if (_particles is null || _particleRenderer is null) if (_particles is null || _particleRenderer is null)
return; 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(); DisableClipDistances();
_particleRenderer.DrawForOwners( _particleRenderer.DrawForOwners(
frame.Camera, frame.Camera,
@ -585,11 +584,17 @@ internal sealed class RetailPViewPassExecutor :
ParticleRenderPass.Scene, ParticleRenderPass.Scene,
_noSceneParticleEntityIds, _noSceneParticleEntityIds,
includeUnattached: true, includeUnattached: true,
clipSlot: (uint)slice.Slot); clipSlot: 0,
unattachedCellScope: outdoorCells
? UnattachedEmitterCellScope.OutdoorCells
: UnattachedEmitterCellScope.InteriorCells);
} }
public void FlushLandscapeAlpha() => _alpha.Flush(); public void FlushLandscapeAlpha() => _alpha.Flush();
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
_alpha.FlushFartherThan(minViewerDistance);
public void DrawCellParticles( public void DrawCellParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
RetailPViewCellSliceContext context) RetailPViewCellSliceContext context)
@ -608,12 +613,14 @@ internal sealed class RetailPViewPassExecutor :
return; return;
DisableClipDistances(); 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( _particleRenderer.DrawForOwners(
frame.Camera, frame.Camera,
frame.CameraWorldPosition, frame.CameraWorldPosition,
ParticleRenderPass.Scene, ParticleRenderPass.Scene,
visible, visible,
clipSlot: (uint)context.Slice.Slot); clipSlot: 0);
DisableClipDistances(); DisableClipDistances();
} }

View file

@ -28,6 +28,12 @@ public sealed class RetailPViewRenderer
private static readonly IReadOnlySet<uint> NoParticleOwners = private static readonly IReadOnlySet<uint> NoParticleOwners =
new HashSet<uint>(); new HashSet<uint>();
// 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<uint> _staticParticleUnionScratch = new();
private readonly HashSet<uint> _cellParticleUnionScratch = new();
private readonly HashSet<uint> _oneCell = new(1); private readonly HashSet<uint> _oneCell = new(1);
// Shell-batch scratch: all of a pass's cells collected for ONE batched // 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 // opaque Render call (instead of one heavy Render per cell). Reused across
@ -320,6 +326,13 @@ public sealed class RetailPViewRenderer
frameEntityPasses, frameEntityPasses,
in frameView); 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) if (entityFrameOpen)
{ {
frameEntityPasses!.CompleteEntityFrame(in frameView); frameEntityPasses!.CompleteEntityFrame(in frameView);
@ -431,6 +444,37 @@ public sealed class RetailPViewRenderer
} }
} }
/// <summary>
/// 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.
/// </summary>
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() private void RecycleLookInFrames()
{ {
for (int i = 0; i < _lookInFrames.Count; i++) for (int i = 0; i < _lookInFrames.Count; i++)
@ -553,6 +597,8 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e); _cellStaticScratch.Add(e);
} }
bool cellDrewObjects = false;
_cellParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in cellSlices) foreach (ClipViewSlice slice in cellSlices)
{ {
int routeIndex = lookInRouteIndex++; int routeIndex = lookInRouteIndex++;
@ -598,12 +644,21 @@ public sealed class RetailPViewRenderer
_cellStaticScratch, _cellStaticScratch,
_oneCell); _oneCell);
// The nested DrawCells object pass includes emitters and cellDrewObjects = true;
// retains the exact setup_view clip until alpha playback. _cellParticleUnionScratch.UnionWith(
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( _cellParticleOwnerScratch);
cellId, slice, _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 // 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 // anchor EnvCell; never let an unrelated building repaint a
// look-in merely because both happen to be nearby. // look-in merely because both happen to be nearby.
int sliceIndex = 0; int sliceIndex = 0;
_staticParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
{ {
int shellRouteIndex = LookInBuildingShellRouteIndex( int shellRouteIndex = LookInBuildingShellRouteIndex(
@ -693,14 +749,20 @@ public sealed class RetailPViewRenderer
_lateParticleOwnerScratch, _lateParticleOwnerScratch,
_buildingShellScratch); _buildingShellScratch);
} }
passes.DrawLandscapeStaticParticles( _staticParticleUnionScratch.UnionWith(
ctx, _lateParticleOwnerScratch);
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
} }
sliceIndex++; 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; bool hasBuildingLookIns = _lookInFrames.Count > 0;
if (hasBuildingLookIns) if (hasBuildingLookIns)
{ {
int barrierSliceIndex = 0; // Ownerless OUTDOOR-cell emitters cannot ride an entity route.
foreach (var slice in clipAssembly.OutsideViewSlices) // Retail inserts each one into the single alpha list once, during
{ // its cell's landscape walk turn, with no portal-view clip; the
// Ownerless outdoor emitters cannot ride an entity route. Retail // interior-cell ownerless emitters submit in the final world
// draws their meshes once for every installed outside_view; // scope instead (see DrawDynamicsLast).
// retain that slot through deferred alpha playback. passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
passes.DrawUnattachedSceneParticles(ctx, slice);
_staticParticleUnionScratch.Clear();
int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length;
for (int barrierSliceIndex = 0;
barrierSliceIndex < outsideSliceTotal;
barrierSliceIndex++)
{
_lateParticleOwnerScratch.Clear(); _lateParticleOwnerScratch.Clear();
if (partition is not null) if (partition is not null)
{ {
@ -838,14 +905,17 @@ public sealed class RetailPViewRenderer
barrierSliceIndex, barrierSliceIndex,
0); 0);
} }
_staticParticleUnionScratch.UnionWith(
passes.DrawLandscapeStaticParticles( _lateParticleOwnerScratch);
ctx,
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
barrierSliceIndex++;
} }
// 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(); passes.FlushLandscapeAlpha();
} }
@ -865,8 +935,10 @@ public sealed class RetailPViewRenderer
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the // pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior // look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted) + the scene-particle owners (statics + // instead of being overpainted). The scene-particle owners (statics +
// dynamics cone survivors — flames ride here for the same reason). // dynamics cone survivors) accumulate across the slices and submit
// ONCE, unclipped, after the loop.
_staticParticleUnionScratch.Clear();
probeSliceIndex = 0; probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices) foreach (var slice in clipAssembly.OutsideViewSlices)
{ {
@ -894,7 +966,15 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{ {
_outdoorStaticScratch.Add(e); _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) if (frameEntityPasses is not null)
@ -934,32 +1014,55 @@ public sealed class RetailPViewRenderer
0, 0,
ctx.PlayerLandblockId ?? 0); ctx.PlayerLandblockId ?? 0);
probeSliceIndex++; probeSliceIndex++;
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
passes.DrawLandscapeSliceLate( passes.DrawLandscapeSliceLate(
ctx, ctx,
new RetailPViewLandscapeLateSliceContext( new RetailPViewLandscapeLateSliceContext(
slice, slice,
_outdoorStaticScratch, _outdoorStaticScratch)
_lateParticleOwnerScratch)
{ {
EntityDraw = entityDraw, 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, // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id // campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. Draw once per // to ride any of the id-filtered particle passes. OUTDOOR-cell ones
// installed outside_view for BOTH root kinds, matching retail's // submit ONCE in the landscape stage, unclipped — retail inserts each
// landscape-stage placement and preserving the slot in each deferred // particle into the single alpha list during its owner cell's walk
// draw. The former outdoor-root post-world tail ran after building // turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
// cells and let exterior alpha repaint the cathedral transition. // FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
// With no look-ins they drain at the end of the landscape stage; the // once-per-outside-slice submission with that slice's clip slot cut
// look-in path submits them at its pre-building barrier so later opaque // effects at aperture boundaries and drew NOTHING when no outside
// cell floors can cover them. // 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) if (!hasBuildingLookIns)
{ passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
passes.DrawUnattachedSceneParticles(ctx, slice);
}
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear. // immediately after LScape::draw and before the optional depth clear.
@ -1137,13 +1240,13 @@ public sealed class RetailPViewRenderer
Array.Empty<WorldEntity>(), Array.Empty<WorldEntity>(),
visibleCellIds: null); visibleCellIds: null);
// An owner routed through any pre-clear outside slice already had // Particles emit exactly once, in the stage matching the parent
// its alpha particles drawn there. Meshes may be submitted in both // cell. Pure-outdoor dynamics are absent from the DynamicLast
// stages, but particles must be emitted exactly once. // route (they draw only in the outside stage), and interior
RenderFrameRouteOwnerSelector.ExceptRoute( // straddlers — present in BOTH routes — emit their particles
_dynamicParticleOwnerScratch, // HERE so the interior stage cannot repaint over them; the late
in frameView, // landscape submission excludes DynamicLast owners for the same
RenderFrameCandidateRoute.LandscapeOutsideDynamic); // reason.
if (_dynamicParticleOwnerScratch.Count > 0) if (_dynamicParticleOwnerScratch.Count > 0)
{ {
passes.DrawDynamicsParticles( passes.DrawDynamicsParticles(
@ -1240,8 +1343,12 @@ public sealed class RetailPViewRenderer
else else
{ {
_dynamicParticleOwnerScratch.Clear(); _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) foreach (var e in _dynamicsScratch)
if (!_outsideStageDynamics.Contains(e)) if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
_dynamicParticleOwnerScratch.Add(e.Id); _dynamicParticleOwnerScratch.Add(e.Id);
} }
if (_dynamicParticleOwnerScratch.Count > 0) if (_dynamicParticleOwnerScratch.Count > 0)
@ -1633,10 +1740,29 @@ public interface IRetailPViewPassExecutor
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
RetailPViewCellSliceContext context, RetailPViewCellSliceContext context,
int portalIndex); int portalIndex);
/// <summary>
/// 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.
/// </summary>
void DrawUnattachedSceneParticles( void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
ClipViewSlice slice); bool outdoorCells);
void FlushLandscapeAlpha(); void FlushLandscapeAlpha();
/// <summary>
/// Pre/inter-building barrier drain: composites only the queued alpha at
/// or beyond <paramref name="minViewerDistance"/> 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.
/// </summary>
void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds); void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result); void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
@ -1979,12 +2105,13 @@ public readonly record struct RetailPViewLandscapeSliceContext(
} }
/// <summary> /// <summary>
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier. /// Scene-particle owners for ONE unclipped landscape-stage submission (the
/// Mesh alpha for the same owners is already queued by the early landscape /// union of every outside slice's cone survivors). Mesh alpha for the same
/// entity route. /// 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.
/// </summary> /// </summary>
public readonly record struct RetailPViewLandscapeStaticParticleContext( public readonly record struct RetailPViewLandscapeStaticParticleContext(
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds); IReadOnlySet<uint> ParticleOwnerIds);
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after /// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
@ -2001,8 +2128,7 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
/// submitted at a pre-building barrier.</summary> /// submitted at a pre-building barrier.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext( public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice, ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics, IReadOnlyList<WorldEntity> Dynamics)
IReadOnlySet<uint> ParticleOwnerIds)
{ {
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
} }

View file

@ -526,7 +526,8 @@ public sealed class ParticleSystem : IParticleSystem
IReadOnlySet<uint> attachedOwnerIds, IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached, bool includeUnattached,
List<ParticleEmitter> destination, List<ParticleEmitter> destination,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null) IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
{ {
ArgumentNullException.ThrowIfNull(attachedOwnerIds); ArgumentNullException.ThrowIfNull(attachedOwnerIds);
ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(destination);
@ -539,8 +540,11 @@ public sealed class ParticleSystem : IParticleSystem
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex]) foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
{ {
LastRenderScopeEmitterVisitCount++; LastRenderScopeEmitterVisitCount++;
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
&& MatchesUnattachedCellScope(emitter, unattachedCellScope))
{
destination.Add(emitter); destination.Add(emitter);
}
} }
} }
@ -566,6 +570,29 @@ public sealed class ParticleSystem : IParticleSystem
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle)); destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
} }
/// <summary>
/// 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 &lt; 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.
/// </summary>
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<ParticleEmitter> public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter>
{ {
private readonly ParticleSystem _owner; private readonly ParticleSystem _owner;

View file

@ -46,6 +46,20 @@ public enum ParticleRenderPass
SkyPostScene = 2, SkyPostScene = 2,
} }
/// <summary>
/// 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.
/// </summary>
public enum UnattachedEmitterCellScope
{
Any = 0,
OutdoorCells = 1,
InteriorCells = 2,
}
/// <summary> /// <summary>
/// Authority used by retail's particle presentation gate. World-owned /// Authority used by retail's particle presentation gate. World-owned
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and /// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and

View file

@ -75,6 +75,80 @@ public sealed class RetailAlphaQueueTests
Assert.Equal(2, source.ResetCount); 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<string>();
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<string>();
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<string>();
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] [Fact]
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer() public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
{ {

View file

@ -34,13 +34,14 @@ public sealed class RetailPViewPassExecutorTests
"terrain-clip", "terrain-clip",
"clear-routing", "clear-routing",
"landscape-late", "landscape-late",
"unattached-particles", "unattached-particles-outdoor",
"landscape-alpha", "landscape-alpha",
"indoor-routing", "indoor-routing",
"indoor-routing", "indoor-routing",
"exit-mask", "exit-mask",
"indoor-routing", "indoor-routing",
"opaque-shells", "opaque-shells",
"unattached-particles-interior",
], ],
executor.Operations); executor.Operations);
} }
@ -65,7 +66,7 @@ public sealed class RetailPViewPassExecutorTests
string.Join('|', executor.Operations), string.Join('|', executor.Operations),
"landscape-early", "landscape-early",
"landscape-late", "landscape-late",
"unattached-particles", "unattached-particles-outdoor",
"landscape-alpha", "landscape-alpha",
"interior-depth-clear", "interior-depth-clear",
"indoor-routing", "indoor-routing",
@ -93,6 +94,32 @@ public sealed class RetailPViewPassExecutorTests
Assert.DoesNotContain("interior-depth-clear", executor.Operations); 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] [Fact]
public void Particle_classifications_reset_before_an_empty_following_frame() public void Particle_classifications_reset_before_an_empty_following_frame()
{ {
@ -277,7 +304,7 @@ public sealed class RetailPViewPassExecutorTests
AssertAppearsInOrder( AssertAppearsInOrder(
string.Join('|', executor.Operations), string.Join('|', executor.Operations),
"landscape-early", "landscape-early",
"unattached-particles", "unattached-particles-outdoor",
"landscape-static-particles", "landscape-static-particles",
"landscape-alpha", "landscape-alpha",
"look-in-punch", "look-in-punch",
@ -763,7 +790,10 @@ public sealed class RetailPViewPassExecutorTests
int portalIndex) => Operations.Add("look-in-punch"); int portalIndex) => Operations.Add("look-in-punch");
public void DrawUnattachedSceneParticles( public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame, 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 FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
public void DrawDynamicsParticles( public void DrawDynamicsParticles(

View file

@ -742,6 +742,58 @@ public sealed class ParticleSystemTests
Assert.Equal(new[] { ownerNine }, destination.Select(emitter => emitter.Handle)); 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<ParticleEmitter>();
var none = new HashSet<uint>();
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] [Fact]
public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable() public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable()
{ {