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>
12 KiB
#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:
- outdoor stage:
useSunlightSet(1)(0x005a485a) → sun ON - 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):
// #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:
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):
-
Per-entity decision. Add a field beside
_currentEntityLightSet(~:152):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:_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; ... -
Per-instance storage. Add to
InstanceGroup(~:2419), afterLightSets:// #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):
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: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 -
Backing array + SSBO. Beside
_clipSlotData/_clipSlotSsbo:private uint[] _indoorData = new uint[256]; // grown to totalInstances like _clipSlotData private uint _instIndoorSsbo; // binding=6Generate it where
_clipSlotSsbo/_instLightSetSsboare generated (:352); delete it where they are deleted (:2226). -
Pack + upload. In the cursor loop (~:1419), beside the clip-slot write:
_clipSlotData[cursor] = grp.Slots[i]; _indoorData[cursor] = grp.IndoorFlags[i]; // #142, parallel to binding=0Add the grow-check beside the others (
:1390) and upload+bind beside the clip-slot upload (:1515), using the existingUploadSsbohelper (it binds viaBindBufferBase):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):
- Predicate-to-flag pin. Assert the per-instance indoor flag equals
WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)for representative ids: EnvCell0xA9B40172→ 1; land sub-cell0xA9B40031→ 0; landblock0xA9B4FFFF→ 0;null→ 0. (The static predicate is the single source of truth for both torches and the sun gate.) - If a lightweight path exists to drive
WbDrawDispatcherinstance emission in a test (mirror any existing light-set/clip-slot dispatcher test), assert the uploaded_indoorData[cursor]matches each entity'sParentCellIddecision. 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).