From ef5049fd581333d6e4228bb6036d2219f1eaf52d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:55:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(lighting):=20#142=20=E2=80=94=20per-instanc?= =?UTF-8?q?e=20sun=20gate=20for=20windowed-building=20interiors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standing inside (or looking into) a windowed building like the Agent of Arcanum, interior objects (furniture, NPCs, the player) were lit by the directional sun because acdream's sun gate was per-FRAME (keyed on the player being in a sealed cell), not per-DRAW as retail does it. Retail's PView::DrawCells (0x005a4840) runs two stages per frame: outdoor stage → useSunlightSet(1) (0x005a485a): sun ON interior stage → useSunlightSet(0) (0x005a49f3): sun OFF DrawMeshInternal (0x0059f398) then calls minimize_object_lighting only when useSunlight==0, so indoor objects ALWAYS skip the sun regardless of whether the player's cell is windowed or sealed. Fix: add a per-instance uint SSBO (binding=6 instanceIndoor[]) whose value is IndoorObjectReceivesTorches(ParentCellId) — the same predicate AP-43 already uses for the torch gate. In mesh_modern.vert, nest the sun loop inside an additional `if (instanceIndoor[instanceIndex] == 0u)` check inside the existing `if (uLightingMode == 0)` block. Indoor objects get torches (unchanged) but now skip the sun; outdoor objects keep the sun and still get no torches. The ambient regime (UpdateSunFromSky: 0.2 sealed / sky otherwise) is untouched — it was already correct. Mechanically: _currentEntityIndoor set once per entity in ComputeEntityLightSet; appended to InstanceGroup.IndoorFlags in AppendCurrentLightSet; grown/packed/uploaded in the same cursor loop as _clipSlotData and _lightSetData; deleted in Dispose. Mode-1 draws (EnvCellRenderer) never read binding=6 — the sun loop is inside the uLightingMode==0 uniform-control-flow branch. AP-43 divergence register updated: the sun half is now per-draw (no longer a residual). Residual narrowed to the unaudited ebp_2 test in CellManager::ChangePosition (no observed impact). Tests: WbDrawDispatcherIndoorFlagTests pins IndoorObjectReceivesTorches for the spec §5 representative ids: 0xA9B40172 (Agent of Arcanum EnvCell) → 1; 0xA9B40031 (land sub-cell) → 0; 0xA9B4FFFF (landblock) → 0; null (outdoor shell) → 0; plus the boundary cases 0x0100/0x00FF. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 2 +- .../Rendering/Shaders/mesh_modern.vert | 29 +++++-- .../Rendering/Wb/WbDrawDispatcher.cs | 45 +++++++++- .../Wb/WbDrawDispatcherIndoorFlagTests.cs | 82 +++++++++++++++++++ 4 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherIndoorFlagTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 903a682b..8ea89e11 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -136,7 +136,7 @@ accepted-divergence entries (#96, #49, #50). | 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-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) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `: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 | 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 | -| AP-43 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 | +| 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 | | AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | | AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index 78011f66..f031a149 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -96,6 +96,15 @@ layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { int instanceLightIdx[]; // 8 per instance; -1 = unused }; +// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0 +// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the +// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun). +// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1 +// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it. +layout(std430, binding = 6) readonly buffer InstanceIndoorBuf { + uint instanceIndoor[]; +}; + // Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal // alongside gl_Position. The array is sized 8 to match the CellClip plane budget // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables @@ -190,14 +199,20 @@ vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { // SUN / directional — OBJECT path only (mode 0). retail's EnvCell path // (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so // EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4). + // #142: within mode 0, also skip the sun for indoor objects (ParentCellId is an + // EnvCell). This mirrors retail's per-draw-stage useSunlight toggle: the interior + // stage runs useSunlightSet(0) (PView::DrawCells 0x005a49f3), so indoor objects + // get no sun even in windowed buildings where the player's frame is not sun-killed. if (uLightingMode == 0) { - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - if (int(uLights[i].posAndKind.w) != 0) continue; // directional only - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + } } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 6b7ac0d6..d40f57e8 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -143,6 +143,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint _instLightSetSsbo; private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject]; private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight + + // #142: per-instance "indoor" flag (binding=6), one uint per instance, parallel + // to _instanceSsbo. 1 = object parented to an EnvCell (skip the sun in the + // shader's uLightingMode==0 branch); 0 = outdoor object (gets the sun). + // Mechanically a clone of _clipSlotData / _clipSlotSsbo. + private uint _instIndoorSsbo; + private uint[] _indoorData = new uint[256]; // This frame's point-light snapshot, handed in by GameWindow before Draw via // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). private IReadOnlyList? _pointSnapshot; @@ -151,6 +158,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // like _currentEntitySlot. -1 = unused slot. private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject]; + // #142: per-entity "indoor" flag — set once per entity in ComputeEntityLightSet, + // parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires + // (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in + // AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[]. + private bool _currentEntityIndoor; + // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the // GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0 // (not yet wired), we bind our OWN fallback no-clip region buffer below so the @@ -350,6 +363,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3 _globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4 _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 + _instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6 } /// @@ -923,7 +937,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable camPos = invView.Translation; // ── Phase 1: clear groups, walk entities, build groups ────────────── - foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); } + foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); grp.IndoorFlags.Clear(); } var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -1397,6 +1411,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject) _lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject]; + // #142: per-instance indoor flag buffer, one uint per instance, parallel to + // _clipSlotData / _instanceData. Grown on demand like the others. + if (_indoorData.Length < totalInstances) + _indoorData = new uint[totalInstances + 256]; + _opaqueDraws.Clear(); _translucentDraws.Clear(); @@ -1429,6 +1448,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable int lsSrc = i * LightManager.MaxLightsPerObject; for (int k = 0; k < LightManager.MaxLightsPerObject; k++) _lightSetData[lsDst + k] = grp.LightSets[lsSrc + k]; + // #142: IndoorFlags[] is parallel to Matrices[]; write at the same + // cursor so binding=6 instanceIndoor[] tracks binding=0 instances. + _indoorData[cursor] = grp.IndoorFlags[i]; cursor++; } @@ -1514,6 +1536,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable fixed (uint* sp = _clipSlotData) UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint)); + // #142: per-instance indoor flag buffer (binding=6), one uint per instance, + // laid out parallel to _instanceData in Phase 3. Only [0..totalInstances) + // is uploaded — stale tail never read (same guarantee as clip-slot above). + fixed (uint* dp = _indoorData) + UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint)); + // Fix B: global point-light buffer (binding=4) + per-instance light-set // buffer (binding=5). The global buffer is this frame's PointSnapshot; the // per-instance buffer holds 8 int indices into it per instance, laid out @@ -2049,12 +2077,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// private void ComputeEntityLightSet(WorldEntity entity) { + // #142: set the indoor flag first so it's available even when the early-return + // fires below. Both the torch selection and the sun gate use the same predicate, + // so they can't disagree — one call, one truth. + _currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId); + Array.Fill(_currentEntityLightSet, -1); var snap = _pointSnapshot; if (snap is null || snap.Count == 0) return; // Retail useSunlight gate: outdoor objects receive no per-object torches. - if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return; + if (!_currentEntityIndoor) return; // #142: reuse the cached flag (was: IndoorObjectReceivesTorches(...)) if (entity.AabbDirty) entity.RefreshAabb(); Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; @@ -2085,6 +2118,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { for (int k = 0; k < LightManager.MaxLightsPerObject; k++) grp.LightSets.Add(_currentEntityLightSet[k]); + grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block } private void ClassifyBatches( @@ -2224,6 +2258,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3 if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4 if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5 + if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6 if (_gpuQueriesInitialized) { for (int i = 0; i < GpuQueryRingDepth; i++) @@ -2417,5 +2452,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0 // instance. -1 = unused slot. public readonly List LightSets = new(); + + // #142: per-instance "indoor" flag, parallel to Matrices. IndoorFlags[i] is + // 1 when the instance's entity is parented to an EnvCell (skip the sun); 0 + // for outdoor objects (gets the sun). Written into _indoorData at the same + // cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0. + public readonly List IndoorFlags = new(); } } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherIndoorFlagTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherIndoorFlagTests.cs new file mode 100644 index 00000000..c9ef2b80 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherIndoorFlagTests.cs @@ -0,0 +1,82 @@ +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +/// +/// #142 — pins the per-instance "indoor" flag predicate used by both the torch +/// gate (AP-43) and the new sun gate (binding=6 instanceIndoor[]). +/// The SSBO flag written by +/// equals IndoorObjectReceivesTorches(entity.ParentCellId) ? 1u : 0u. +/// This test pins that the shared predicate returns the correct value for the +/// representative cell ids used in the spec acceptance gate (§5). +/// +/// +/// Retail decomp anchor: useSunlightSet 0x0054d450 (sun only, never +/// ambient); PView::DrawCells 0x005a4840 — outdoor stage runs +/// useSunlightSet(1) (0x005a485a), interior stage runs +/// useSunlightSet(0) (0x005a49f3). Indoor objects skip the sun, not +/// because the player is inside, but because the OBJECT is in an interior cell. +/// +/// +public sealed class WbDrawDispatcherIndoorFlagTests +{ + // ── Spec §5 representative ids ───────────────────────────────────────── + + [Fact] + public void EnvCell_AgenOfArcanum_IndoorFlag1() + { + // 0xA9B40172: low word 0x0172 >= 0x0100 → indoor (EnvCell) + // This is the exact cell from the in-game probe (agent-arcanum-probe.log + // 2026-06-20) that triggered #142 — the Agent of Arcanum interior. + uint envCell = 0xA9B40172u; + Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(envCell), + "EnvCell 0xA9B40172 must be indoor (flag=1): objects here skip the sun."); + } + + [Fact] + public void LandSubCell_OutdoorFlag0() + { + // 0xA9B40031: low word 0x0031 < 0x0100 → outdoor land sub-cell + uint landSubCell = 0xA9B40031u; + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(landSubCell), + "Land sub-cell 0xA9B40031 must be outdoor (flag=0): objects here get the sun."); + } + + [Fact] + public void LandblockId_OutdoorFlag0() + { + // 0xA9B4FFFF: low word 0xFFFF — a landblock pseudo-id, not an indoor cell + uint landblockId = 0xA9B4FFFFu; + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(landblockId), + "Landblock id 0xA9B4FFFF must be outdoor (flag=0): the 0xFFFF low-word is excluded."); + } + + [Fact] + public void NullParentCellId_OutdoorFlag0() + { + // Outdoor scenery / building exterior shells have no ParentCellId → outdoor + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null), + "Null ParentCellId must be outdoor (flag=0): building shells get the sun, not torches."); + } + + // ── Boundary: first valid EnvCell low word ────────────────────────────── + + [Fact] + public void CellId_LowWord0x0100_IsIndoor() + { + // Low word exactly 0x0100 is the first indoor EnvCell id — just on the boundary + uint firstEnvCell = 0xA9B40100u; + Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(firstEnvCell), + "Low word 0x0100 is the lowest valid EnvCell id (inclusive boundary → indoor)."); + } + + [Fact] + public void CellId_LowWord0x00FF_IsOutdoor() + { + // Low word 0x00FF is one below the EnvCell range — still outdoor + uint lastLandSubCell = 0xA9B400FFu; + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(lastLandSubCell), + "Low word 0x00FF is just below the EnvCell range (exclusive boundary → outdoor)."); + } +}