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>
245 lines
12 KiB
Markdown
245 lines
12 KiB
Markdown
# #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).
|