merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)

main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 16:19:15 +02:00
commit 5ac9d8c19c
53 changed files with 6691 additions and 439 deletions

View file

@ -1015,21 +1015,36 @@ public sealed class GameWindow : IDisposable
// integrates gravity against an empty world and free-falls
// the player into the void (retail loads cells synchronously;
// this is the async-streaming equivalent of that invariant).
isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)
&& _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null
// #107 gate-2 extension (2026-06-10): an INDOOR spawn claim
// additionally waits for the claimed cell's hydration so the
// entry snap's AdjustPosition validation can act (retail loads
// the cell synchronously before SetPosition; this is the
// async-streaming equivalent). Claims that can never hydrate
// (id outside the landblock's NumCells range per the dat)
// don't hold the gate — the Resolve-head safety net demotes
// them loudly.
&& (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|| sp.Position is not { } spawnClaim
|| spawnClaim.LandblockId == 0
|| _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)
|| IsSpawnClaimUnhydratable(spawnClaim.LandblockId)),
isSpawnGroundReady: () =>
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
// #107 / #135: spawn-ground readiness is spawn-claim aware. For an
// INDOOR claim (sealed dungeon / building interior) the ground the
// player lands on is the EnvCell FLOOR (its BSP), so gate on the
// cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap.
// A dungeon's cells sit in their landblock at an arbitrary (often
// negative) offset, so the spawn's WORLD position can fall in a
// NEIGHBOUR terrain landblock that the #135 dungeon collapse
// deliberately does not load; requiring terrain there hangs login
// forever (cellReady true, SampleTerrainZ null). Retail loads the
// cell synchronously and places the player on the cell floor —
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
// (Before #135 this only passed by accident: the 25×25 window
// happened to stream the neighbour terrain.)
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
&& sp.Position is { } spawnClaim
&& spawnClaim.LandblockId != 0
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
&& !IsSpawnClaimUnhydratable(spawnClaim.LandblockId))
return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId);
// Outdoor spawn, OR an unhydratable indoor claim that will demote to
// an outdoor position: hold until the terrain under the spawn streams
// (the original #106 gate — entering against an empty world free-falls
// the player into the void).
return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
},
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
@ -2086,6 +2101,7 @@ public sealed class GameWindow : IDisposable
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
clearPendingLoads: _streamer.ClearPendingLoads,
removeTerrain: id =>
{
// Phase G.2: release any LightSources attached to entities
@ -2608,6 +2624,57 @@ public sealed class GameWindow : IDisposable
// landblock; each neighbor landblock is offset by 192 units per step.
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
// G.3 (#133): recenter streaming onto the player's spawn landblock at
// login. The streaming center (_liveCenterX/_liveCenterY) is pinned to
// the startup default (Holtburg, 0xA9B4) and is otherwise only moved by
// the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A
// character saved INSIDE a far dungeon spawns with that dungeon's
// landblock id, but the center never followed it, so the dungeon (tens
// of km away in world space) never streamed and the #107 auto-entry
// gate's SampleTerrainZ(pe.Position) waited forever — the player hung
// frozen at login. Mirror the teleport-arrival recenter HERE, for the
// PLAYER's spawn only, BEFORE the world-space translation below: when
// the spawn landblock differs from the current center, move the center
// onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the
// new center frame (identical to the teleport path's
// `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`),
// and the next StreamingController.Tick observes the new center and
// streams the spawn landblock.
//
// No-op for a normal Holtburg login: the saved spawn landblock equals
// the default center, so the guard is false and origin/worldPos are
// byte-identical to the pre-fix path. Gated on the player guid so NPC /
// object spawns never move the center. Idempotent + thrash-free: a
// re-sent CreateObject for the same spawn landblock leaves the center
// already-equal, so the guard is false on every repeat.
if (spawn.Guid == _playerServerGuid
&& (lbX != _liveCenterX || lbY != _liveCenterY))
{
Console.WriteLine(
$"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " +
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
_liveCenterX = lbX;
_liveCenterY = lbY;
}
// #135: the instant we know the player spawned into a SEALED dungeon,
// pre-collapse streaming to that single landblock — BEFORE the first
// StreamingController.Tick bootstraps the 25×25 ocean-grid window. The
// player isn't placed yet (physics CurrCell is null), so the per-frame
// insideDungeon gate stays false for the entire hydration window and
// NormalTick would otherwise load ~24 neighbor dungeons then unload them
// (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed-
// dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
// (reentrant); the controller call is render-thread-safe (Channel writes).
if (spawn.Guid == _playerServerGuid
&& _streamingController is not null
&& IsSealedDungeonCell(p.LandblockId))
{
_streamingController.PreCollapseToDungeon(lbX, lbY);
}
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
@ -4621,10 +4688,18 @@ public sealed class GameWindow : IDisposable
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
// Phase A.1: track the most recently updated entity's landblock so the
// streaming controller can follow the player. TODO: filter by our own
// character guid once we reliably know it from CharacterList.
_lastLivePlayerLandblockId = update.Position.LandblockId;
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
// streaming controller can follow the player in the fly-camera / pre-player-mode
// (login hold) views. Filtered to our OWN character guid — resolving the original
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
// must NOT move the streaming observer: during a dungeon-login hold (player not
// yet placed, so _playerController is null and the PortalSpace observer branch
// can't apply) that would drift the observer off the pre-collapsed dungeon
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
// (~line 1984) before world entry, so it is valid by the time updates arrive.
if (update.Guid == _playerServerGuid)
_lastLivePlayerLandblockId = update.Position.LandblockId;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
@ -5046,7 +5121,7 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rmState.Body.Orientation;
}
// Phase B.3: portal-space arrival detection.
// Phase B.3 / G.3a (#133): portal-space arrival detection.
// Only runs for our own player character while in PortalSpace.
if (_playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
@ -5060,79 +5135,127 @@ public sealed class GameWindow : IDisposable
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
// #107 (2026-06-10): ANY player position update while in PortalSpace
// IS the teleport arrival. Retail/holtburger exit portal space on the
// next position event unconditionally (holtburger messages.rs
// PlayerTeleport handler: log + LoginComplete; the destination applies
// through the normal position flow — no distance test). The old
// `differentLandblock || farAway(>100m)` arrival gate was an
// invention: ACE's same-landblock short-hop position corrections
// (e.g. right after an indoor login) matched neither condition, so
// PortalSpace never exited and movement input stayed frozen for the
// whole session (the #107 "input ignored" wedge shape —
// flood-fix-gate2.log: `teleport started (seq=1)` with no arrival).
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
System.Numerics.Vector3 newWorldPos;
if (differentLandblock)
{
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
// Recenter the streaming controller on the new landblock NOW (kick
// off the dungeon load). After recentering, the destination is
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
_liveCenterX = lbX;
_liveCenterY = lbY;
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
System.Numerics.Vector3 newWorldPos;
if (differentLandblock)
{
// 1. Recenter the streaming controller on the new landblock.
_liveCenterX = lbX;
_liveCenterY = lbY;
// Recompute worldPos with new center (it becomes local-to-center).
// After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
// relative to the new origin — which maps to world-space (0,0,0) + local offset.
// The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
// (after recentering, origin is (0,0,0) since lb == center)
}
else
{
// Same landblock: worldPos is already in the current center frame.
newWorldPos = worldPos;
}
// 2. Resolve through physics for the correct ground Z.
uint newCellId = p.LandblockId;
var resolved = _physicsEngine.Resolve(
newWorldPos, newCellId,
System.Numerics.Vector3.Zero, _playerController.StepUpHeight);
var snappedPos = new System.Numerics.Vector3(
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
// 3. Snap player entity + controller.
entity.SetPosition(snappedPos);
entity.ParentCellId = resolved.CellId;
entity.Rotation = rot;
_playerController.SetPosition(snappedPos, resolved.CellId);
// 4. Recenter chase camera on the new position.
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
playerVelocity: System.Numerics.Vector3.Zero,
isOnGround: true,
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
dt: 1f / 60f);
// 5. Return to InWorld.
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
// 5. Send LoginComplete to tell the server the client finished loading.
// Per holtburger's PlayerTeleport handler (client/messages.rs:434-440),
// retail clients call send_login_complete() after each portal transition.
// ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path
// doesn't also send one. We send directly here instead.
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
// #135: pre-collapse on teleport into a sealed dungeon too — same
// race as login. The destination isn't placed until it hydrates, so
// without this NormalTick loads the full neighbor window during the
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
// observer pinned to _liveCenterX/Y while held, so the stale frozen
// player position can't drift the observer off the dungeon and re-expand.
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
}
else
{
newWorldPos = worldPos;
}
// G.3a: do NOT snap here. The destination dungeon landblock has not
// streamed in yet; an immediate Resolve falls back to the resident
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
// in portal space — TeleportArrivalController.Tick (per frame) places
// the player via PlaceTeleportArrival once the destination cell
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
EnsureTeleportArrivalController();
_pendingTeleportRot = rot;
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
}
}
// G.3a (#133): holds a teleport arrival in portal space until the destination
// dungeon landblock/cell has hydrated, then places the player via the unchanged
// validated-claim Resolve path. Lazily constructed on the first teleport (all
// runtime deps are wired by then).
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
private void EnsureTeleportArrivalController()
{
if (_teleportArrival is not null) return;
_teleportArrival = new AcDream.App.World.TeleportArrivalController(
readiness: TeleportArrivalReadiness,
place: PlaceTeleportArrival);
}
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
// against the teleport's (destPos, destCell): an impossible indoor claim short-
// circuits to immediate placement; otherwise hold until terrain is sampled and,
// for an indoor cell, the cell struct has hydrated.
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
System.Numerics.Vector3 destPos, uint destCell)
{
if (IsSpawnClaimUnhydratable(destCell))
return AcDream.App.World.ArrivalReadiness.Impossible;
// #135: an INDOOR destination (sealed dungeon / building interior) gates on the
// EnvCell FLOOR, not the terrain heightmap. A dungeon's negative-offset cells can
// place destPos in a NEIGHBOUR terrain landblock the #135 collapse doesn't load,
// so SampleTerrainZ would stay null forever (the cell IS ready). Retail places on
// the cell floor. Outdoor: the terrain heightmap is the ground.
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
if (indoor)
return _physicsEngine.IsSpawnCellReady(destCell)
? AcDream.App.World.ArrivalReadiness.Ready
: AcDream.App.World.ArrivalReadiness.NotReady;
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
return AcDream.App.World.ArrivalReadiness.NotReady;
return AcDream.App.World.ArrivalReadiness.Ready;
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
// once the destination is ready (or force-run on impossible/timeout, logged loud).
private void PlaceTeleportArrival(
System.Numerics.Vector3 destPos, uint destCell, bool forced)
{
var resolved = _physicsEngine.Resolve(
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
var snappedPos = new System.Numerics.Vector3(
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
if (forced)
Console.WriteLine(
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.SetPosition(snappedPos);
pe.ParentCellId = resolved.CellId;
pe.Rotation = _pendingTeleportRot;
}
_playerController.SetPosition(snappedPos, resolved.CellId);
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
playerVelocity: System.Numerics.Vector3.Zero,
isOnGround: true,
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
dt: 1f / 60f);
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
// Tell the server the client finished loading the new landblock (holtburger
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
}
/// <summary>
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
/// Freeze movement input by setting the player controller to PortalSpace.
@ -5144,6 +5267,7 @@ public sealed class GameWindow : IDisposable
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
EnsureTeleportArrivalController();
Console.WriteLine($"live: teleport started (seq={sequence})");
}
@ -5266,6 +5390,11 @@ public sealed class GameWindow : IDisposable
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
private string? _lastOutStagePtSig;
private readonly HashSet<uint> _outStageUnmatchedScratch = new();
private readonly HashSet<uint> _outStageMatchedScratch = new();
private static System.Numerics.Vector3 SkyPesAnchor(
AcDream.Core.World.SkyObjectData obj,
System.Numerics.Vector3 cameraWorldPos)
@ -5765,22 +5894,56 @@ public sealed class GameWindow : IDisposable
// Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below — they have real GfxObj MeshRefs that work
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
// Transforms — needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
// shared with every draw-space consumer of portal polygons (OutsideView
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
// to the cells beyond — otherwise the flood lookup-misses the unregistered
// neighbour and the grey clear shows through the opening (#133: ramp
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
// before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
// is intentionally NOT fed into the visibility transform (#119-residual: the
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
// PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of
// drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer →
// sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to
// TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can
// never reach it and lags one cell behind the eye (#133 residual: the camera sat
// 1.32 m past the ramp portal's plane while the viewer cell stalled in
// 0x00070103 — the sweep transited every cached neighbour but NEVER the
// un-cached connector 0x014D — so the side test culled the on-screen connector
// portal and the grey clear showed through). Retail keeps the whole landblock
// cell array resident for the sweep; a portals-only connector has an empty
// collision BSP but its portals drive the transit. CacheCellStruct reads the
// cellStruct directly, not the render sub-meshes.
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
_pendingCellMeshes[envCellId] = cellSubMeshes;
// Keep the small render lift out of physics; retail BSP
// contact planes use the EnvCell origin verbatim.
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
// Phase A8: register the cell with EnvCellRenderer for rendering.
// staticObjects is empty — cell stabs continue as separate WorldEntity
// records via the dispatcher (see lines below for the unchanged stab path).
@ -5793,25 +5956,6 @@ public sealed class GameWindow : IDisposable
cellWorldPosition: cellOrigin,
cellRotation: envCell.Position.Orientation,
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
// Step 4: build LoadedCell for portal visibility — with the
// PHYSICS (unlifted) transform. The +0.02 m render lift above
// is a DRAW concern (shell z-fighting vs terrain); feeding it
// into the visibility graph shifted every HORIZONTAL portal
// plane 2 cm up, putting an eye standing on a deck/landing
// 1020 mm BELOW the lifted plane — outside the side test's
// ±10 mm in-plane window — so the cell behind the portal was
// side-culled: the tower-top staircase vanish + roof flap
// (#119-residual; captured live at eye z=126.803 vs the
// 010A→0107 plane at 126.80, reproduced ONLY with the lift in
// TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical
// doorways were immune (the lift slides their planes along
// themselves), which is why this hit exactly stairs, decks,
// and cellar mouths.
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
// Cache CellStruct physics BSP for indoor collision (UNCHANGED).
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
}
}
}
@ -5828,6 +5972,17 @@ public sealed class GameWindow : IDisposable
.DumpEntitySourceIds.Contains(stab.Id);
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
// WorldBuilder editor shows it at the origin. acdream's render path came from
// WB (no distance LOD), so without this skip it draws the marker forever (the
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
if ((stab.Id & 0xFF000000u) == 0x01000000u
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
continue;
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
if ((stab.Id & 0xFF000000u) == 0x01000000u)
@ -5861,6 +6016,12 @@ public sealed class GameWindow : IDisposable
}
foreach (var mr in flat)
{
// #136: skip an editor-only marker PART (retail hides it at runtime
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
// part GfxObj 0x010028CA is such a marker — skipping it empties
// meshRefs and the whole stab drops below.
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
continue;
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null)
{
@ -6949,7 +7110,27 @@ public sealed class GameWindow : IDisposable
int observerCx = _liveCenterX;
int observerCy = _liveCenterY;
if (_playerMode && _playerController is not null)
if (_playerMode && _playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
{
// Teleport hold (#135): the local player position is frozen at the
// PRE-teleport spot, expressed in the OLD center frame, but
// _liveCenterX/_liveCenterY were already recentered onto the
// destination landblock (OnLivePositionUpdated). Follow the
// destination directly — the stale position-derived offset
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
// the dungeon and trip ExitDungeonExpand, re-streaming the very
// neighbor window the pre-collapse just suppressed. Correct for an
// outdoor teleport too: pre-load the destination during the hold.
//
// NOTE: these assignments equal the observerCx/Cy defaults initialized
// above — the LOAD-BEARING effect of this branch is INHIBITING the
// position-derived offset in the else-if below while the player position
// is frozen, not the (redundant) assignment. Kept explicit for clarity.
observerCx = _liveCenterX;
observerCy = _liveCenterY;
}
else if (_playerMode && _playerController is not null)
{
// Player mode: follow the physics-resolved player position.
// The player walks via the local physics engine; the server
@ -6961,12 +7142,28 @@ public sealed class GameWindow : IDisposable
observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
}
else if (_liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
&& _lastLivePlayerLandblockId is { } lid)
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
{
// Live mode (fly camera): follow the server's last-known player position.
observerCx = (int)((lid >> 24) & 0xFFu);
observerCy = (int)((lid >> 16) & 0xFFu);
// Live, not yet in player mode: the login auto-entry hold, or a live
// fly-camera spectator. Follow the PLAYER's server-known landblock; if it
// hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is
// the spawn/teleport recenter (the dungeon landblock at a dungeon login).
//
// #135 regression fix (2026-06-14): this MUST NOT fall through to the
// fly-camera projection below. During a dungeon-login hold the streaming is
// pre-collapsed onto the spawn landblock; a camera-derived observer far from
// it trips ExitDungeonExpand and unloads the dungeon before it can hydrate —
// the player is never placed and login hangs with no dungeon. Previously
// _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC
// kept this branch on the dungeon; once it was filtered to the player guid
// (line ~4507), a not-yet-arrived player UP dropped to the camera branch.
// The fly camera is the OFFLINE observer only.
if (_lastLivePlayerLandblockId is { } lid)
{
observerCx = (int)((lid >> 24) & 0xFFu);
observerCy = (int)((lid >> 16) & 0xFFu);
}
// else: keep the _liveCenterX/_liveCenterY default (the spawn recenter).
}
else
{
@ -6980,7 +7177,37 @@ public sealed class GameWindow : IDisposable
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
}
_streamingController.Tick(observerCx, observerCy);
// Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
// (indoor cell that doesn't see outside — the same predicate that kills
// the sun/sky, playerInsideCell below), collapse streaming to the single
// dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
// window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
// interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
// and keep their surrounding terrain.
// True only for a sealed indoor cell. Read the physics CurrCell's own
// SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather
// than the render registry: the registry lookup only succeeds AFTER the
// landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which
// delayed the collapse and let the full 25×25 neighbor window churn in
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
// moment the player is placed, so the collapse now engages at the snap.
bool insideDungeon = false;
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
&& !pcEnv.SeenOutside)
{
insideDungeon = true;
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
// at arbitrary world coords (the "ocean" placement) with negative local
// offsets, so floor(pp.Y/192) lands one landblock off — which collapses
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
// and breaking the render (the Bug-A coordinate class). The cell id is the
// authoritative landblock.
uint cellLb = pcEnv.Id >> 16;
observerCx = (int)((cellLb >> 8) & 0xFFu);
observerCy = (int)(cellLb & 0xFFu);
}
_streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks
// into the current center landblock (the one the observer is in).
@ -7000,6 +7227,12 @@ public sealed class GameWindow : IDisposable
// Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick();
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
// (which applies the destination landblock) and the live-session drain
// (which may have just called BeginArrival), so a destination that
// hydrated this frame is placed the same frame.
_teleportArrival?.Tick();
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
@ -7138,10 +7371,24 @@ public sealed class GameWindow : IDisposable
// so it doesn't get frustum-culled when the player walks away from
// the spawn landblock. Without this, the entity stays in the spawn
// landblock's entity list and disappears when that landblock is culled.
var pp = _playerController.Position;
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
uint currentLb;
if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u)
{
// Indoor cell (dungeon/building EnvCell): the entity's landblock is
// the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean"
// world coords with negative local-Y, so floor(pp.Y/192) lands one
// landblock off (the Bug-A class) — relocating the player into the
// landblock the dungeon collapse unloaded, making the avatar
// invisible. The cell id is authoritative.
currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu;
}
else
{
var pp = _playerController.Position;
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
_worldState.RelocateEntity(pe, currentLb);
}
@ -7717,6 +7964,25 @@ public sealed class GameWindow : IDisposable
_sceneLightingUbo?.Upload(ubo);
// #133 A7 (2026-06-13): objective dungeon-lighting probe. One
// rate-limited [light] line — insideCell / ambient / sun /
// registered-point-lights / active-slot-count / player cell — so
// the dungeon-dim question is self-verifiable from launch.log
// without a screenshot. RegisteredCount is point/spot lights only
// (the sun lives in LightManager.Sun, never in the _all list);
// ubo.CellAmbient.W is the shader active-slot count, which counts
// the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1.
AcDream.Core.Rendering.RenderingDiagnostics.EmitLight(
insideCell: playerInsideCell,
ambientR: Lighting.CurrentAmbient.AmbientColor.X,
ambientG: Lighting.CurrentAmbient.AmbientColor.Y,
ambientB: Lighting.CurrentAmbient.AmbientColor.Z,
sunIntensity: Lighting.Sun?.Intensity ?? 0f,
registeredLights: Lighting.RegisteredCount,
activeLights: (int)ubo.CellAmbient.W,
playerCellId: playerRoot?.CellId ?? 0u,
lights: Lighting);
// Never cull the landblock the player is currently on.
uint? playerLb = null;
if (_playerMode && _playerController is not null)
@ -7796,9 +8062,9 @@ public sealed class GameWindow : IDisposable
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
// OutsideView terrain integration that consumes this is the next (cutover) step.
_outdoorNode = null;
if (viewerRoot is null && viewerCellId != 0u)
_outdoorNodeBuildingCells.Clear();
if (viewerRoot is not null || viewerCellId != 0u)
{
_outdoorNodeBuildingCells.Clear();
// T2 (BR-4): draw-driven flood gating. Retail floods a building's
// interior exactly when its shell DRAWS and an aperture survives
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
@ -7813,6 +8079,12 @@ public sealed class GameWindow : IDisposable
// Per-building iteration is also the FPS fix the 2026-06-07
// Chebyshev hack approximated: dozens of AABB tests instead of an
// O(all loaded cells) portal sweep.
// #124: the gather now runs for INTERIOR roots too — retail's
// look-in executes inside LScape::draw for ANY root with a
// non-empty outside view (DrawCells pc:432719). The renderer
// routes interior-root look-ins to its landscape-stage sub-pass
// (DrawBuildingLookIns); the root's own building self-excludes
// via the seed eye-side test.
foreach (var registry in _buildingRegistries.Values)
{
foreach (var b in registry.All())
@ -7827,10 +8099,11 @@ public sealed class GameWindow : IDisposable
_outdoorNodeBuildingCells.Add(bc);
}
}
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
if (viewerRoot is null)
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
}
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
@ -7956,10 +8229,10 @@ public sealed class GameWindow : IDisposable
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
{
RootCell = clipRoot,
// R-A2: outdoor root floods each nearby building per-building (not via the root). The
// gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it
// is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots.
NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null,
// R-A2: outdoor root floods each nearby building per-building (not via the root).
// #124: interior roots get the gather too — the renderer routes them to the
// landscape-stage look-in sub-pass instead of the merge.
NearbyBuildingCells = _outdoorNodeBuildingCells,
ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj,
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
@ -7985,6 +8258,22 @@ public sealed class GameWindow : IDisposable
renderWeather: playerSeenOutside,
kf,
environOverrideActive),
// #131/#132: the late phase — dynamics meshes + scene
// particles + weather AFTER the look-ins (FlushAlphaList
// deferral).
DrawLandscapeSliceLate = lateCtx =>
DrawRetailPViewLandscapeSliceLate(
lateCtx,
camera,
frustum,
camPos,
playerLb,
animatedIds,
renderSky,
renderWeather: playerSeenOutside,
kf,
environOverrideActive,
isOutdoorRoot: clipRoot.IsOutdoorNode),
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
// INTERIOR roots: one FULL depth clear between the outside stage and
// the interior stage, then SEALS re-stamp every outside-leading
@ -8005,6 +8294,26 @@ public sealed class GameWindow : IDisposable
DrawExitPortalMasks = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: clipRoot.IsOutdoorNode),
// #124: look-in apertures are ALWAYS the punch (retail
// maxZ1), independent of the root-keyed selector above.
DrawLookInPortalPunch = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: true),
// #131: unattached emitters under an interior root — the
// landscape-stage pass (the outdoor T3 pass below is gated
// IsOutdoorNode, so the two never both run).
DrawUnattachedSceneParticles = () =>
{
if (_particleSystem is null || _particleRenderer is null)
return;
DisableClipDistances();
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
},
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors =>
@ -8125,20 +8434,26 @@ public sealed class GameWindow : IDisposable
&& _particleSystem is not null && _particleRenderer is not null)
{
// T3 (BR-5): unattached emitters (campfires, ground effects —
// AttachedObjectId == 0) under the OUTDOOR root. The unified
// path's attached emitters draw via the landscape slice + the
// per-cell callbacks; unattached ones had NO pass on
// outdoor-node frames (the unattached-particles-dropped-
// outdoors divergence, adjusted-confirmed). The outdoor root's
// outside view is full-screen (cone pass-all); depth test
// composites them against the world.
// AttachedObjectId == 0) under the OUTDOOR root. The outdoor
// root's outside view is full-screen (cone pass-all); depth
// test composites them against the world.
// #132 outdoor sibling: ATTACHED outdoor-static scene emitters
// (lantern/candle flames) moved here too — drawn in the
// landscape slice they were overpainted by merged building
// interiors (drawn later) whenever a punched aperture sat
// behind them. Post-frame, depth is complete and the flames
// composite correctly. The owner-id set is the late slice's
// (full-screen cone outdoors). Cell-pass and dynamics-pass
// emitters keep their own passes (no double-draw: their owners
// are never in the outdoor-static id set).
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
emitter => emitter.AttachedObjectId == 0
|| _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
}
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
@ -9800,12 +10115,113 @@ public sealed class GameWindow : IDisposable
animatedEntityIds: animatedIds);
}
_outdoorSceneParticleEntityIds.Clear();
foreach (var entity in sliceCtx.OutdoorEntities)
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
// #131/#132: scene particles + weather MOVED to the LATE phase
// (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the
// #124 look-ins (retail's FlushAlphaList deferral, DrawCells
// pc:432722); drawn here they were overpainted by far-building
// interiors wherever a look-in aperture sat behind them.
if (scissor)
_gl!.Disable(EnableCap.ScissorTest);
DisableClipDistances();
if (_outdoorSceneParticleEntityIds.Count > 0
}
// #131/#132: the LATE landscape phase — per slice, invoked by the renderer
// AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage
// dynamics' meshes (a translucent portal swirl blends over a far interior
// instead of being overpainted by it — translucents write no depth to
// protect themselves) + ALL attached scene particles (statics' flames
// included — the #132 candle) + weather. Retail equivalent: alpha draws
// collected during LScape::draw flush ONCE after it
// (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722).
private void DrawRetailPViewLandscapeSliceLate(
AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx,
ICamera camera,
FrustumPlanes? frustum,
System.Numerics.Vector3 camPos,
uint? playerLb,
HashSet<uint>? animatedIds,
bool renderSky,
bool renderWeather,
AcDream.Core.World.SkyKeyframe kf,
bool environOverrideActive,
bool isOutdoorRoot)
{
var slice = lateCtx.Slice;
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
_gl!.BindBufferBase(BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo);
// Outside-stage dynamics' meshes — viewcone pre-filtered by the
// renderer, never hard-clipped (T3).
DisableClipDistances();
if (lateCtx.Dynamics.Count > 0)
{
var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
lateCtx.Dynamics,
(IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
_wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: null,
animatedEntityIds: animatedIds);
}
_outdoorSceneParticleEntityIds.Clear();
foreach (var entity in lateCtx.ParticleOwners)
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
// #131 [outstage-pt] probe: the slice Scene-particle id set + how many
// live emitters the filter would actually match, plus the distinct
// UNMATCHED attached owner ids (the portal-identification handle —
// an emitter whose owner never lands in the set draws nowhere
// indoors). Print-on-change.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& _particleSystem is not null)
{
int matched = 0, attached = 0, unattached = 0;
_outStageUnmatchedScratch.Clear();
_outStageMatchedScratch.Clear();
foreach (var (emitter, _) in _particleSystem.EnumerateLive())
{
if (emitter.AttachedObjectId == 0) { unattached++; continue; }
attached++;
if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId))
{
matched++;
if (_outStageMatchedScratch.Count < 48)
_outStageMatchedScratch.Add(emitter.AttachedObjectId);
}
else if (_outStageUnmatchedScratch.Count < 12)
_outStageUnmatchedScratch.Add(emitter.AttachedObjectId);
}
var unm = new System.Text.StringBuilder(96);
foreach (uint id in _outStageUnmatchedScratch)
unm.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
var mat = new System.Text.StringBuilder(192);
foreach (uint id in _outStageMatchedScratch)
mat.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
string ptSig = System.FormattableString.Invariant(
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{mat}] unmatchedIds=[{unm}]");
if (ptSig != _lastOutStagePtSig)
{
_lastOutStagePtSig = ptSig;
Console.WriteLine("[outstage-pt] " + ptSig);
}
}
// #132 outdoor sibling: under an OUTDOOR root the merged building
// interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn
// here is overpainted whenever a punched aperture sits behind it
// (user-confirmed at the outdoor candle). Outdoor roots therefore
// SKIP the slice Scene pass and draw attached scene particles in the
// post-frame pass alongside the T3 unattached pass (the id set built
// above carries over — the outdoor root has a single full-screen
// slice). Interior roots draw here: the look-ins already ran and the
// post-clear seal discipline owns the rest of the frame.
if (!isOutdoorRoot
&& _outdoorSceneParticleEntityIds.Count > 0
&& _particleSystem is not null
&& _particleRenderer is not null)
{
@ -9881,9 +10297,16 @@ public sealed class GameWindow : IDisposable
if (localVerts.Length < 3)
continue;
// cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b);
// the shell that rasterizes this aperture draws +ShellDrawLiftZ
// higher. The seal/punch is a DRAW — stamp depth in the same lifted
// space or the stamp sits 2 cm below the drawn hole (#130 family).
int n = System.Math.Min(localVerts.Length, world.Length);
for (int v = 0; v < n; v++)
{
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
}
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
}
@ -10136,26 +10559,18 @@ public sealed class GameWindow : IDisposable
// Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in
// framebuffer pixels and enable the scissor test; returns true iff applied (the caller then
// disables EnableCap.ScissorTest after its draw/clear). Mirrors the terrain Scissor-mode
// NDC→pixel conversion (one source for the box math). Used to confine the sky/weather particle
// passes (particle.vert has no gl_ClipDistance) and the conditional doorway depth-only Z-clear
// to the doorway opening. Returns false (no scissor) when not applied (outdoor / no window).
// disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice
// (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false
// (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer
// bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel
// off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip.
private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb)
{
if (!apply || _window is null) return false;
var fb = _window.FramebufferSize;
// NDC [-1,1] → window pixels. Clamp so a doorway opening that extends past a screen edge
// still yields a valid box (same clamp the terrain Scissor path uses).
float nx0 = System.Math.Clamp(ndcAabb.X, -1f, 1f);
float ny0 = System.Math.Clamp(ndcAabb.Y, -1f, 1f);
float nx1 = System.Math.Clamp(ndcAabb.Z, -1f, 1f);
float ny1 = System.Math.Clamp(ndcAabb.W, -1f, 1f);
int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X);
int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y);
int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X);
int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y);
var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y);
_gl!.Enable(EnableCap.ScissorTest);
_gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph));
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}
@ -10523,6 +10938,7 @@ public sealed class GameWindow : IDisposable
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
clearPendingLoads: _streamer.ClearPendingLoads,
removeTerrain: id =>
{
if (_lightingSink is not null &&
@ -11771,6 +12187,35 @@ public sealed class GameWindow : IDisposable
return unhydratable;
}
// #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell
// (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes
// a real dungeon (collapse streaming to its single landblock) from a building
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
// the hydration path (BuildLoadedCell, ~line 5999) and as the physics
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
// decision matches the eventual gate decision exactly. Returns false when the dat
// lacks the cell (out-of-range index / missing record) so we never collapse on a
// guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may
// already hold it (the login spawn handler does).
private bool IsSealedDungeonCell(uint cellId)
{
// Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF
// structural shell ids (LandBlockInfo / LandBlock heightmap). A naive
// `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get<EnvCell> on
// 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as
// an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport
// position never carries a shell id, but exclude them so the read is sound.
uint low = cellId & 0xFFFFu;
if (low < 0x0100u || low >= 0xFFFEu) return false;
if (_dats is null) return false;
DatReaderWriter.DBObjs.EnvCell? envCell;
lock (_datLock)
envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(cellId);
return envCell is not null
&& !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
}
private void EnterPlayerModeFromAutoEntry()
{
_playerMode = true;

View file

@ -0,0 +1,45 @@
// NdcScissorRect.cs
//
// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
// The scissor that brackets a landscape/doorway slice is a fallback BOUND on
// the slice's view region (AD-17 in the divergence register): it must CONTAIN
// every fragment the per-fragment plane clip would keep. Under-inclusion is
// the bug class — the #130 doorway top-edge background strip was this box
// computed as Floor(origin) + Ceiling(size), whose far edge
// floor(min)+ceil(maxmin) lands up to one pixel SHORT of the true max edge
// at unlucky fractional alignments, scissoring away the aperture's top/right
// pixel row for the whole slice (sky, terrain, statics, weather) while the
// seal still stamps it — a strip of clear color no later pass can fill.
//
// Correct outer bound: floor both mins, ceil both maxes, width = difference.
// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in
// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
// i ≥ X00.5 ⇒ i ≥ floor(X0) and i ≤ X10.5 ⇒ i < ceil(X1). So
// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by
// at most one pixel per edge — safe per AD-17's doctrine (the wall shell /
// plane clip repaints or kills the surplus).
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
public static class NdcScissorRect
{
/// <summary>Convert an NDC AABB (minX, minY, maxX, maxY in [-1,1]) to a
/// framebuffer-pixel scissor box that CONTAINS it. Inputs are clamped to
/// the screen so a region extending past an edge still yields a valid box.
/// Width/height are at least 1.</summary>
public static (int X, int Y, int Width, int Height) ToPixels(
Vector4 ndcAabb, int fbWidth, int fbHeight)
{
float nx0 = Math.Clamp(ndcAabb.X, -1f, 1f);
float ny0 = Math.Clamp(ndcAabb.Y, -1f, 1f);
float nx1 = Math.Clamp(ndcAabb.Z, -1f, 1f);
float ny1 = Math.Clamp(ndcAabb.W, -1f, 1f);
int px0 = (int)MathF.Floor((nx0 * 0.5f + 0.5f) * fbWidth);
int py0 = (int)MathF.Floor((ny0 * 0.5f + 0.5f) * fbHeight);
int px1 = (int)MathF.Ceiling((nx1 * 0.5f + 0.5f) * fbWidth);
int py1 = (int)MathF.Ceiling((ny1 * 0.5f + 0.5f) * fbHeight);
return (px0, py0, Math.Max(1, px1 - px0), Math.Max(1, py1 - py0));
}
}

View file

@ -52,7 +52,8 @@ uniform mat4 uViewProjection;
uniform int uPlaneCount;
uniform vec4 uPlanes[8];
uniform int uForceFarZ;
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
out float gl_ClipDistance[8];
void main()
{
@ -62,7 +63,14 @@ void main()
if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
else if (uDepthBias > 0.0)
clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan)
{
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
// constant-NDC form alone reached METERS at distance (door-shaped
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
clipPos.z -= biasNdc * clipPos.w;
}
gl_Position = clipPos;
}";
@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly int _locPlanes;
private readonly int _locForceFarZ;
private readonly int _locDepthBias;
private readonly int _locDepthBiasEyeCapN;
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// stencil below). The bias keeps the #108 case covered — terrain
/// hugging the door plane (centimeters in front of the aperture) must
/// still be punched; a hill or another house meters nearer must not.
/// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1.
/// </summary>
private const float PunchMarkDepthBias = 0.0005f;
/// <summary>
/// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
/// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
/// ~190 m at a landblock away: every hill/house in front of a distant
/// aperture passed the mark and got far-Z punched — door-shaped leaks
/// through occluders. Fix: cap the bias's EYE-SPACE span at
/// <see cref="PunchMarkBiasEyeCapMeters"/>. Below the ~10 m crossover
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
/// bit-identical to the T5-validated close-range behavior (#108 grass
/// coverage untouched); beyond it the punch can never reach an occluder
/// more than the cap in front of the aperture plane.
/// </summary>
public const float PunchMarkBiasEyeCapMeters = 0.5f;
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
/// d4b5c71). The cap conversion below assumes the production camera near
/// plane; the small f/(fn) factor (~1.00002 at far 5000) is ignored.</summary>
public const float CameraNearPlaneMeters = 0.1f;
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
/// sync with <c>VertSrc</c>): the NDC bias applied at eye depth
/// <paramref name="eyeDepthMeters"/>.</summary>
public static float MarkBiasNdc(float eyeDepthMeters) =>
MathF.Min(PunchMarkDepthBias,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
/ MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
/// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
@ -237,6 +274,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;

View file

@ -97,16 +97,31 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
}
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs
/// terrain; applied in GameWindow's cell registration). The visibility
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b).
/// Draw-space consumers of portal polygons (the OutsideView color gate
/// here, the seal/punch depth fans in GameWindow) must apply this lift so
/// they meet the drawn shell's aperture edge — the unlifted gate left a
/// 2 cm background strip under the drawn lintel (#130).</summary>
public const float ShellDrawLiftZ = 0.02f;
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null)
Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f)
{
var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame;
@ -318,8 +333,22 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
// shell that rasterizes this aperture draws +drawLiftZ above
// the physics transform — project the region in the SAME
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
var outsideRegion = drawLiftZ == 0f
? clippedRegion
: ClipPortalAgainstView(
poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj,
activeViewPolygons,
out _);
AddRegion(frame.OutsideView, outsideRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
continue;
}
@ -451,12 +480,18 @@ public static class PortalVisibilityBuilder
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
/// <param name="seedRegion">Optional NDC region the seed apertures clip against —
/// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip
/// 0x005a4320): full screen when the viewer is outdoors, the accumulated
/// outside (doorway) view when a building is looked into from an interior
/// root (#124). Null = full screen (the outdoor-root behavior).</param>
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
@ -503,7 +538,7 @@ public static class PortalVisibilityBuilder
poly,
cell.WorldTransform,
viewProj,
FullScreenRegion,
seedRegion ?? FullScreenRegion,
out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
@ -633,8 +668,9 @@ public static class PortalVisibilityBuilder
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =

View file

@ -27,6 +27,16 @@ public sealed class RetailPViewRenderer
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
// #124: per-building look-in frames under an INTERIOR root, drawn as a
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
// main frame (see DrawInside). Rebuilt each interior-root frame.
private readonly List<PortalVisibilityFrame> _lookInFrames = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
// (statics + outside-stage dynamics passing the slice cone).
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -54,7 +64,9 @@ public sealed class RetailPViewRenderer
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection);
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
@ -65,6 +77,26 @@ public sealed class RetailPViewRenderer
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame);
// #124: interior-root building look-ins. Retail runs the look-in INSIDE
// the landscape stage for ANY root — LScape::draw is the FIRST call of
// DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
// clear (pc:432732) and the exit-portal seals (pc:432785); a far
// building seen through our doorway floods clipped to the INSTALLED
// outside view (GetClip vs current view, ConstructView(CBldPortal)
// 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
// (inside the landscape stage), NEVER merged into the main frame — a
// merged cell would draw post-clear and z-fail against the root's seal
// (its geometry is beyond the door plane). The eye-side seed test
// self-excludes the root's own building (the eye is on its interior
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
// (no depth clear under outdoor roots — the merged form is equivalent
// there).
_lookInFrames.Clear();
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
@ -76,15 +108,31 @@ public sealed class RetailPViewRenderer
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells;
if (_lookInFrames.Count > 0)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
foreach (uint c in f.OrderedVisibleCells)
_lookInPrepareScratch.Add(c);
prepareCells = _lookInPrepareScratch;
}
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
@ -213,6 +261,133 @@ public sealed class RetailPViewRenderer
}
}
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
// the accumulated doorway region — so a far building floods only within the
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
// building self-excludes via the seed eye-side test.
private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons);
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
}
}
// #124: draw the interior-root look-ins INSIDE the landscape stage —
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
// pc:432719, before the depth clear + seals). Per building: punch ALL
// apertures first (retail finishes build_draw_portals_only pass 1 — the
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// landscape/clear/seal). Anything rasterized outside an aperture is
// repainted by the root's own shells after the depth clear, so over-draw
// here is color-safe; statics draw whole (the main viewcone has no entry
// for look-in cells; over-include is the safe direction).
private void DrawBuildingLookIns(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition)
{
if (_lookInFrames.Count == 0)
return;
foreach (var frame in _lookInFrames)
{
// Pass 1: far-Z punch every aperture of this building.
if (ctx.DrawLookInPortalPunch is not null)
{
foreach (uint cellId in frame.OrderedVisibleCells)
{
if (!frame.CellViews.TryGetValue(cellId, out var view))
continue;
foreach (var poly in view.Polygons)
{
var single = new CellView();
single.Add(poly);
var cps = ClipPlaneSet.From(single);
if (cps.IsNothingVisible)
continue;
var planes = new Vector4[cps.Count];
for (int p = 0; p < cps.Count; p++)
planes[p] = cps.Planes[p];
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
cellId,
new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes),
Array.Empty<WorldEntity>()));
}
}
}
// Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting();
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
_cellStaticScratch.Clear();
if (partition.ByCell.TryGetValue(cellId, out var bucket))
_cellStaticScratch.AddRange(bucket);
// #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the
// Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE
// under an interior root — DrawDynamicsLast viewcone-culls
// them (the main cone has no entries for look-in cells), and
// post-clear they would z-fail against the root's seal anyway
// (the #118 lesson). Retail draws a look-in cell's objects
// inside the NESTED DrawCells (DrawObjCellForDummies,
// pc:432878+), i.e. right here in the landscape stage. Drawn
// WHOLE like the statics (AP-33's documented over-include).
// No double-draw: dynamics-last keeps culling them (their
// cell is absent from the main cone), and their emitters ride
// the DrawCellParticles call below, not DrawDynamicsParticles
// (which only sees dynamics-last cone survivors).
foreach (var e in partition.Dynamics)
if (e.ParentCellId == cellId)
_cellStaticScratch.Add(e);
if (_cellStaticScratch.Count > 0)
{
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
// The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(
cellId, slice, _cellStaticScratch));
}
}
}
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
@ -222,6 +397,18 @@ public sealed class RetailPViewRenderer
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
// depth to mark against, the #117 lesson); then the look-ins; then
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) — the portal-swirl/candle-flame class.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@ -243,21 +430,74 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
// #118: outside-stage dynamics ride the landscape pass like retail's
// per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn
// BEFORE the depth clear + seals so the seal PROTECTS their pixels in
// the aperture instead of z-killing them. Same per-slice cone test as
// the statics above. Empty under outdoor roots (see DrawInside).
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(ctx, clipAssembly, partition);
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted) + the scene-particle owners (statics +
// dynamics cone survivors — flames ride here for the same reason).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
bool ownerPass = viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r);
if (ownerPass)
_lateParticleOwnerScratch.Add(e);
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
// double as an ENTITY-id watchlist here — one line per watched
// outdoor-static owner per CHANGE of its cone verdict.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id)
&& (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass))
{
_outStageOwnerVerdicts[e.Id] = ownerPass;
Console.WriteLine(System.FormattableString.Invariant(
$"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}"));
}
}
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_outdoorStaticScratch.Add(e);
_lateParticleOwnerScratch.Add(e);
}
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
EmitOutStageProbe(probeSliceIndex, viewcone);
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext(
slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
}
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
@ -271,6 +511,33 @@ public sealed class RetailPViewRenderer
UseIndoorMembershipOnlyRouting();
}
// #131 [outstage] probe state (2026-06-12, throwaway): print-on-change —
// which outdoor dynamics were routed to the outside stage and which
// survived the slice viewcone. Strip with the probe when #131 closes.
private string? _lastOutStageSig;
private readonly Dictionary<uint, bool> _outStageOwnerVerdicts = new();
private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone)
{
var sb = new System.Text.StringBuilder(192);
sb.Append("slice=").Append(sliceIndex)
.Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" [");
for (int i = 0; i < _outsideStageDynamics.Count; i++)
{
var e = _outsideStageDynamics[i];
EntitySphere(e, out var c, out float r);
bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r);
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant(
$"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}"));
}
sb.Append(']');
string sig = sb.ToString();
if (sig == _lastOutStageSig) return;
_lastOutStageSig = sig;
Console.WriteLine("[outstage] " + sig);
}
// §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature +
// monotonic sequence so held-flap vs healthy frames diff cleanly in one capture.
private string? _lastClipRouteSig;
@ -665,6 +932,12 @@ public interface IRetailPViewCellDrawCallbacks
{
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
/// <summary>#124: far-Z punch one look-in aperture (a clipped view polygon
/// of a looked-into building cell) — always the PUNCH variant regardless
/// of root kind (retail maxZ1; the root-keyed forceFarZ selector only
/// governs the MAIN frame's exit-portal masks).</summary>
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
@ -704,6 +977,11 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>#131/#132: the LATE landscape phase, per slice, after the #124
/// look-ins — outside-stage dynamics' meshes + all scene particles +
/// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView).</summary>
public Action<RetailPViewLandscapeLateSliceContext>? DrawLandscapeSliceLate { get; init; }
/// <summary>T1: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
@ -711,6 +989,13 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; }
/// <summary>#131: Scene-pass draw of UNATTACHED emitters
/// (AttachedObjectId == 0) for interior-root frames — invoked once at the
/// end of the landscape stage (pre-clear). Outdoor roots draw them via
/// GameWindow's dedicated post-frame pass instead.</summary>
public Action? DrawUnattachedSceneParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
@ -727,6 +1012,14 @@ public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities);
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics,
IReadOnlyList<WorldEntity> ParticleOwners);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,

View file

@ -46,10 +46,12 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
// attenuation inside Range; hard edge at Range; spotlights use a
// binary cos-cone test. This is deliberate — the retail "bubble of
// light" look relies on crisp boundaries.
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the
// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly
// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside
// Range / crisp boundaries" note was a misread; it is the literal cause of
// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3
// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test.
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
@ -73,7 +75,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
float atten = 1.0; // retail: no attenuation inside Range
// calc_point_light (1 - dist/falloff_eff) linear ramp; Range already
// carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff.
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
// Spotlight: hard-edged cos-cone test.
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);

View file

@ -49,7 +49,15 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
float atten = 1.0;
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
// line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
// LINEAR fade to exactly 0 at the edge. That is what makes a torch a
// smooth glow that blends into the ambient instead of a flat disc with
// a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
// falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
// into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
// denominator is just Range and fades to 0 exactly at the cutoff.
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);

View file

@ -283,6 +283,27 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// when wired, else the no-clip fallback (count 0 = ungated terrain).
BindClipUboBinding2();
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
// (0x006b7040) draws each land triangle ONLY when the camera is on the
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
// evaluates the same per-triangle eye-side predicate at rasterization.
// LandblockMesh emits every triangle CCW in world XY seen from above
// (LandblockMeshTests winding pin), which the unified camera chain
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
// winding from above / CW from below (TerrainCullOrientationTests) —
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
// underside. WB drew the whole world with culling DISABLED
// frame-globally (WB GameScene.cs:841 — an editor camera goes
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
// sheet through the exit-door aperture — the #108 grass window.
// Self-contained state per feedback_render_self_contained_gl_state;
// the frame-global CW + cull-off baseline is restored after the draw.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect(
@ -292,6 +313,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
(uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.Disable(EnableCap.CullFace);
}
public void Dispose()

View file

@ -62,6 +62,24 @@ namespace AcDream.App.Rendering.Wb {
public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty<VertexPositionNormalTexture>();
public List<MeshBatchData> Batches { get; set; } = new();
/// <summary>
/// #125 (2026-06-12): GL upload-retry counter. A failed
/// <see cref="ObjectMeshManager.UploadMeshData"/> (returns null from its
/// catch) used to be dropped permanently — the staged item was consumed,
/// no render data was produced, and the prepared data lingered in the CPU
/// cache where <c>PrepareMeshDataAsync</c>'s cache-hit short-circuit
/// returned it without ever re-staging it for upload (session-sticky
/// invisible mesh, one [wb-error] line). The drain loop now re-stages a
/// failed upload for the NEXT frame up to <see cref="ObjectMeshManager.
/// MaxUploadRetries"/> times. The counter lives on the mesh-data object so
/// it resets to 0 naturally whenever the id is re-prepared (fresh object),
/// and bounds a deterministic GL failure to a few loud lines instead of a
/// silent permanent drop OR an unbounded per-frame retry storm. Retail
/// loads content synchronously and has no such failure mode — this
/// converges our async pipeline toward that guarantee.
/// </summary>
public int UploadAttempts;
/// <summary>For EnvCell: the geometry of the cell itself.</summary>
public ObjectMeshData? EnvCellGeometry { get; set; }
@ -216,6 +234,32 @@ namespace AcDream.App.Rendering.Wb {
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
/// <summary>#125: how many times a failed GL upload is re-staged before
/// giving up loudly. Small — a transient GL error clears on the next
/// frame; anything that fails this many times is a genuine defect to
/// surface, not retry forever. See <see cref="ObjectMeshData.UploadAttempts"/>.</summary>
public const int MaxUploadRetries = 3;
/// <summary>
/// #125: drain one staged upload, returning whether it should be
/// re-staged for a later frame. The caller (the per-frame Tick drain)
/// collects the re-stages and re-enqueues them AFTER the drain loop —
/// never inside it — so a deterministic failure can't spin the queue in
/// a single frame. Increments the mesh-data's own attempt counter (resets
/// on re-prepare) and gives up loudly past <see cref="MaxUploadRetries"/>.
/// </summary>
public bool UploadOrRequeue(ObjectMeshData meshData) {
if (UploadMeshData(meshData) is not null)
return false; // success (incl. legitimate 0-vertex → empty render data)
if (HasRenderData(meshData.ObjectId))
return false; // raced to present by another path
meshData.UploadAttempts++;
if (meshData.UploadAttempts < MaxUploadRetries)
return true; // re-stage for next frame
Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)");
return false;
}
// Cache for decoded textures to avoid redundant BCn decoding
private readonly ConcurrentQueue<uint> _decodedTextureLru = new();
private readonly ConcurrentDictionary<uint, byte[]> _decodedTextureCache = new();

View file

@ -244,10 +244,21 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_disposed) return;
_graphicsDevice!.ProcessGLQueue();
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
// null from its catch) is re-staged for a LATER frame, not dropped. The
// re-stages are collected and re-enqueued AFTER the loop — re-enqueuing
// inside the while would let a deterministic failure spin the queue in a
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<ObjectMeshData>? requeue = null;
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{
_meshManager.UploadMeshData(meshData);
if (_meshManager.UploadOrRequeue(meshData))
(requeue ??= new()).Add(meshData);
}
if (requeue is not null)
foreach (var m in requeue)
_meshManager.StagedMeshData.Enqueue(m);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe

View file

@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
/// <summary>
/// Control job: drop every queued (not-yet-started) Load from the worker's
/// priority queues, keeping Unloads. Posted by
/// <see cref="LandblockStreamer.ClearPendingLoads"/> when the player enters a
/// dungeon and the in-flight outdoor/neighbor window load must be cancelled
/// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by
/// convention; readers pattern-match on the type.
/// </summary>
public sealed record ClearLoads() : LandblockStreamJob(0);
}
/// <summary>

View file

@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
}
/// <summary>
/// Cancel every queued-but-not-started Load. Posts a
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
/// honours at read time, dropping all pending Loads from both priority
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
/// complete; the StreamingController's collapsed-sweep unloads those few.
/// </summary>
public void ClearPendingLoads()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
}
/// <summary>
/// Drain up to <paramref name="maxBatchSize"/> completed results.
/// Non-blocking. Call from the render thread once per OnUpdate.
@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable
}
while (_inbox.Reader.TryRead(out var job))
{
if (job is LandblockStreamJob.ClearLoads)
{
// Dungeon-entry cancellation: drop every queued Load,
// keep Unloads. Handled at read time so it supersedes
// Loads sitting in the priority queues ahead of it.
DropLoadJobs(highPriority);
DropLoadJobs(lowPriority);
continue;
}
EnqueuePrioritized(job, highPriority, lowPriority);
}
if (highPriority.Count == 0 && lowPriority.Count == 0)
continue;
@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable
lowPriority.Enqueue(job);
}
/// <summary>
/// Drop every <see cref="LandblockStreamJob.Load"/> from a priority queue,
/// preserving Unloads (and any other control jobs). Rotates the queue once
/// in place. Used by the <see cref="LandblockStreamJob.ClearLoads"/> path.
/// </summary>
private static void DropLoadJobs(Queue<LandblockStreamJob> queue)
{
int count = queue.Count;
for (int i = 0; i < count; i++)
{
var job = queue.Dequeue();
if (job is not LandblockStreamJob.Load)
queue.Enqueue(job);
}
}
private static void RemoveLowPriorityJobsForLandblock(
Queue<LandblockStreamJob> queue,
uint landblockId,

View file

@ -22,9 +22,24 @@ public sealed class StreamingController
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly Action? _clearPendingLoads;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
// True while streaming is collapsed to the single dungeon landblock the
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
// are never visible, so we stop loading the 25×25 window entirely.
private bool _collapsed;
// The dungeon landblock id we collapsed onto. Once collapsed we key the
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
// expand on that flicker thrashes collapse↔expand (reload storms + a light
// leak). We only expand when the observer actually moves to a different
// landblock (teleport/portal out).
private uint _collapsedCenter;
/// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
@ -71,13 +86,15 @@ public sealed class StreamingController
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null)
Action<uint>? removeTerrain = null,
Action? clearPendingLoads = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
@ -97,7 +114,76 @@ public sealed class StreamingController
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
/// </list>
/// </summary>
public void Tick(int observerCx, int observerCy)
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed)
{
// Hysteresis. Cases:
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
// → re-collapse onto it.
// - CurrCell flickered null but the player hasn't gone anywhere: the
// observer landblock reverts to the position-derived value, which for a
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
// local Y). Hold — never expand on an adjacent flicker.
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
// from the ocean-grid dungeon block) → expand.
if (insideDungeon && centerId != _collapsedCenter)
EnterDungeonCollapse(observerCx, observerCy, centerId);
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
ExitDungeonExpand(observerCx, observerCy);
else
SweepCollapsed();
}
else if (insideDungeon)
{
EnterDungeonCollapse(observerCx, observerCy, centerId);
}
else
{
NormalTick(observerCx, observerCy);
}
DrainAndApply();
}
/// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
/// from the login / teleport spawn path the instant the streaming center is
/// recentered onto a SEALED dungeon landblock.
///
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
///
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
/// same as <see cref="Tick"/>.</para>
/// </summary>
public void PreCollapseToDungeon(int cx, int cy)
{
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
if (_collapsed && _collapsedCenter == centerId) return;
EnterDungeonCollapse(cx, cy, centerId);
}
/// <summary>
/// Outdoor / building-interior streaming — the original two-tier model.
/// </summary>
private void NormalTick(int observerCx, int observerCy)
{
if (_region is null)
{
@ -116,9 +202,88 @@ public sealed class StreamingController
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
}
}
// Drain up to N completions per frame so a big diff doesn't spike
// GPU upload time. Remaining completions wait for the next frame.
/// <summary>
/// Dungeon-entry edge: cancel the in-flight window load, unload every
/// resident neighbor, and pin streaming to the player's single dungeon
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
/// their thousands of emitters (#133 FPS). Unloading them also tears down
/// their lights, shrinking the static-light set toward retail's ≤40.
/// </summary>
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
{
_collapsed = true;
_collapsedCenter = centerId;
_clearPendingLoads?.Invoke();
foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) _enqueueUnload(id);
// Pin a radius-0 region so RecenterTo never re-expands while inside,
// and so the post-exit rebuild starts from a clean, consistent state.
_region = new StreamingRegion(cx, cy, 0, 0);
_region.MarkResidentFromBootstrap();
// The dungeon landblock itself must be (or become) loaded. If a prior
// ClearPendingLoads cancelled its queued load, re-enqueue it.
if (!_state.IsLoaded(centerId))
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
}
/// <summary>
/// While collapsed, unload any landblock that finished loading after the
/// collapse edge — a Load the worker had already dequeued before the
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
/// effect. At steady state only the dungeon landblock is resident, so this
/// is a no-op.
/// </summary>
private void SweepCollapsed()
{
// Always preserve the true dungeon landblock (_collapsedCenter), never the
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
foreach (var id in _state.LoadedLandblockIds)
if (id != _collapsedCenter) _enqueueUnload(id);
}
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
private static int ChebyshevLandblocks(uint a, uint b)
{
int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
}
/// <summary>
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
/// two-tier window at the new center and unload anything resident from the
/// collapsed state that falls outside it.
/// </summary>
private void ExitDungeonExpand(int observerCx, int observerCy)
{
_collapsed = false;
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
foreach (var id in _state.LoadedLandblockIds)
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
var boot = rebuilt.ComputeFirstTickDiff();
foreach (var id in boot.ToLoadNear)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap();
_region = rebuilt;
}
/// <summary>
/// Drain up to N completions per frame so a big diff doesn't spike GPU
/// upload time. Remaining completions wait for the next frame.
/// </summary>
private void DrainAndApply()
{
var drained = _drainCompletions(MaxCompletionsPerFrame);
foreach (var result in drained)
{

View file

@ -0,0 +1,105 @@
using System;
using System.Numerics;
namespace AcDream.App.World;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>The claim can never hydrate (e.g. an indoor cell id outside the dat's
/// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
/// demote rather than hold forever.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// G.3a (#133) — holds a teleport arrival in portal space until the destination
/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
/// unconditional snap in <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>The controller is pure: readiness and placement are injected delegates,
/// so it carries no GL / dat / network dependency and is fully unit-testable. The
/// player stays input-frozen while this is Holding because the GameWindow keeps
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>The timeout is a coarse frame count (not wall-clock) so the controller
/// needs no external clock; it is a loud safety net for a never-hydrating
/// destination, not a precise deadline.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _place; // (destPos, destCell, forced)
private readonly int _maxHoldFrames;
private Vector3 _destPos;
private uint _destCell;
private int _heldFrames;
public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
public TeleportArrivalController(
Func<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>Begin holding for a teleport arrival. Called from OnLivePositionUpdated
/// AFTER the streaming origin has been recentered on the destination landblock.
/// Re-calling with a fresh server position resets the hold (server-authoritative).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
public void Tick()
{
if (Phase != TeleportArrivalPhase.Holding) return;
_heldFrames++;
ArrivalReadiness verdict = _readiness(_destPos, _destCell);
if (verdict == ArrivalReadiness.Ready)
{
Place(forced: false);
return;
}
if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
{
Place(forced: true);
}
// else NotReady -> keep holding
}
private void Place(bool forced)
{
// Flip to Idle BEFORE invoking the placement delegate so the machine
// reflects "done holding" even if the delegate were to re-enter Tick.
Phase = TeleportArrivalPhase.Idle;
_place(_destPos, _destCell, forced);
}
}