fix(lighting): indoor interiors now lit — landblock-key bug + viewer light + weenie fixtures

The whole "indoor interiors read dark/flat vs retail" saga was ONE root-cause
bug: EnvCellRenderer.GetCellLightSet derived the landblock key as
`cellId & 0xFFFF0000` (0xXXYY0000), but landblocks are keyed by the streaming
id 0xXXYYFFFF. The lookup missed for EVERY cell, so SelectForObject never ran
and every EnvCell wall received ZERO point lights — torches, lanterns, the
viewer light, all of it. Confirmed by a [cell-light] probe: inBounds=False
selected=0 across 1M+ cell draws; after the fix inBounds=True selected=3-4.
User-confirmed the interiors now look like retail.

Three faithful additions that were blocked by the key bug (and only show now):
- Viewer light (LightManager.UpdateViewerLight): retail's SmartBox::set_viewer
  (0x00452c40) adds a white fill light at the player every frame via
  add_dynamic_light — the dominant interior fill (no sun indoors). acdream had
  NO dynamic lights at all. Params from the cdb capture: intensity 2.25,
  falloff 10, white, offset (0,0,2). Indoor-only via the AP-43 gate.
- Weenie fixture lights (OnLiveEntitySpawnedLocked): server-spawned lanterns/
  braziers carry Setup.Lights but the dat-static registration never saw
  CreateObject entities. Register on spawn; unregister on despawn
  (UnregisterOwner made unconditional). Register row AP-44.
- IndoorObjectReceivesTorches now excludes the 0xFFFF landblock marker (it is
  not an EnvCell) — fixes WbDrawDispatcherIndoorFlagTests.LandblockId_OutdoorFlag0
  (a #142 verification miss).

Divergence register: AP-44 (weenie light spawn-position, no movement tracking),
AP-47 (acdream's 128-light/camera-independent cap keeps interiors always-lit vs
retail's 40-nearest-to-player budget that pops in on approach — intentional,
user-preferred).

Investigation: docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md

Core 1505 / App 476 green. Visual gate: user-confirmed "looks like retail now."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 15:51:55 +02:00
parent ef5049fd58
commit 0d8b827721
6 changed files with 185 additions and 7 deletions

View file

@ -43,6 +43,7 @@ public sealed class LightManager
private readonly List<LightSource> _all = new();
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
private int _activeCount;
private LightSource? _viewerLight; // retail SmartBox::viewer_light (see UpdateViewerLight)
/// <summary>Current cell ambient state applied to everything.</summary>
public CellAmbientState CurrentAmbient { get; set; }
@ -86,6 +87,7 @@ public sealed class LightManager
_all.Clear();
Array.Clear(_active);
_activeCount = 0;
_viewerLight = null; // re-created + re-registered by the next UpdateViewerLight
}
/// <summary>
@ -212,6 +214,58 @@ public sealed class LightManager
}
}
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
// Retail adds a white fill light pinned to the player EVERY frame via
// Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor
// stage runs useSunlightSet(1) (sun only — dynamics are NOT enabled), but the
// interior stage runs minimize_envcell_lighting, which enables the dynamic
// lights, so EnvCell walls + indoor objects are lit by this viewer light while
// the player is inside. acdream registered NO dynamic lights at all, so
// interiors had only flat ambient and read dark/cool vs retail's lit rooms.
//
// It rides the existing point-light path: registered in _all ⇒ included in
// BuildPointLightSnapshot ⇒ selected by SelectForObject for nearby cells /
// objects. The AP-43 indoor gate (WbDrawDispatcher.IndoorObjectReceivesTorches)
// already restricts per-object point lights to EnvCell-parented objects, and
// only EnvCellRenderer selects per-cell lights, so the viewer light lights
// ONLY indoor draws — matching retail's interior-stage-only dynamic enable.
// Terrain has no point-light path, so it is unaffected.
//
// Params from the live cdb capture (reference_retail_ambient_values.md):
// intensity 2.25, falloff 10, colour white, offset (0,0,2) above the player
// (SmartBox::set_viewer player branch). Dynamic lights use rangeAdjust 1.5
// (config_hardware_light 0x0059ad30) ⇒ Range = 10 × 1.5 = 15 m.
public const float ViewerLightIntensity = 2.25f; // GRV SmartBox.ViewerLightIntensity
public const float ViewerLightFalloff = 10f; // GRV SmartBox.ViewerLightFalloff
private const uint ViewerLightOwnerId = 0xFFFFFFFFu; // sentinel; never an entity id
/// <summary>
/// Reposition the always-on viewer fill light at the player (offset +2 m up),
/// registering it on first call. Call once per frame BEFORE
/// <see cref="BuildPointLightSnapshot"/> / <see cref="Tick"/>. Mirrors retail's
/// per-frame <c>SmartBox::set_viewer</c> add_dynamic_light; here the light lives
/// in <c>_all</c> and is repositioned so the existing snapshot + per-object
/// selection light the cell around the player. Indoor-only via the AP-43 gate
/// (see the note above).
/// </summary>
public void UpdateViewerLight(Vector3 playerWorldPos)
{
if (_viewerLight is null)
{
_viewerLight = new LightSource
{
Kind = LightKind.Point,
ColorLinear = Vector3.One, // white (1,1,1)
Intensity = ViewerLightIntensity,
Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5
OwnerId = ViewerLightOwnerId,
IsLit = true,
};
_all.Add(_viewerLight);
}
_viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f);
}
/// <summary>
/// Select up to <see cref="MaxLightsPerObject"/> point/spot lights from
/// <paramref name="snapshot"/> that reach the object sphere