fix(lighting): #142 — per-instance sun gate for windowed-building interiors

Standing inside (or looking into) a windowed building like the Agent of
Arcanum, interior objects (furniture, NPCs, the player) were lit by the
directional sun because acdream's sun gate was per-FRAME (keyed on the
player being in a sealed cell), not per-DRAW as retail does it.

Retail's PView::DrawCells (0x005a4840) runs two stages per frame:
  outdoor stage   → useSunlightSet(1) (0x005a485a): sun ON
  interior stage  → useSunlightSet(0) (0x005a49f3): sun OFF
DrawMeshInternal (0x0059f398) then calls minimize_object_lighting only
when useSunlight==0, so indoor objects ALWAYS skip the sun regardless of
whether the player's cell is windowed or sealed.

Fix: add a per-instance uint SSBO (binding=6 instanceIndoor[]) whose value
is IndoorObjectReceivesTorches(ParentCellId) — the same predicate AP-43
already uses for the torch gate. In mesh_modern.vert, nest the sun loop
inside an additional `if (instanceIndoor[instanceIndex] == 0u)` check
inside the existing `if (uLightingMode == 0)` block. Indoor objects get
torches (unchanged) but now skip the sun; outdoor objects keep the sun and
still get no torches. The ambient regime (UpdateSunFromSky: 0.2 sealed /
sky otherwise) is untouched — it was already correct.

Mechanically: _currentEntityIndoor set once per entity in
ComputeEntityLightSet; appended to InstanceGroup.IndoorFlags in
AppendCurrentLightSet; grown/packed/uploaded in the same cursor loop as
_clipSlotData and _lightSetData; deleted in Dispose. Mode-1 draws
(EnvCellRenderer) never read binding=6 — the sun loop is inside the
uLightingMode==0 uniform-control-flow branch.

AP-43 divergence register updated: the sun half is now per-draw (no
longer a residual). Residual narrowed to the unaudited ebp_2 test in
CellManager::ChangePosition (no observed impact).

Tests: WbDrawDispatcherIndoorFlagTests pins IndoorObjectReceivesTorches
for the spec §5 representative ids: 0xA9B40172 (Agent of Arcanum EnvCell)
→ 1; 0xA9B40031 (land sub-cell) → 0; 0xA9B4FFFF (landblock) → 0; null
(outdoor shell) → 0; plus the boundary cases 0x0100/0x00FF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 12:55:56 +02:00
parent cd024377a0
commit ef5049fd58
4 changed files with 148 additions and 10 deletions

View file

@ -0,0 +1,82 @@
using AcDream.App.Rendering.Wb;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
/// <summary>
/// #142 — pins the per-instance "indoor" flag predicate used by both the torch
/// gate (AP-43) and the new sun gate (binding=6 <c>instanceIndoor[]</c>).
/// The SSBO flag written by <see cref="WbDrawDispatcher.AppendCurrentLightSet"/>
/// equals <c>IndoorObjectReceivesTorches(entity.ParentCellId) ? 1u : 0u</c>.
/// This test pins that the shared predicate returns the correct value for the
/// representative cell ids used in the spec acceptance gate (§5).
///
/// <para>
/// Retail decomp anchor: <c>useSunlightSet</c> 0x0054d450 (sun only, never
/// ambient); <c>PView::DrawCells</c> 0x005a4840 — outdoor stage runs
/// <c>useSunlightSet(1)</c> (0x005a485a), interior stage runs
/// <c>useSunlightSet(0)</c> (0x005a49f3). Indoor objects skip the sun, not
/// because the player is inside, but because the OBJECT is in an interior cell.
/// </para>
/// </summary>
public sealed class WbDrawDispatcherIndoorFlagTests
{
// ── Spec §5 representative ids ─────────────────────────────────────────
[Fact]
public void EnvCell_AgenOfArcanum_IndoorFlag1()
{
// 0xA9B40172: low word 0x0172 >= 0x0100 → indoor (EnvCell)
// This is the exact cell from the in-game probe (agent-arcanum-probe.log
// 2026-06-20) that triggered #142 — the Agent of Arcanum interior.
uint envCell = 0xA9B40172u;
Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(envCell),
"EnvCell 0xA9B40172 must be indoor (flag=1): objects here skip the sun.");
}
[Fact]
public void LandSubCell_OutdoorFlag0()
{
// 0xA9B40031: low word 0x0031 < 0x0100 → outdoor land sub-cell
uint landSubCell = 0xA9B40031u;
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(landSubCell),
"Land sub-cell 0xA9B40031 must be outdoor (flag=0): objects here get the sun.");
}
[Fact]
public void LandblockId_OutdoorFlag0()
{
// 0xA9B4FFFF: low word 0xFFFF — a landblock pseudo-id, not an indoor cell
uint landblockId = 0xA9B4FFFFu;
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(landblockId),
"Landblock id 0xA9B4FFFF must be outdoor (flag=0): the 0xFFFF low-word is excluded.");
}
[Fact]
public void NullParentCellId_OutdoorFlag0()
{
// Outdoor scenery / building exterior shells have no ParentCellId → outdoor
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null),
"Null ParentCellId must be outdoor (flag=0): building shells get the sun, not torches.");
}
// ── Boundary: first valid EnvCell low word ──────────────────────────────
[Fact]
public void CellId_LowWord0x0100_IsIndoor()
{
// Low word exactly 0x0100 is the first indoor EnvCell id — just on the boundary
uint firstEnvCell = 0xA9B40100u;
Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(firstEnvCell),
"Low word 0x0100 is the lowest valid EnvCell id (inclusive boundary → indoor).");
}
[Fact]
public void CellId_LowWord0x00FF_IsOutdoor()
{
// Low word 0x00FF is one below the EnvCell range — still outdoor
uint lastLandSubCell = 0xA9B400FFu;
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(lastLandSubCell),
"Low word 0x00FF is just below the EnvCell range (exclusive boundary → outdoor).");
}
}