docs(lighting): #142 spec — per-instance sun gate for windowed interiors

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 12:49:35 +02:00
parent f7f3e0887b
commit cd024377a0

View file

@ -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<uint> 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).