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:
commit
5ac9d8c19c
53 changed files with 6691 additions and 439 deletions
|
|
@ -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
|
||||
// 10–20 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue