# #142 — windowed-building interiors read "like outdoors": per-instance sun gate **Date:** 2026-06-20 **Status:** user pre-approved (2026-06-20), ready to implement **Milestone:** M1.5 "Indoor world feels right" **Predecessor brainstorm:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md` **Companion memory:** `claude-memory/reference_retail_ambient_values.md` --- ## 1. Problem Standing inside (or looking into) a **windowed** building — e.g. the Holtburg Agent of Arcanum — the interior reads "like outdoors": flat, sun-shaded, not the warm torch-lit room retail shows. Dungeons (sealed cells) already look right. ## 2. Root cause (decomp-verified + in-game-probe-confirmed) acdream's lighting **REGIME** has two halves. One is already faithful; one is not. **Ambient — already faithful, DO NOT CHANGE.** Retail sets a single global ambient once per frame in `CellManager::ChangePosition` (0x004559B0), keyed on the player's current cell: - `curr_cell->seen_outside != 0` (windowed or outdoor) → ambient = **sky ambient** (`SetWorldAmbientLight(smartbox_2, calc_object_light(), LScape::ambient_color)`) - `seen_outside == 0` (sealed) → ambient = **flat (0.2,0.2,0.2)** (`SetWorldAmbientLight(smartbox_1, 0.2f, 0xffffffff)`) acdream's `UpdateSunFromSky(playerInsideCell)` already does exactly this (flat 0.2 for sealed, sky ambient otherwise). Adding a *second* indoor ambient would diverge **away** from retail. The in-game probe (2026-06-20) confirmed the interior ambient is the sky value `(0.43,0.43,0.51)` — correct. **Sun — the bug. Retail gates it per-DRAW-STAGE; acdream gates it per-FRAME.** `PView::DrawCells` (0x005a4840) draws each frame in two stages: 1. outdoor stage: `useSunlightSet(1)` (0x005a485a) → sun ON 2. interior-cell stage: `useSunlightSet(0)` (0x005a49f3) → sun OFF `useSunlightSet` (0x0054d450) **only toggles the sun** (`Render::useSunlight` + the sun's active-light slot) — it never touches ambient. Per-object torch lighting is gated on the same flag: `minimize_object_lighting` (0x0054d480) runs only when `Render::useSunlight == 0` (`DrawMeshInternal` 0x0059f398). So **every interior draw gets no sun + torches**, even while the player stands in a windowed building with sky visible. acdream gates the sun **per frame**: `UpdateSunFromSky` zeroes the sun only when the player is in a **sealed** cell. A windowed building is `seen_outside = true`, so acdream leaves the sun on for the whole frame and the interior objects get sunlit. ### In-game probe evidence (`agent-arcanum-probe.log`, 2026-06-20) The Agent of Arcanum is a **windowed EnvCell building**: ``` [cell-transit] 0xA9B40031 -> 0xA9B40170 -> 0xA9B40171 -> 0xA9B40172 (land cell → EnvCells) [light] insideCell=False ambient=(0.426,0.426,0.507) sun=1 ... playerCell=0xA9B40172 ``` `playerCell` low-16-bits `0x0172 >= 0x0100` ⟹ EnvCell. `insideCell=False` inside it ⟹ `SeenOutside=true` (windowed). **`sun=1` while indoors** ⟹ the bug. Warm torches ARE present + selected (`[light-detail]` intensity-100 cream/orange points). So the only wrong term is the directional sun washing over the interior objects. The mode-1 EnvCell **walls** are already correct: the shader's sun loop is gated on `uLightingMode == 0`, so walls never get the sun, and `EnvCellRenderer` already binds their torch set (the old "D-2" gap is fixed). **Only mode-0 objects (furniture, NPCs, the player) inside an EnvCell get the erroneous sun.** ## 3. Design — per-instance sun gate Mirror AP-43's per-object torch decision. Add a 1-bit-per-instance "indoor" flag, derived from the **same** `IndoorObjectReceivesTorches(ParentCellId)` predicate AP-43 already computes, and gate the sun off for indoor mode-0 objects. This makes the sun per-draw, exactly matching retail's per-stage `useSunlight`. **Rejected alternatives (record why, do not revisit):** - *Dual indoor/outdoor ambient* (the handoff's Fork-1): retail uses one global ambient keyed on the player cell, which acdream already replicates. Diverges from retail. The probe confirmed the ambient is already correct. - *Split indoor/outdoor into separate draw passes*: DO-NOT-RETRY — fights the batched bindless-MDI (same reasoning that put AP-43 per-object). ### 3.1 Shader — `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` Add the binding=6 SSBO declaration alongside the existing per-instance buffers (binding=3 clip slot, binding=5 light set): ```glsl // #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[]; }; ``` In `accumulateLights`, nest the indoor check **inside** the existing `if (uLightingMode == 0)` sun block: ```glsl if (uLightingMode == 0) { 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; } } } ``` `uLightingMode` is a uniform, so the `instanceIndoor[]` read is uniform-control-flow gated — a mode-1 draw never executes it. The torch/point-light accumulator below is untouched (indoor objects keep their torches). ### 3.2 Dispatcher — `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` The per-instance flag is a `uint` per instance — mechanically identical to the **binding=3 clip-slot** buffer (`_clipSlotData` / `_clipSlotSsbo`). Mirror it. The per-entity *value* is the **same decision** as the torch set, computed in `ComputeEntityLightSet`. Sites (line numbers @ HEAD, will drift — match by symbol): 1. **Per-entity decision.** Add a field beside `_currentEntityLightSet` (~:152): ```csharp private bool _currentEntityIndoor; ``` In `ComputeEntityLightSet` (~:2050), set it at the top from the existing predicate, and reuse it for the early-return so the two can't disagree: ```csharp _currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId); Array.Fill(_currentEntityLightSet, -1); var snap = _pointSnapshot; if (snap is null || snap.Count == 0) return; if (!_currentEntityIndoor) return; // was: if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return; ... ``` 2. **Per-instance storage.** Add to `InstanceGroup` (~:2419), after `LightSets`: ```csharp // #142: per-instance "indoor" flag, parallel to Matrices. 1 = EnvCell-parented // (skip the sun); 0 = outdoor. Written into _indoorData at the same cursor as // Matrices, so binding=6 instanceIndoor[] tracks the binding=0 instance. public readonly List IndoorFlags = new(); ``` Clear it where the other per-group lists are cleared (~:926): ```csharp grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); grp.IndoorFlags.Clear(); ``` Append it per instance in `AppendCurrentLightSet` (~:2084) — it already runs once per instance from both emit sites (~:2019, ~:2145), so folding it in covers both: ```csharp 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 ``` 3. **Backing array + SSBO.** Beside `_clipSlotData` / `_clipSlotSsbo`: ```csharp private uint[] _indoorData = new uint[256]; // grown to totalInstances like _clipSlotData private uint _instIndoorSsbo; // binding=6 ``` Generate it where `_clipSlotSsbo` / `_instLightSetSsbo` are generated (~:352); delete it where they are deleted (~:2226). 4. **Pack + upload.** In the cursor loop (~:1419), beside the clip-slot write: ```csharp _clipSlotData[cursor] = grp.Slots[i]; _indoorData[cursor] = grp.IndoorFlags[i]; // #142, parallel to binding=0 ``` Add the grow-check beside the others (~:1390) and upload+bind beside the clip-slot upload (~:1515), using the existing `UploadSsbo` helper (it binds via `BindBufferBase`): ```csharp fixed (uint* dp = _indoorData) UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint)); ``` ### 3.3 `UpdateSunFromSky` — UNCHANGED Its per-frame ambient regime (flat 0.2 sealed / sky otherwise) IS retail's `ChangePosition` and the probe confirmed it correct. Its per-frame sun-zero for sealed frames stays: it correctly models "a sealed dungeon runs only the interior stage" and harmlessly guards transient null-cell frames; the new per-instance flag adds the windowed/look-in coverage it was missing. The two together reproduce retail's per-stage `useSunlight` for every case. **Do not touch the ambient values or the sun colors here.** ### 3.4 `EnvCellRenderer` — UNCHANGED Mode-1 draws set `uLightingMode = 1`, so the `instanceIndoor[]` read (inside the uniform `uLightingMode == 0` branch) is never executed for EnvCell shells. The walls already skip the sun and already get their torch set. No binding=6 bind needed. ## 4. Divergence register Update **AP-43** in `docs/architecture/retail-divergence-register.md` in the same commit: the regime is now per-draw for **both** torches (AP-43 original) and the sun (this change), matching retail's per-stage `useSunlight`. Narrow the row to whatever residual remains (e.g. the `ebp_2` term in `ChangePosition`'s seen_outside test, unaudited), or close it if nothing remains. Do not leave it claiming the sun half is unfixed. ## 5. Tests `tests/AcDream.App.Tests/` (or the nearest existing dispatcher test project): 1. **Predicate-to-flag pin.** Assert the per-instance indoor flag equals `WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)` for representative ids: EnvCell `0xA9B40172` → 1; land sub-cell `0xA9B40031` → 0; landblock `0xA9B4FFFF` → 0; `null` → 0. (The static predicate is the single source of truth for both torches and the sun gate.) 2. If a lightweight path exists to drive `WbDrawDispatcher` instance emission in a test (mirror any existing light-set/clip-slot dispatcher test), assert the uploaded `_indoorData[cursor]` matches each entity's `ParentCellId` decision. Optional if no such harness exists — do not build GL plumbing for it. `dotnet build` + `dotnet test` green (App / Core / UI / Net suites) before done. ## 6. Faithfulness guardrails (DO NOT DRIFT) - Do **not** add a second ambient or change `UpdateSunFromSky`'s ambient/sun values. - Do **not** touch the torch/point-light path or shorten `Falloff × 1.3`. - Keep the predicate exactly `IndoorObjectReceivesTorches` — same one AP-43 uses. - Do **not** split the MDI into indoor/outdoor passes. - The flag is per-INSTANCE (parallel to Matrices), never per-group. ## 7. Acceptance gate (visual) Launch with `testaccount2` / `testpassword2`, go to the **Agent of Arcanum**: looking in from outside AND standing inside, the interior should read warm/torch-lit (no sun wash) instead of "like outdoors"; outdoor world through the windows still sunlit; dungeons + the sealed meeting hall unchanged. ## 8. Decomp anchors `CellManager::ChangePosition` 0x004559B0 (per-frame ambient + sun keyed on player `curr_cell->seen_outside`; the 0.2/sky split @ pc:94682-94712) · `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3) · `useSunlightSet` 0x0054d450 (sun only, never ambient) · `DrawMeshInternal` 0x0059f398 (`useSunlight==0` gate on per-object lighting) · `minimize_object_lighting` 0x0054d480 · `IndoorObjectReceivesTorches` (`WbDrawDispatcher.cs`, AP-43).