From cd024377a05f388e70143a871778db92dfc0cffb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 12:49:35 +0200 Subject: [PATCH] =?UTF-8?q?docs(lighting):=20#142=20spec=20=E2=80=94=20per?= =?UTF-8?q?-instance=20sun=20gate=20for=20windowed=20interiors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decomp + in-game probe (agent-arcanum-probe.log) show the ambient regime is already retail-faithful (CellManager::ChangePosition 0x004559B0); only the sun is wrong — acdream gates it per-FRAME, retail per-STAGE (useSunlight 0x0054d450). Fix = a per-instance indoor flag (reusing IndoorObjectReceivesTorches, the AP-43 predicate) gating the sun off for indoor mode-0 objects. No second ambient, UpdateSunFromSky + EnvCellRenderer unchanged. User pre-approved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-indoor-sun-gate-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md diff --git a/docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md b/docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md new file mode 100644 index 00000000..eccf2528 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md @@ -0,0 +1,245 @@ +# #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).