diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 7744316e..0922068d 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -39,6 +39,97 @@ 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.
+**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
@@ -431,7 +522,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
@@ -19536,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/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/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/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/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 bc582a3d..f1c71440 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -28,6 +28,19 @@ 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();
+
+ // 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
@@ -166,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;
}
@@ -251,10 +270,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);
@@ -296,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(
@@ -317,6 +364,24 @@ 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
+ // 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);
@@ -428,6 +493,71 @@ 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.
+ ///
+ ///
+ /// 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,
+ 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++)
@@ -485,12 +615,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)
@@ -550,6 +690,8 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e);
}
+ bool cellDrewObjects = false;
+ _cellParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in cellSlices)
{
int routeIndex = lookInRouteIndex++;
@@ -595,12 +737,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
@@ -612,6 +763,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(
@@ -690,14 +842,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();
}
}
@@ -795,14 +953,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)
{
@@ -835,15 +998,30 @@ public sealed class RetailPViewRenderer
barrierSliceIndex,
0);
}
-
- passes.DrawLandscapeStaticParticles(
- ctx,
- new RetailPViewLandscapeStaticParticleContext(
- slice,
- _lateParticleOwnerScratch));
- barrierSliceIndex++;
+ _staticParticleUnionScratch.UnionWith(
+ _lateParticleOwnerScratch);
}
- passes.FlushLandscapeAlpha();
+
+ // ONE unclipped submission for the union of every slice's cone
+ // 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.FlushLandscapeAlphaFartherThan(
+ LookInBarrierDrainDistance(
+ _lookInFrames[0],
+ ctx.Cells,
+ ctx.CameraWorldPosition));
}
// #124: far-building look-ins draw HERE — still inside the landscape
@@ -862,8 +1040,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)
{
@@ -891,7 +1071,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)
@@ -931,37 +1119,75 @@ 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.
// 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 —
@@ -1134,13 +1360,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(
@@ -1177,6 +1403,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);
@@ -1223,22 +1457,28 @@ 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
{
_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)
@@ -1630,10 +1870,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);
@@ -1910,6 +2169,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 +2195,7 @@ public sealed class RetailPViewFrameResult
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
HashSet drawableCells,
+ HashSet inViewCells,
RenderFrameDiagnosticCounts diagnosticCounts,
RenderProjectionCounts sourceCounts,
InteriorEntityPartition.Result? diagnosticPartition)
@@ -1926,6 +2203,7 @@ public sealed class RetailPViewFrameResult
PortalFrame = portalFrame;
ClipAssembly = clipAssembly;
DrawableCells = drawableCells;
+ InViewCells = inViewCells;
DiagnosticCounts = diagnosticCounts;
SourceCounts = sourceCounts;
DiagnosticPartition = diagnosticPartition;
@@ -1941,6 +2219,7 @@ public sealed class RetailPViewFrameResult
portalFrame,
clipAssembly,
drawableCells,
+ drawableCells,
RetailPViewRenderer.LegacyDiagnosticCounts(
diagnosticPartition),
RetailPViewRenderer.LegacySourceCounts(
@@ -1956,12 +2235,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
@@ -1978,8 +2258,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.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/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/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/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/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);
+ }
+}
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..36628dbf 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -34,13 +34,20 @@ public sealed class RetailPViewPassExecutorTests
"terrain-clip",
"clear-routing",
"landscape-late",
- "unattached-particles",
- "landscape-alpha",
+ "unattached-particles-outdoor",
+ // #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);
}
@@ -65,7 +72,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 +100,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,9 +310,9 @@ public sealed class RetailPViewPassExecutorTests
AssertAppearsInOrder(
string.Join('|', executor.Operations),
"landscape-early",
- "unattached-particles",
+ "unattached-particles-outdoor",
"landscape-static-particles",
- "landscape-alpha",
+ "landscape-alpha-farther",
"look-in-punch",
"landscape-late",
"landscape-alpha",
@@ -353,7 +386,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");
}
@@ -763,8 +796,13 @@ 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 FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
+ Operations.Add("landscape-alpha-farther");
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
public void DrawDynamicsParticles(
RetailPViewFrameInput frame,
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(
[
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()
{