fix(rendering): port retail shared alpha list

Queue translucent world GfxObj batches and scene particles in one stable far-to-near stream using transformed DAT sort centers, then drain it at retail's landscape and final-world boundaries. Preserve authored blend, cull, lighting, opacity, and adjacent-only batching so particles behind lifestones are composited through the crystal instead of overpainting it.

Release build succeeds and all 5,914 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 08:14:05 +02:00
parent ec1bb19609
commit 6b0472ee32
14 changed files with 1083 additions and 34 deletions

View file

@ -44,6 +44,45 @@ Copy this block when adding a new issue:
---
## #225 — Scene particles overpaint translucent world objects
**Status:** IN-PROGRESS — implementation complete; connected visual gate pending
**Severity:** MEDIUM
**Filed:** 2026-07-18
**Component:** rendering / world translucency / particles
**Description:** Smoke, candle flames, and other scene particles behind a
translucent lifestone crystal remained fully bright and visible through it.
The crystal itself had the correct DAT translucency, but particles composited
as though no transparent object were in front of them.
**Root cause / status:** `WbDrawDispatcher` finished its transparent object
pass before `ParticleRenderer` began its independent scene-particle pass.
Both paths depth-tested but disabled depth writes, so a later particle behind
the crystal still passed the opaque depth buffer and overpainted the crystal.
Retail makes each emitted particle a `CPhysicsPart`, orders it with ordinary
parts by the transformed GfxObj `sort_center`, and appends delayed surfaces to
one alpha list. acdream now has one frame-scoped `RetailAlphaQueue` shared by
world Wb entities and scene particles, with retail's landscape and final-world
flush boundaries. It preserves DAT blend/cull/lighting state and batches only
adjacent compatible submissions so renderer grouping cannot change the
compositing order.
**Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`;
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`;
`src/AcDream.App/Rendering/ParticleRenderer.cs`;
`src/AcDream.App/Rendering/RetailPViewRenderer.cs`.
**Research:**
`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`.
**Acceptance:** At a translucent lifestone, smoke or flame behind the crystal
is attenuated by it while an effect in front remains bright. The lifestone's
own transparency, nearby candle/portal particles, indoor/outdoor PView
transitions, paperdoll rendering, and portal-space rendering do not regress.
---
## #224 — Gameplay indicator bar only implemented effect icons
**Status:** IN-PROGRESS

View file

@ -220,6 +220,8 @@ src/
TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path
Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch
Wb/EnvCellRenderer.cs -> indoor cell-shell draw path
ParticleRenderer.cs -> DAT particle billboard/full-mesh dispatch
RetailAlphaQueue.cs -> shared delayed world alpha ordering/flush owner
TextureCache.cs -> done
ChaseCamera.cs -> done
FlyCamera.cs -> done
@ -512,6 +514,10 @@ matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior.
└── Read current entity mesh refs, draw
TerrainModernRenderer + WbDrawDispatcher + EnvCellRenderer
(frustum cull, translucency pass, portal visibility, etc.)
World Wb translucents + Scene particles submit to RetailAlphaQueue;
RetailPViewRenderer flushes the landscape scope before the conditional
depth clear and GameWindow flushes the final scope before private
viewports/UI. Sky/private viewport particles remain immediate.
Projectiles remain ordinary live-entity draws; there is no global or
projectile-specific render pass.

View file

@ -129,7 +129,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive D3DBLEND_ONE per-surface routing |
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive D3DBLEND_ONE |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
@ -141,7 +141,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |

View file

@ -130,6 +130,25 @@ anchors: `CPhysicsPart::GetMaxDegradeDistance` `0x0050D510`,
`CPhysicsObj::ShouldDrawParticles` `0x0050FE60`, and
`ParticleEmitter::UpdateParticles` `0x0051D180`.
**Retail shared world-alpha seam (2026-07-18).** WorldBuilder's editor
renderers classify translucent mesh batches correctly, but they have no live
retail `CPartCell`/`CShadowPart` list and render particles in a separate
batcher. `src/AcDream.App/Rendering/RetailAlphaQueue.cs` is therefore an
acdream-owned runtime seam above the extracted mesh pipeline. During the main
world frame, `WbDrawDispatcher` and `ParticleRenderer` submit transparent
GfxObj subsets and scene particles into one stable far-to-near stream keyed by
the transformed DAT `SortCenter`; only adjacent compatible entries may batch.
`RetailPViewRenderer` drains the landscape scope before the optional depth
clear, and `GameWindow` drains the final scope before private viewports/UI.
Sky and sealed off-screen render targets remain independent. No DAT reader,
mesh decoder, or second scene graph was introduced. Retail anchors:
`CPhysicsPart::UpdateViewerDistance` `0x0050E030`,
`RenderDeviceD3D::DrawObjCellForDummies` `0x005A0760`,
`CShadowPart::insertion_sort` `0x006B5130`,
`D3DPolyRender::AddMeshToAlphaList` `0x0059C230`, and
`D3DPolyRender::FlushAlphaList` `0x0059D2E0`. The modern per-cell-order and
EnvCell-shell residual is tracked explicitly as AP-34.
**Retail portal-space viewport adapter (2026-07-15).**
`src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted
Setup/GfxObj mesh pipeline and mandatory `WbDrawDispatcher` for retail's

View file

@ -0,0 +1,191 @@
# Retail shared alpha-list and particle ordering
## Scope
This note records the Sept. 2013 retail mechanism that orders translucent
physics-object parts and particle parts. It is the oracle for replacing
acdream's independent `WbDrawDispatcher` and `ParticleRenderer` transparent
passes.
The motivating failure is a translucent lifestone crystal drawn before a
later scene-particle pass. Both passes depth-test and disable depth writes, so
a smoke or flame particle behind the crystal passes the opaque depth buffer
and composites at full strength over the already-drawn crystal.
## Named-retail sources
- `ParticleEmitter::EmitParticle` at `0x0051D010`
- `CPhysicsObj::AddPartToShadowCells` call at `0x0051D126`
- `CPhysicsPart::UpdateViewerDistance` at `0x0050E030`
- `CPartCell::add_part` at `0x0052E740`
- `RenderDeviceD3D::DrawObjCellForDummies` at `0x005A0760`
- `CShadowPart::insertion_sort` at `0x006B5130`
- `CShadowPart::draw` at `0x006B50D0`
- `CPhysicsPart::Draw` at `0x0050D7A0`
- `D3DPolyRender::DrawMesh` at `0x0059D4A0`
- `D3DPolyRender::AddMeshToAlphaList` at `0x0059C230`
- `D3DPolyRender::FlushAlphaList` at `0x0059D2E0`
- `PView::DrawCells` at `0x005A4840`
- `SmartBox::RenderNormalMode` at `0x00453AA0`
- `D3DPolyRender::s_AlphaDelayMask = 0xE` at `0x00820D88`
The matching PDB header defines:
```text
CShadowPart {
uint num_planes;
ClipPlaneList** planes;
Frame* frame;
CPhysicsPart* part;
}
CPhysicsPart {
float CYpt;
Vector3 viewer_heading;
...
}
```
`CYpt` is the distance from the viewer to the transformed GfxObj
`sort_center`, not merely the object's origin.
## Retail pseudocode
### Particle materialization
```text
EmitParticle(emitter):
particlePart = initialize one particle as a CPhysicsPart
parentPhysicsObject.AddPartToShadowCells(particlePart)
```
A particle is therefore not a special post-process overlay. It participates
in the same cell-owned part list as an object's ordinary `CPhysicsPart`s.
### Viewer-distance key
```text
UpdateViewerDistance(part):
localCenter = part.gfxobj_scale * part.gfxobj.sort_center
viewerOffset = viewer.position offset to (part.position + localCenter)
part.CYpt = length(viewerOffset)
part.viewer_heading = normalize(viewerOffset), or +Z when almost zero
update degradation from CYpt
calculate final draw frame
```
### Cell part ordering
```text
DrawObjCellForDummies(cell):
update all object/part viewer distances in the cell
if cell.shadow_parts.count > 1:
CShadowPart.insertion_sort(cell.shadow_parts)
DrawObjCell(cell)
CShadowPart.insertion_sort(cell.parts):
stable insertion sort using part.CYpt
arrange that cell's parts in descending CYpt order
DrawPartCell(cell):
for shadowPart in cell.shadow_parts:
shadowPart.draw()
CShadowPart.draw(shadowPart):
shadowPart.part.Draw(force = false)
```
The sort covers ordinary object parts and particle parts together **within one
`CPartCell`**. Equal distance retains the established list order. The later
alpha-list append also preserves the order in which retail traverses cells;
it does not globally re-sort entries from different cells.
### Deferred translucent subsets
```text
CPhysicsPart.Draw(part):
install material, surface array, object scale, and draw frame
DrawMesh(part.gfxobj)
DrawMesh(mesh):
for each surface subset in authored order:
if subset matches AlphaDelayMask (retail default 0xE):
AddMeshToAlphaList(mesh, subset, surface,
captureCurrentWorldAndMaterialState)
else:
RenderMeshSubset immediately
AddMeshToAlphaList(...):
append one entry to the selected fixed alpha list
preserve append order
optionally snapshot world matrix and material state
FlushAlphaList():
render every queued clip-list entry in append order
clear clip list
render every queued alpha-list entry in append order
clear alpha list
```
`FlushAlphaList` does not invent a second distance sort. It preserves the
order established by the cell's `CShadowPart` list and the authored part /
surface traversal.
### Flush scopes
```text
PView.DrawCells():
if outside view exists:
LScape.draw() // queues delayed alpha
FlushAlphaList()
conditionally clear depth
draw indoor cell shells and object lists
SmartBox.RenderNormalMode():
DrawInside(viewerCell) // PView path above
FlushAlphaList() // final world-object alpha scope
```
The landscape flush must occur before retail's outside-to-inside depth clear.
The final flush drains delayed alpha produced after that boundary.
## Cross-reference findings
- acdream's extracted WorldBuilder mesh path correctly preserves DAT surface
translucency metadata and a GfxObj `SortCenter`, but WorldBuilder renders
its editor content in renderer-local passes. It has no live `CPartCell` /
`CShadowPart` ownership and therefore cannot supply retail's mixed
object-particle queue.
- ACViewer is useful for DAT geometry and particle asset interpretation but is
a viewer rather than the retail cell renderer. It does not supersede the
named-retail ordering above.
- ACE is authoritative for particle/gameplay events but is server-side and has
no client alpha compositor. It corroborates asset and object identity, not
draw ordering.
The named retail client remains the behavioral oracle.
## Port contract
1. Scene particles and translucent live/static GfxObj batches submit into one
stage-scoped queue.
2. Each submission carries the retail viewer-distance key derived from the
transformed GfxObj sort center (particle billboards use their live center).
3. In the modern renderer's stage-scoped reconstruction, sort far-to-near with
stable submission sequence as the equal-distance tiebreak. This recreates
mixed ordinary/particle part ordering, but it is scope-global rather than
retaining retail's missing per-`CPartCell` lists. AP-34 records that narrow
architectural residual explicitly.
4. The queue itself does not reorder by renderer or material. Only adjacent
compatible submissions may batch.
5. Every flush keeps depth testing enabled and depth writes disabled.
6. Each submission preserves its DAT blend mode, cull mode, transform,
texture, lighting, opacity, and selection-lighting state.
7. Flush before the outside-to-inside depth clear, then flush the final world
scope before world-space overlays and UI.
8. Sky-pre-scene and sky-post-scene particles remain in their dedicated sky
passes; they do not enter the world alpha queue.
9. A particle behind translucent crystal draws first and is then attenuated by
the crystal. A particle in front draws after the crystal and remains bright.
10. No lifestone-specific branch, transparent depth prepass, or particle
depth-test suppression is permitted.