diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
index f3e0c55..bcca4ec 100644
--- a/src/AcDream.App/Rendering/CellVisibility.cs
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -330,6 +330,21 @@ public sealed class CellVisibility
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
}
+ ///
+ /// Brute-force scan of every loaded cell to test whether
+ /// is inside any of them. Does not touch
+ /// the camera cache (), so this is safe
+ /// to call alongside in the same frame
+ /// for a different position (e.g. player position when the camera is
+ /// in third-person chase mode).
+ ///
+ public bool IsInsideAnyCell(Vector3 worldPoint)
+ {
+ foreach (var cell in _cellLookup.Values)
+ if (PointInCell(worldPoint, cell)) return true;
+ return false;
+ }
+
// ------------------------------------------------------------------
// GetVisibleCells (BFS)
// ------------------------------------------------------------------
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6f87a61..13a660c 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -6848,6 +6848,19 @@ public sealed class GameWindow : IDisposable
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
+ // Lighting decisions (sun zeroed, indoor ambient applied) must
+ // track the PLAYER's cell, not the camera's. In third-person
+ // chase mode the camera enters interiors before the player body
+ // does, so a camera-based trigger flips the scene to indoor
+ // lighting prematurely. Retail's CellManager::ChangePosition
+ // @ 0x004559B0 reads CObjCell::seen_outside on the player's
+ // current cell — that's the semantics we want here. When the
+ // player isn't in player mode (orbit / fly debug camera) we
+ // fall back to the camera trigger.
+ bool playerInsideCell = (_playerMode && _playerController is not null)
+ ? _cellVisibility.IsInsideAnyCell(_playerController.Position)
+ : cameraInsideCell;
+
// Phase C.1: tick retail PhysicsScript particle hooks. Named
// retail decomp confirms SkyObject.PesObjectId is copied by
// SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is
@@ -6861,7 +6874,7 @@ public sealed class GameWindow : IDisposable
// the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the
// frame — terrain, static mesh, instanced mesh, sky.
- UpdateSunFromSky(kf, cameraInsideCell);
+ UpdateSunFromSky(kf, playerInsideCell);
Lighting.Tick(camPos);
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
@@ -8326,15 +8339,18 @@ public sealed class GameWindow : IDisposable
/// Indoor brightness then comes from per-cell point lights
/// (Setup.Lights on the cell's static objects, registered through
/// ).
+ /// The trigger is the PLAYER's cell, not the camera's — third-person
+ /// chase camera enters interiors before the player body does, and
+ /// retail keys lighting off the player position.
///
- private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell)
+ private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell)
{
// Sun direction: points FROM the sun TOWARDS the world. Our
// shader does dot(N, -forward) so a positive N·L means the
// surface faces the sun.
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
- if (cameraInsideCell)
+ if (playerInsideCell)
{
// Indoor default — retail's flat 0.2 neutral ambient, sun
// zeroed. See xref to retail decomp in the doc comment above.