From 87afbc0a42203e6652eaa2a354bf3fb379e8e3c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 19:26:04 +0200 Subject: [PATCH] fix #132 (outdoor sibling): outdoor attached scene emitters move to the post-frame pass; sharpen the #131 probe User gate on 20d1730: the candle is FIXED indoors ("now the candle light is visible when I'm in the house when it is in front of the opening") and the OUTDOOR sibling surfaced exactly as AP-34 recorded ("when I go out it is not showing unless I turn so the angle doesn't put it in front of the opening"): under an OUTDOOR root the merged building interiors draw AFTER the landscape stage (DrawEnvCellShells), so a slice-drawn flame is overpainted by a punched aperture's interior behind it. Fix: outdoor roots SKIP the late-slice Scene-particle draw; attached outdoor-static scene emitters draw in the POST-FRAME pass alongside the T3 unattached pass, where depth is complete and flames composite correctly against interiors. The owner-id set carries over from the late slice (single full-screen slice outdoors); cell-pass and dynamics-pass emitters keep their own passes (their owners are never in the outdoor-static id set - no double-draw). Interior roots keep the late-slice draw (their stage ends with the clear + seal discipline). AP-34 row updated (the outdoor residual is now covered; the remaining residual is translucent MESH batches within stage draw calls). Portal swirl (#131): the user's "same results" on 20d1730 KILLS the look-in-erasure hypothesis for the portal - the mesh now draws after the look-ins and is still missing indoors. No further speculative fix; the [outstage] probe now prints each outside-stage dynamic's SourceGfxObjOrSetupId (portals have distinctive setups) and [outstage-pt] lists up to 12 distinct UNMATCHED attached emitter owner ids - the next capture identifies whether the portal entity reaches the through-door draw at all, and where its emitters point. Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 19 ++++++- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 54 ++++++++++++++----- .../Rendering/RetailPViewRenderer.cs | 2 +- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0894eb2c..36b10937 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4670,8 +4670,23 @@ Background-dependence explained exactly. **Fix:** the landscape stage's two-phase split (see #131 FIX 2): all scene particles moved to the LATE phase, after the look-ins. -**Gate:** the candle at the original spot — flame stays visible when -the through-opening is behind it. +**Gate 1 result (user):** indoors FIXED ("now the candle light is +visible when I'm in the house when it is in front of the opening") — +but the OUTDOOR sibling surfaced ("when I go out it is not showing +unless I turn so the angle doesn't put it in front of the opening"): +under an OUTDOOR root the merged building interiors draw AFTER the +landscape stage, so a slice-drawn flame is overpainted by the punched +aperture's interior — the residual AP-34 had already recorded. + +**Fix 2 (outdoor):** outdoor roots skip the slice Scene pass; attached +outdoor-static scene emitters draw in the POST-FRAME pass alongside the +T3 unattached pass (depth complete there — flames composite correctly +against interiors). The owner-id filter carries over; cell-pass and +dynamics-pass emitters keep their own passes (owners never in the +outdoor-static set → no double-draw). + +**Gate:** both sides — indoors with the opening behind the candle, and +outdoors at the angle that previously erased it. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0178bfd7..5c26f137 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -129,7 +129,7 @@ accepted-divergence entries (#96, #49, #50). | 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 statics (**#124** sub-pass) draw WHOLE — no per-part viewcone check; retail viewconeCheck's each part vs the installed view. Look-in DYNAMICS are not drawn at all (deferred; retail draws objects per overlapped cell in the landscape stage) | `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) | Statics: a few wasted draws only. Dynamics: an NPC inside a far building seen through two openings is invisible where retail shows it | `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), not retail's single deferred alpha flush. Residual: under OUTDOOR roots slice particles still draw before merged building interiors (the unreported outdoor sibling of **#131**/**#132**); building exteriors' own translucent batches draw early | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) | 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 interior-root cases the user can see (#131 portal swirl, #132 candle flame) | 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 | 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) | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5a0f7c74..a60ed6a8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5096,6 +5096,7 @@ public sealed class GameWindow : IDisposable // #131 [outstage-pt] probe state (throwaway — strip when #131 closes). private string? _lastOutStagePtSig; + private readonly HashSet _outStageUnmatchedScratch = new(); private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, @@ -7841,7 +7842,8 @@ public sealed class GameWindow : IDisposable renderSky, renderWeather: playerSeenOutside, kf, - environOverrideActive), + environOverrideActive, + isOutdoorRoot: clipRoot.IsOutdoorNode), // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). // INTERIOR roots: one FULL depth clear between the outside stage and // the interior stage, then SEALS re-stamp every outside-leading @@ -8002,20 +8004,26 @@ public sealed class GameWindow : IDisposable && _particleSystem is not null && _particleRenderer is not null) { // T3 (BR-5): unattached emitters (campfires, ground effects — - // AttachedObjectId == 0) under the OUTDOOR root. The unified - // path's attached emitters draw via the landscape slice + the - // per-cell callbacks; unattached ones had NO pass on - // outdoor-node frames (the unattached-particles-dropped- - // outdoors divergence, adjusted-confirmed). The outdoor root's - // outside view is full-screen (cone pass-all); depth test - // composites them against the world. + // AttachedObjectId == 0) under the OUTDOOR root. The outdoor + // root's outside view is full-screen (cone pass-all); depth + // test composites them against the world. + // #132 outdoor sibling: ATTACHED outdoor-static scene emitters + // (lantern/candle flames) moved here too — drawn in the + // landscape slice they were overpainted by merged building + // interiors (drawn later) whenever a punched aperture sat + // behind them. Post-frame, depth is complete and the flames + // composite correctly. The owner-id set is the late slice's + // (full-screen cone outdoors). Cell-pass and dynamics-pass + // emitters keep their own passes (no double-draw: their owners + // are never in the outdoor-static id set). sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; _particleRenderer.Draw( _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId == 0); + emitter => emitter.AttachedObjectId == 0 + || _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky @@ -9697,7 +9705,8 @@ public sealed class GameWindow : IDisposable bool renderSky, bool renderWeather, AcDream.Core.World.SkyKeyframe kf, - bool environOverrideActive) + bool environOverrideActive, + bool isOutdoorRoot) { var slice = lateCtx.Slice; bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); @@ -9724,19 +9733,28 @@ public sealed class GameWindow : IDisposable _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); // #131 [outstage-pt] probe: the slice Scene-particle id set + how many - // live emitters the filter would actually match. Print-on-change. + // live emitters the filter would actually match, plus the distinct + // UNMATCHED attached owner ids (the portal-identification handle — + // an emitter whose owner never lands in the set draws nowhere + // indoors). Print-on-change. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled && _particleSystem is not null) { int matched = 0, attached = 0, unattached = 0; + _outStageUnmatchedScratch.Clear(); foreach (var (emitter, _) in _particleSystem.EnumerateLive()) { if (emitter.AttachedObjectId == 0) { unattached++; continue; } attached++; if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) matched++; + else if (_outStageUnmatchedScratch.Count < 12) + _outStageUnmatchedScratch.Add(emitter.AttachedObjectId); } + var unm = new System.Text.StringBuilder(96); + foreach (uint id in _outStageUnmatchedScratch) + unm.Append(System.FormattableString.Invariant($" 0x{id:X8}")); string ptSig = System.FormattableString.Invariant( - $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached}"); + $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} unmatchedIds=[{unm}]"); if (ptSig != _lastOutStagePtSig) { _lastOutStagePtSig = ptSig; @@ -9744,7 +9762,17 @@ public sealed class GameWindow : IDisposable } } - if (_outdoorSceneParticleEntityIds.Count > 0 + // #132 outdoor sibling: under an OUTDOOR root the merged building + // interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn + // here is overpainted whenever a punched aperture sits behind it + // (user-confirmed at the outdoor candle). Outdoor roots therefore + // SKIP the slice Scene pass and draw attached scene particles in the + // post-frame pass alongside the T3 unattached pass (the id set built + // above carries over — the outdoor root has a single full-screen + // slice). Interior roots draw here: the look-ins already ran and the + // post-clear seal discipline owns the rest of the frame. + if (!isOutdoorRoot + && _outdoorSceneParticleEntityIds.Count > 0 && _particleSystem is not null && _particleRenderer is not null) { diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 2b39b1e6..f23d419c 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -488,7 +488,7 @@ public sealed class RetailPViewRenderer bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r); if (i > 0) sb.Append(' '); sb.Append(System.FormattableString.Invariant( - $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}:{(pass ? "PASS" : "CULL")}:r={r:F1}")); + $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}")); } sb.Append(']'); string sig = sb.ToString();