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

12 KiB
Raw Blame History

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

// #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):

  1. 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;
    ...
    
  2. Per-instance storage. Add to InstanceGroup (~:2419), after LightSets:

    // #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
    
  3. Backing array + SSBO. Beside _clipSlotData / _clipSlotSsbo:

    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:

    _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):

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