acdream/docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md
Erik cd024377a0 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>
2026-06-20 12:49:35 +02:00

245 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# #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).