fix(world+streaming): trees-in-sky Z, O(1) anim, teleport near-ring + immediate unloads
Three apparatus-confirmed fixes from the world-load/FPS deep-dive (all live-verified).
1. trees-in-sky — scenery ground-Z now samples THIS landblock's OWN heightmap
(TerrainSurface.SampleZFromHeightmap, lock-step with the physics terrain) instead
of the global PhysicsEngine.SampleTerrainZ query. At build time the landblock isn't
registered in physics yet, so that query could only return null OR a STALE
neighbour's height — the previous location's terrain, still registered after a
teleport recenter — planting scenery at the old altitude (+250..500m, confirmed via
the [scenery-z-stale] probe). Own-heightmap is correct in every case; the query is
removed. (GameWindow.BuildSceneryEntitiesForStreaming)
2. FPS per-hop — TickAnimations recovered each animated entity's server guid via an
O(N) ReferenceEquals reverse scan over ALL _entitiesByServerGuid (which never
evicts, so N climbs every teleport — the drops-with-each-hop sink). Replaced with
ae.Entity.ServerGuid: O(1), exact-equivalent (the dict key IS entity.ServerGuid).
(GameWindow.TickAnimations)
3. teleport arrival + bulk floating terrain — two streaming fixes:
- Near-ring eager-apply: a teleport applies the destination's 3x3 surroundings
(StreamingController.PriorityRadius) and holds the fade until they're resident
(PhysicsEngine.IsNeighborhoodTerrainResident), so the player arrives in a loaded,
collidable world instead of one landblock in the void.
- Immediate unloads: DrainAndApply no longer throttles UNLOADS at the per-frame
load budget — they're cheap (free GPU buffers, no upload). A teleport produced
~600 unloads draining at 4/frame, leaving the previous region resident for
seconds (floating terrain) and accumulating across rapid hops (951 resident vs a
625 window). Only GPU-upload LOADS are metered now. Cut out-of-window resident
650 -> 63 and resident 951 -> 688 (live-verified via [resid-audit]).
Includes gated-off diagnostic probes (ACDREAM_PROBE_SCENERY_FRAME / _BLDG_REACH /
_STREAM_RESID) used to root-cause the above — zero-cost when unset, same pattern as
the committed tp-probe.
The pre-existing teleport-induced "terrain arcs in the sky" (present in the dd2eb8b
baseline too, with NONE of this work) are a SEPARATE bug — investigated next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8fbde99441
commit
15e320490d
6 changed files with 382 additions and 61 deletions
|
|
@ -805,6 +805,18 @@ public sealed class GameWindow : IDisposable
|
||||||
private int _liveCenterY;
|
private int _liveCenterY;
|
||||||
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
|
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
|
||||||
|
|
||||||
|
// Trees-in-sky probe (2026-06-22 — REMOVABLE apparatus, see
|
||||||
|
// PhysicsDiagnostics.ProbeSceneryFrameEnabled). The streaming worker records
|
||||||
|
// here, per landblock, the _liveCenter it baked scenery against at BUILD time;
|
||||||
|
// ApplyLoadedTerrainLocked reads it back to compare against the APPLY-time
|
||||||
|
// center. Written/read only when the probe is on, so this stays empty in
|
||||||
|
// normal play.
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, (int Cx, int Cy)>
|
||||||
|
_sceneryBuildCenterByLandblock = new();
|
||||||
|
|
||||||
|
// Floating-terrain probe throttle (REMOVABLE, see PhysicsDiagnostics.ProbeStreamResidEnabled).
|
||||||
|
private int _residAuditTick;
|
||||||
|
|
||||||
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
|
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
|
||||||
// single-flag reads instead of env-var lookups. True iff
|
// single-flag reads instead of env-var lookups. True iff
|
||||||
// ACDREAM_LIVE=1 was set when the window came up.
|
// ACDREAM_LIVE=1 was set when the window came up.
|
||||||
|
|
@ -5476,8 +5488,14 @@ public sealed class GameWindow : IDisposable
|
||||||
_teleportHoldSeconds = 0f;
|
_teleportHoldSeconds = 0f;
|
||||||
_teleportForced = false;
|
_teleportForced = false;
|
||||||
if (_streamingController is not null)
|
if (_streamingController is not null)
|
||||||
|
{
|
||||||
_streamingController.PriorityLandblockId =
|
_streamingController.PriorityLandblockId =
|
||||||
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
|
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
|
||||||
|
// Eager-apply the destination's IMMEDIATE SURROUNDINGS (the near ring), not
|
||||||
|
// just the one landblock the player stands on — so they arrive in a loaded,
|
||||||
|
// collidable world instead of a single landblock in the void.
|
||||||
|
_streamingController.PriorityRadius = TeleportNearRingRadius;
|
||||||
|
}
|
||||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||||
"AIM", p.LandblockId,
|
"AIM", p.LandblockId,
|
||||||
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
|
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
|
||||||
|
|
@ -5504,6 +5522,14 @@ public sealed class GameWindow : IDisposable
|
||||||
// Now rarely fires because priority-apply makes residency fast.
|
// Now rarely fires because priority-apply makes residency fast.
|
||||||
private const float TeleportMaxHoldSeconds = 10f;
|
private const float TeleportMaxHoldSeconds = 10f;
|
||||||
|
|
||||||
|
// 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply
|
||||||
|
// (and to hold the fade for) on a teleport. radius 1 = the 3×3 around the destination —
|
||||||
|
// the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive
|
||||||
|
// standing on loaded ground with wall collision and a non-empty immediate view. The far
|
||||||
|
// ring (out to the streaming window) still drains at the per-frame budget after the fade
|
||||||
|
// lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade.
|
||||||
|
private const int TeleportNearRingRadius = 1;
|
||||||
|
|
||||||
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
|
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
|
||||||
// body's cell-relative CellPosition. This is the ONE place the streaming center
|
// body's cell-relative CellPosition. This is the ONE place the streaming center
|
||||||
// (_liveCenter) is allowed to touch the physics frame — at the placement seam,
|
// (_liveCenter) is allowed to touch the physics frame — at the placement seam,
|
||||||
|
|
@ -5523,10 +5549,14 @@ public sealed class GameWindow : IDisposable
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// worldReady for the TAS transit: is the player's teleport destination resident so we
|
/// worldReady for the TAS transit: is the player's teleport destination resident so we
|
||||||
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
|
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
|
||||||
/// struct hydrating (#135); outdoor gates on the destination terrain landblock being
|
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
|
||||||
/// registered (priority-applied). An impossible claim (indoor cell id outside the dat's
|
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
|
||||||
/// NumCells) returns true so the TAS stops holding and the forced placement surfaces the
|
/// single destination landblock — so the fade only lifts onto a loaded, collidable world
|
||||||
/// failure loudly rather than holding forever.
|
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
|
||||||
|
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
|
||||||
|
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
|
||||||
|
/// returns true so the TAS stops holding and the forced placement surfaces the failure
|
||||||
|
/// loudly rather than holding forever.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool TeleportWorldReady(uint destCell)
|
private bool TeleportWorldReady(uint destCell)
|
||||||
{
|
{
|
||||||
|
|
@ -5534,7 +5564,7 @@ public sealed class GameWindow : IDisposable
|
||||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||||
return indoor
|
return indoor
|
||||||
? _physicsEngine.IsSpawnCellReady(destCell)
|
? _physicsEngine.IsSpawnCellReady(destCell)
|
||||||
: _physicsEngine.IsLandblockTerrainResident(destCell);
|
: _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||||
|
|
@ -6003,6 +6033,13 @@ public sealed class GameWindow : IDisposable
|
||||||
(lbY - _liveCenterY) * 192f,
|
(lbY - _liveCenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
|
// Trees-in-sky probe (REMOVABLE): stamp the streaming center this scenery
|
||||||
|
// was baked against. ApplyLoadedTerrainLocked compares it to the apply-time
|
||||||
|
// center; a delta is the frame split that floats trees. Capture is the exact
|
||||||
|
// _liveCenter used in lbOffset above (the worker-thread read).
|
||||||
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeSceneryFrameEnabled)
|
||||||
|
_sceneryBuildCenterByLandblock[lb.LandblockId] = (_liveCenterX, _liveCenterY);
|
||||||
|
|
||||||
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
|
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
|
||||||
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
|
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
|
||||||
// Both must go into our ID so landblocks don't collide.
|
// Both must go into our ID so landblocks don't collide.
|
||||||
|
|
@ -6012,6 +6049,7 @@ public sealed class GameWindow : IDisposable
|
||||||
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
||||||
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
|
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
|
||||||
uint localIndex = 0;
|
uint localIndex = 0;
|
||||||
|
bool loggedStaleZ = false; // trees-in-sky probe: one [scenery-z-stale] line per lb
|
||||||
|
|
||||||
foreach (var spawn in spawns)
|
foreach (var spawn in spawns)
|
||||||
{
|
{
|
||||||
|
|
@ -6074,11 +6112,36 @@ public sealed class GameWindow : IDisposable
|
||||||
// fall back to the local bilinear sample.
|
// fall back to the local bilinear sample.
|
||||||
var worldPx = localX + lbOffset.X;
|
var worldPx = localX + lbOffset.X;
|
||||||
var worldPy = localY + lbOffset.Y;
|
var worldPy = localY + lbOffset.Y;
|
||||||
float? maybePhysicsZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy);
|
// FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS
|
||||||
float groundZ = maybePhysicsZ
|
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
|
||||||
?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
|
// (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48),
|
||||||
|
// scoped to the landblock being built. The former global
|
||||||
|
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
|
||||||
|
// build time this landblock is NOT registered in physics yet, so that query
|
||||||
|
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||||||
|
// height — the previous location's terrain, still registered after a teleport
|
||||||
|
// recenter (which drops only the single stale CENTER landblock, GameWindow
|
||||||
|
// :5444) until streaming unloads it — planting scenery at the old location's
|
||||||
|
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
||||||
|
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
|
||||||
|
// case, so the global query is removed (also drops its per-spawn cost).
|
||||||
|
float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
|
||||||
float finalZ = groundZ + spawn.LocalPosition.Z;
|
float finalZ = groundZ + spawn.LocalPosition.Z;
|
||||||
|
|
||||||
|
// Trees-in-sky probe (REMOVABLE — strip after verification). Proves the fix:
|
||||||
|
// compute what the OLD global physics query WOULD have returned and compare
|
||||||
|
// to the own-heightmap groundZ we now use. A large divergence is the stale-
|
||||||
|
// neighbor altitude the old code floated scenery to — and no longer does.
|
||||||
|
if (!loggedStaleZ
|
||||||
|
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeSceneryFrameEnabled
|
||||||
|
&& _physicsEngine.SampleTerrainZ(worldPx, worldPy) is { } wouldBePhysicsZ
|
||||||
|
&& System.MathF.Abs(wouldBePhysicsZ - groundZ) > 5f)
|
||||||
|
{
|
||||||
|
AcDream.Core.Physics.PhysicsDiagnostics.LogSceneryZStale(
|
||||||
|
lb.LandblockId, worldPx, worldPy, wouldBePhysicsZ, groundZ);
|
||||||
|
loggedStaleZ = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Issue #48 diagnostic. One log line per (spawn, rendered-mesh)
|
// Issue #48 diagnostic. One log line per (spawn, rendered-mesh)
|
||||||
// disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2
|
// disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2
|
||||||
// (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0).
|
// (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0).
|
||||||
|
|
@ -6086,7 +6149,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// line by world coords + gfx id, the data picks the hypothesis.
|
// line by world coords + gfx id, the data picks the hypothesis.
|
||||||
if (_options.DumpSceneryZ)
|
if (_options.DumpSceneryZ)
|
||||||
{
|
{
|
||||||
string source = maybePhysicsZ.HasValue ? "physics" : "bilinear";
|
// groundZ now always comes from THIS landblock's own heightmap (the
|
||||||
|
// global physics query was removed — see the trees-in-sky fix above).
|
||||||
|
string source = "heightmap";
|
||||||
foreach (var mr in meshRefs)
|
foreach (var mr in meshRefs)
|
||||||
{
|
{
|
||||||
var dgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
var dgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||||
|
|
@ -6627,6 +6692,33 @@ public sealed class GameWindow : IDisposable
|
||||||
(lbY - _liveCenterY) * 192f,
|
(lbY - _liveCenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
|
// Trees-in-sky probe (REMOVABLE): compare the streaming center this
|
||||||
|
// landblock's SCENERY was baked against (worker, build time) to the center
|
||||||
|
// its TERRAIN is being placed against here (render, apply time). A non-zero
|
||||||
|
// deltaLB is the frame split that floats scenery off its terrain.
|
||||||
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeSceneryFrameEnabled)
|
||||||
|
{
|
||||||
|
var bc = _sceneryBuildCenterByLandblock.TryGetValue(lb.LandblockId, out var c)
|
||||||
|
? c : (_liveCenterX, _liveCenterY);
|
||||||
|
AcDream.Core.Physics.PhysicsDiagnostics.LogSceneryFrame(
|
||||||
|
lb.LandblockId, bc.Item1, bc.Item2, _liveCenterX, _liveCenterY);
|
||||||
|
_sceneryBuildCenterByLandblock.TryRemove(lb.LandblockId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating-terrain probe (REMOVABLE): this landblock is being APPLIED at the
|
||||||
|
// current center; if its Chebyshev distance from the center exceeds the streaming
|
||||||
|
// window, it's an in-flight load from a PREVIOUS center applying against the new
|
||||||
|
// one — it will render far off at the horizon (a floating "terrain trace").
|
||||||
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeStreamResidEnabled
|
||||||
|
&& _streamingController is not null)
|
||||||
|
{
|
||||||
|
int applyCheby = System.Math.Max(
|
||||||
|
System.Math.Abs(lbX - _liveCenterX), System.Math.Abs(lbY - _liveCenterY));
|
||||||
|
if (applyCheby > _streamingController.FarRadius)
|
||||||
|
AcDream.Core.Physics.PhysicsDiagnostics.LogApplyOob(
|
||||||
|
lb.LandblockId, applyCheby, _streamingController.FarRadius, origin.X, origin.Y);
|
||||||
|
}
|
||||||
|
|
||||||
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
|
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
|
||||||
// two-tier entry point. Delegates to AddLandblock internally; both
|
// two-tier entry point. Delegates to AddLandblock internally; both
|
||||||
// paths share one GPU upload path.
|
// paths share one GPU upload path.
|
||||||
|
|
@ -7575,6 +7667,25 @@ public sealed class GameWindow : IDisposable
|
||||||
foreach (var entity in rescued)
|
foreach (var entity in rescued)
|
||||||
_worldState.AppendLiveEntity(centerLb, entity);
|
_worldState.AppendLiveEntity(centerLb, entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Floating-terrain probe (REMOVABLE): ~2×/sec, list resident landblocks sitting
|
||||||
|
// OUTSIDE the streaming window (Chebyshev > FarRadius from the current center) —
|
||||||
|
// stale old-location terrain that never unloaded. Same thread as the Tick above,
|
||||||
|
// so LoadedLandblockIds is stable. Zero cost when the probe is off.
|
||||||
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeStreamResidEnabled
|
||||||
|
&& (++_residAuditTick % 30 == 0))
|
||||||
|
{
|
||||||
|
var oob = new List<uint>();
|
||||||
|
foreach (var id in _worldState.LoadedLandblockIds)
|
||||||
|
{
|
||||||
|
int lx = (int)((id >> 24) & 0xFFu), ly = (int)((id >> 16) & 0xFFu);
|
||||||
|
if (System.Math.Max(System.Math.Abs(lx - _liveCenterX), System.Math.Abs(ly - _liveCenterY))
|
||||||
|
> _streamingController.FarRadius)
|
||||||
|
oob.Add(id);
|
||||||
|
}
|
||||||
|
AcDream.Core.Physics.PhysicsDiagnostics.LogResidentAudit(
|
||||||
|
_liveCenterX, _liveCenterY, _worldState.LoadedLandblockIds.Count, oob);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain pending live-session traffic AFTER streaming so any incoming
|
// Drain pending live-session traffic AFTER streaming so any incoming
|
||||||
|
|
@ -7615,7 +7726,10 @@ public sealed class GameWindow : IDisposable
|
||||||
case AcDream.Core.World.TeleportAnimEvent.Place:
|
case AcDream.Core.World.TeleportAnimEvent.Place:
|
||||||
PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
|
PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
|
||||||
if (_streamingController is not null)
|
if (_streamingController is not null)
|
||||||
|
{
|
||||||
_streamingController.PriorityLandblockId = 0u;
|
_streamingController.PriorityLandblockId = 0u;
|
||||||
|
_streamingController.PriorityRadius = 0;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete:
|
case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete:
|
||||||
if (_playerController is not null)
|
if (_playerController is not null)
|
||||||
|
|
@ -9247,14 +9361,18 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
var ae = kv.Value;
|
var ae = kv.Value;
|
||||||
|
|
||||||
// Locate the server guid for this entity once per tick — needed
|
// The server guid is carried on the entity itself
|
||||||
// for dead-reckoning. O(N) reverse lookup; for player populations
|
// (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at
|
||||||
// < 100 the cost is negligible.
|
// construction — see OnLiveEntitySpawnedLocked: `ServerGuid = spawn.Guid`
|
||||||
uint serverGuid = 0;
|
// and `_entitiesByServerGuid[spawn.Guid] = entity`). Read it directly —
|
||||||
foreach (var esg in _entitiesByServerGuid)
|
// O(1) — instead of the former reverse ReferenceEquals scan over ALL of
|
||||||
{
|
// _entitiesByServerGuid, which made this loop O(animated × all-entities).
|
||||||
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
|
// That super-linear cost is the FPS-drops-per-teleport sink: world
|
||||||
}
|
// entities accumulate without bound (pruned only on DeleteObject, and ACE
|
||||||
|
// does not re-send / clear KnownObjects across a teleport), so N climbs
|
||||||
|
// every hop. ServerGuid defaults to 0 for entities not server-tracked,
|
||||||
|
// exactly matching the old scan's miss case.
|
||||||
|
uint serverGuid = ae.Entity.ServerGuid;
|
||||||
|
|
||||||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,21 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint PriorityLandblockId { get; set; }
|
public uint PriorityLandblockId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-06-22: the radius (in landblocks, Chebyshev) around
|
||||||
|
/// <see cref="PriorityLandblockId"/> that <see cref="DrainAndApply"/> eager-applies
|
||||||
|
/// ahead of the per-frame budget. 0 (default) = only the single center landblock — the
|
||||||
|
/// original priority behaviour. A teleport sets this to the near ring so the player's
|
||||||
|
/// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not
|
||||||
|
/// just the one landblock they stand on. Without it, only the destination landblock
|
||||||
|
/// applies immediately and everything around it drains at <see cref="MaxCompletionsPerFrame"/>,
|
||||||
|
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
|
||||||
|
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
|
||||||
|
/// transient walk-through-walls). The far ring still drains at the budget. The eager
|
||||||
|
/// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden.
|
||||||
|
/// </summary>
|
||||||
|
public int PriorityRadius { get; set; }
|
||||||
|
|
||||||
// Completions that were drained past a priority item get buffered here
|
// Completions that were drained past a priority item get buffered here
|
||||||
// so they still apply over subsequent frames without loss.
|
// so they still apply over subsequent frames without loss.
|
||||||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||||||
|
|
@ -293,6 +308,17 @@ public sealed class StreamingController
|
||||||
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="id"/> is the priority center or within
|
||||||
|
/// <see cref="PriorityRadius"/> landblocks of it (Chebyshev) — i.e. inside the teleport
|
||||||
|
/// near ring that <see cref="DrainAndApply"/> eager-applies. With the default radius 0
|
||||||
|
/// this reduces to an exact match on <see cref="PriorityLandblockId"/> (the original
|
||||||
|
/// single-landblock priority behaviour).
|
||||||
|
/// </summary>
|
||||||
|
private bool IsWithinPriorityRing(uint id)
|
||||||
|
=> PriorityLandblockId != 0u
|
||||||
|
&& ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
||||||
/// two-tier window at the new center and unload anything resident from the
|
/// two-tier window at the new center and unload anything resident from the
|
||||||
|
|
@ -319,62 +345,49 @@ public sealed class StreamingController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
/// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the
|
||||||
/// upload time. Remaining completions wait for the next frame.
|
/// expensive part, so they are metered at <see cref="MaxCompletionsPerFrame"/> to avoid a
|
||||||
///
|
/// GPU-upload spike; the overflow buffers in <see cref="_deferredApply"/> and drains over
|
||||||
/// <para>
|
/// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied
|
||||||
/// When <see cref="PriorityLandblockId"/> is set (non-zero), the priority
|
/// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and
|
||||||
/// landblock is applied immediately even if it sits past position N in the
|
/// metering them at the load rate left the previous location's terrain resident for seconds
|
||||||
/// outbox. Non-priority completions drained past it are buffered in
|
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
|
||||||
/// <see cref="_deferredApply"/> and applied over subsequent frames
|
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
|
||||||
/// (no loss, no GPU spike).
|
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
|
||||||
/// </para>
|
/// (<see cref="PriorityRadius"/>, applied behind the fade) likewise bypass the budget so the
|
||||||
|
/// player materialises in a loaded world.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void DrainAndApply()
|
private void DrainAndApply()
|
||||||
{
|
{
|
||||||
// --- Step 1: priority hunt FIRST and UNCONDITIONALLY (when a destination is set).
|
// --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring
|
||||||
// The teleport destination must apply the frame the worker finishes building it,
|
// loads immediately; defer every other (budget-metered) load. Draining the whole
|
||||||
// ahead of any deferred/outbox backlog — otherwise a big dungeon-exit expand
|
// outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush
|
||||||
// (hundreds of completions) buries it and the arrival times out into the skybox.
|
// promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on
|
||||||
// CRITICAL: this must NOT be gated behind the per-frame budget. The prior version
|
// freeing them. The drain cap must NOT be gated behind the per-frame load budget
|
||||||
// ran the deferred drain first and returned when the budget hit 0, so once
|
// (the prior version returned once the budget hit 0, stranding the outbox).
|
||||||
// _deferredApply held >= MaxCompletionsPerFrame items the hunt (and the outbox
|
const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
|
||||||
// drain) were skipped entirely — the destination never priority-applied. The hunt
|
int iter = 0;
|
||||||
// drains the outbox in bounded chunks; the priority is applied on match, every
|
while (iter++ < MaxDrainIterations)
|
||||||
// non-priority item is buffered in _deferredApply (no loss).
|
|
||||||
if (PriorityLandblockId != 0u)
|
|
||||||
{
|
|
||||||
const int MaxHuntIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
|
|
||||||
int hunted = 0;
|
|
||||||
while (hunted < MaxHuntIterations)
|
|
||||||
{
|
{
|
||||||
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||||||
if (chunk.Count == 0) break;
|
if (chunk.Count == 0) break;
|
||||||
foreach (var result in chunk)
|
foreach (var result in chunk)
|
||||||
{
|
{
|
||||||
hunted++;
|
if (result is LandblockStreamResult.Unloaded
|
||||||
if (ResultLandblockId(result) == PriorityLandblockId)
|
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||||
ApplyResult(result); // applied immediately, ahead of the budget
|
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||||||
else
|
else
|
||||||
_deferredApply.Add(result);
|
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 2: apply up to MaxCompletionsPerFrame this frame — the deferred backlog
|
// --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so
|
||||||
// first (FIFO), then fresh outbox completions. Caps GPU upload per frame so a big
|
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
|
||||||
// diff doesn't spike. While a priority is set, Step 1 has already moved the outbox
|
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
|
||||||
// into _deferredApply, so this drains that backlog at the budget rate.
|
|
||||||
int budget = MaxCompletionsPerFrame;
|
int budget = MaxCompletionsPerFrame;
|
||||||
int i = 0;
|
int i = 0;
|
||||||
while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; }
|
while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; }
|
||||||
if (i > 0) _deferredApply.RemoveRange(0, i);
|
if (i > 0) _deferredApply.RemoveRange(0, i);
|
||||||
budget -= i;
|
|
||||||
if (budget <= 0) return;
|
|
||||||
|
|
||||||
var drained = _drainCompletions(budget);
|
|
||||||
foreach (var result in drained)
|
|
||||||
ApplyResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -859,6 +859,27 @@ public static class CellTransit
|
||||||
if (PhysicsDiagnostics.ProbeCellSetEnabled)
|
if (PhysicsDiagnostics.ProbeCellSetEnabled)
|
||||||
PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates);
|
PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates);
|
||||||
|
|
||||||
|
// Lost-collision probe (REMOVABLE): in a landblock that HAS cached buildings,
|
||||||
|
// report which of them are reachable from the player's swept candidate set.
|
||||||
|
// reachable=NO while standing at a wall ⇒ the building IS cached but the
|
||||||
|
// post-teleport cell resolution excludes its landcell (#145); empty cachedInLb
|
||||||
|
// ⇒ the landblock's CacheBuilding never ran. One line per walk, near buildings.
|
||||||
|
if (PhysicsDiagnostics.ProbeBuildingReachEnabled)
|
||||||
|
{
|
||||||
|
uint prefix = currentCellId & 0xFFFF0000u;
|
||||||
|
var cachedInLb = new List<uint>();
|
||||||
|
foreach (var bid in cache.BuildingIds)
|
||||||
|
if ((bid & 0xFFFF0000u) == prefix) cachedInLb.Add(bid);
|
||||||
|
var inSet = new List<uint>();
|
||||||
|
for (int ci = 0; ci < candidates.Count; ci++)
|
||||||
|
{
|
||||||
|
uint cid = candidates.OrderedIds[ci];
|
||||||
|
if ((cid & 0xFFFFu) < 0x0100u && cache.GetBuilding(cid) is not null)
|
||||||
|
inSet.Add(cid);
|
||||||
|
}
|
||||||
|
PhysicsDiagnostics.LogBuildingReach(currentCellId, candidates.Count, cachedInLb, inSet);
|
||||||
|
}
|
||||||
|
|
||||||
// THE PICK — verbatim CObjCell::find_cell_list containing-cell pick
|
// THE PICK — verbatim CObjCell::find_cell_list containing-cell pick
|
||||||
// (pseudo_c:308788-308825): iterate the array IN ORDER from index 0; for each
|
// (pseudo_c:308788-308825): iterate the array IN ORDER from index 0; for each
|
||||||
// cell, point_in_cell; set the running result on ANY containing cell;
|
// cell, point_in_cell; set the running result on ANY containing cell;
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,12 @@ public sealed class PhysicsDataCache
|
||||||
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform,
|
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform,
|
||||||
uint modelId = 0u)
|
uint modelId = 0u)
|
||||||
{
|
{
|
||||||
|
// Lost-collision probe (REMOVABLE): record every cache attempt + whether it
|
||||||
|
// actually took (first-wins) so we can see if/when a landblock's buildings
|
||||||
|
// get cached after a teleport.
|
||||||
|
AcDream.Core.Physics.PhysicsDiagnostics.LogBuildingCache(
|
||||||
|
landcellId, cached: !_buildings.ContainsKey(landcellId));
|
||||||
|
|
||||||
if (_buildings.ContainsKey(landcellId)) return;
|
if (_buildings.ContainsKey(landcellId)) return;
|
||||||
Matrix4x4.Invert(worldTransform, out var inverse);
|
Matrix4x4.Invert(worldTransform, out var inverse);
|
||||||
_buildings[landcellId] = new BuildingPhysics
|
_buildings[landcellId] = new BuildingPhysics
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,138 @@ public static class PhysicsDiagnostics
|
||||||
$"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}"));
|
$"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trees-in-sky frame-split probe (2026-06-22 — REMOVABLE apparatus). The
|
||||||
|
/// streaming worker bakes each landblock's SCENERY world position against the
|
||||||
|
/// <c>_liveCenter</c> that is current when it BUILDS the landblock, while the
|
||||||
|
/// render thread positions that landblock's TERRAIN against the
|
||||||
|
/// <c>_liveCenter</c> that is current when it APPLIES it. A teleport recenters
|
||||||
|
/// <c>_liveCenter</c> between those two moments, so a landblock built before the
|
||||||
|
/// recenter and applied after it has its scenery offset from its terrain by
|
||||||
|
/// <c>deltaLB × 192m</c> in XY — the "arc of trees floating in the sky" after a
|
||||||
|
/// teleport. When enabled, <c>BuildSceneryEntitiesForStreaming</c> records the
|
||||||
|
/// build-time center and <c>ApplyLoadedTerrainLocked</c> emits one
|
||||||
|
/// <c>[scenery-frame]</c> line per landblock comparing build vs apply center; a
|
||||||
|
/// non-zero <c>deltaLB</c> on a misplaced landblock confirms the mechanism.
|
||||||
|
/// Initial state from <c>ACDREAM_PROBE_SCENERY_FRAME=1</c>. Zero cost when off.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProbeSceneryFrameEnabled { get; set; } =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SCENERY_FRAME") == "1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One <c>[scenery-frame]</c> line. Self-guards on
|
||||||
|
/// <see cref="ProbeSceneryFrameEnabled"/>. A non-zero <c>deltaLB</c> means this
|
||||||
|
/// landblock's scenery was baked against a different streaming center than its
|
||||||
|
/// terrain was applied against — the trees-in-sky frame split.
|
||||||
|
/// </summary>
|
||||||
|
public static void LogSceneryFrame(
|
||||||
|
uint landblockId, int buildCx, int buildCy, int applyCx, int applyCy)
|
||||||
|
{
|
||||||
|
if (!ProbeSceneryFrameEnabled) return;
|
||||||
|
int dx = buildCx - applyCx, dy = buildCy - applyCy;
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[scenery-frame] lb=0x{landblockId:X8} build=({buildCx},{buildCy}) apply=({applyCx},{applyCy}) deltaLB=({dx},{dy}) offsetM=({dx * 192f:F1},{dy * 192f:F1})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One <c>[scenery-z-stale]</c> line (self-guards on
|
||||||
|
/// <see cref="ProbeSceneryFrameEnabled"/>). Emitted when, at scenery build, the
|
||||||
|
/// physics-engine terrain sample (<c>SampleTerrainZ</c>, which searches ALL
|
||||||
|
/// registered landblocks) disagrees materially with the landblock's OWN
|
||||||
|
/// heightmap. That divergence means the physics query resolved the world point
|
||||||
|
/// into a STALE neighbor landblock — the previous location's terrain, still
|
||||||
|
/// registered after a teleport recenter (which removes only the single stale
|
||||||
|
/// center landblock) until streaming unloads it — and returned ITS height. The
|
||||||
|
/// scenery is then planted at the old location's altitude: trees-in-sky. The
|
||||||
|
/// own-heightmap value (<paramref name="ownZ"/>) is the correct Z.
|
||||||
|
/// </summary>
|
||||||
|
public static void LogSceneryZStale(
|
||||||
|
uint landblockId, float worldX, float worldY, float physicsZ, float ownZ)
|
||||||
|
{
|
||||||
|
if (!ProbeSceneryFrameEnabled) return;
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[scenery-z-stale] lb=0x{landblockId:X8} world=({worldX:F1},{worldY:F1}) physicsZ={physicsZ:F2} ownZ={ownZ:F2} deltaZ={physicsZ - ownZ:F2}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lost-building-collision probe (2026-06-22 — REMOVABLE apparatus). Building
|
||||||
|
/// WALL collision is the dat-baked <c>PhysicsDataCache.CacheBuilding</c> channel
|
||||||
|
/// (first-wins, never removed), reached at resolve time only when a candidate
|
||||||
|
/// landcell in the player's swept cell-walk hits <c>GetBuilding(cellId)</c>. When
|
||||||
|
/// enabled: <c>[bldg-cache]</c> logs each CacheBuilding (CACHED vs first-wins
|
||||||
|
/// skip) so we can see whether/when a landblock's buildings cache; and
|
||||||
|
/// <c>[bldg-reach]</c> logs, per cell-walk in a landblock that HAS cached
|
||||||
|
/// buildings, which of them landed in the player's candidate set
|
||||||
|
/// (<c>reachable=YES/NO</c>). NO while standing at a wall ⇒ the building is cached
|
||||||
|
/// but the player's post-teleport cell resolution excludes it (#145 family);
|
||||||
|
/// empty <c>cachedInLb</c> ⇒ the landblock's CacheBuilding never ran (apply
|
||||||
|
/// backlog). Initial state from <c>ACDREAM_PROBE_BLDG_REACH=1</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProbeBuildingReachEnabled { get; set; } =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_BLDG_REACH") == "1";
|
||||||
|
|
||||||
|
public static void LogBuildingCache(uint landcellId, bool cached)
|
||||||
|
{
|
||||||
|
if (!ProbeBuildingReachEnabled) return;
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[bldg-cache] landcell=0x{landcellId:X8} {(cached ? "CACHED" : "skip(first-wins)")}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LogBuildingReach(
|
||||||
|
uint seedCell, int candidateCount,
|
||||||
|
IReadOnlyCollection<uint> cachedInLb, IReadOnlyCollection<uint> inCandidateSet)
|
||||||
|
{
|
||||||
|
if (!ProbeBuildingReachEnabled) return;
|
||||||
|
if (cachedInLb.Count == 0) return; // only interesting in a landblock with buildings
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[bldg-reach] seed=0x{seedCell:X8} cands={candidateCount} cachedInLb=[{HexList(cachedInLb)}] inSet=[{HexList(inCandidateSet)}] reachable={(inCandidateSet.Count > 0 ? "YES" : "NO")}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HexList(IReadOnlyCollection<uint> ids)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
bool first = true;
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
if (!first) sb.Append(',');
|
||||||
|
sb.Append(System.FormattableString.Invariant($"0x{id:X8}"));
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Floating-terrain probe (2026-06-22 — REMOVABLE apparatus). The thin terrain
|
||||||
|
/// "traces" at the horizon after rapid multi-hop teleporting are landblocks rendering
|
||||||
|
/// at a STALE world position — their Chebyshev distance from the current streaming
|
||||||
|
/// center exceeds the window (FarRadius), so their world origin is off by the teleport
|
||||||
|
/// distance. When enabled: <c>[apply-oob]</c> fires when a landblock is APPLIED while
|
||||||
|
/// out-of-window (an in-flight load from a PREVIOUS center, applied against the new one);
|
||||||
|
/// <c>[resid-audit]</c> (throttled) lists every RESIDENT landblock sitting out-of-window
|
||||||
|
/// (un-cleared old-location terrain that never unloaded). A persistent non-zero
|
||||||
|
/// <c>outOfWindow</c> count is the floating terrain; whether it arrives via apply-oob or
|
||||||
|
/// lingers as residents tells us which mechanism. Initial state from
|
||||||
|
/// <c>ACDREAM_PROBE_STREAM_RESID=1</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProbeStreamResidEnabled { get; set; } =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_STREAM_RESID") == "1";
|
||||||
|
|
||||||
|
public static void LogApplyOob(uint landblockId, int cheby, int farRadius, float ox, float oy)
|
||||||
|
{
|
||||||
|
if (!ProbeStreamResidEnabled) return;
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[apply-oob] lb=0x{landblockId:X8} cheby={cheby} (>{farRadius}) origin=({ox:F0},{oy:F0}) — applied OUTSIDE the streaming window (in-flight stale-location apply)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LogResidentAudit(
|
||||||
|
int centerX, int centerY, int residentTotal, IReadOnlyCollection<uint> outOfWindow)
|
||||||
|
{
|
||||||
|
if (!ProbeStreamResidEnabled) return;
|
||||||
|
if (outOfWindow.Count == 0) return; // only interesting when stale terrain is resident
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[resid-audit] center=({centerX},{centerY}) resident={residentTotal} outOfWindow={outOfWindow.Count} oob=[{HexList(outOfWindow)}]"));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A6.P3 issue #98 step-walk investigation (2026-05-23). When true,
|
/// A6.P3 issue #98 step-walk investigation (2026-05-23). When true,
|
||||||
/// emits one <c>[step-walk]</c> line at selected points in the transition
|
/// emits one <c>[step-walk]</c> line at selected points in the transition
|
||||||
|
|
@ -575,6 +707,9 @@ public static class PhysicsDiagnostics
|
||||||
ProbeSweptEnabled = false;
|
ProbeSweptEnabled = false;
|
||||||
ProbeStepWalkEnabled = false;
|
ProbeStepWalkEnabled = false;
|
||||||
ProbeTeleportEnabled = false;
|
ProbeTeleportEnabled = false;
|
||||||
|
ProbeSceneryFrameEnabled = false;
|
||||||
|
ProbeBuildingReachEnabled = false;
|
||||||
|
ProbeStreamResidEnabled = false;
|
||||||
|
|
||||||
// Side-channel fields
|
// Side-channel fields
|
||||||
LastBspHitPoly = null;
|
LastBspHitPoly = null;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,34 @@ public sealed class PhysicsEngine
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once EVERY in-bounds landblock within Chebyshev <paramref name="radius"/> of the
|
||||||
|
/// landblock covering <paramref name="cellOrLandblockId"/> has had its terrain registered.
|
||||||
|
/// This is the teleport "surroundings are loaded" gate: holding the fade until the player's
|
||||||
|
/// own landblock AND its immediate neighbours are resident means they arrive standing on a
|
||||||
|
/// loaded, collidable world (their cell-walk can root into neighbour cells) instead of a
|
||||||
|
/// single landblock floating in the void. Off-map neighbours (coords outside 0..254) are
|
||||||
|
/// skipped — they never load, so requiring them would hang the hold until the wall-clock
|
||||||
|
/// timeout. radius 0 is equivalent to <see cref="IsLandblockTerrainResident"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius)
|
||||||
|
{
|
||||||
|
var resident = new HashSet<uint>();
|
||||||
|
foreach (var key in _landblocks.Keys) resident.Add(key & 0xFFFF0000u);
|
||||||
|
|
||||||
|
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
|
||||||
|
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
|
||||||
|
for (int dx = -radius; dx <= radius; dx++)
|
||||||
|
for (int dy = -radius; dy <= radius; dy++)
|
||||||
|
{
|
||||||
|
int nx = cx + dx, ny = cy + dy;
|
||||||
|
if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip
|
||||||
|
uint prefix = ((uint)nx << 24) | ((uint)ny << 16);
|
||||||
|
if (!resident.Contains(prefix)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cell-based spatial index for static object collision.
|
/// Cell-based spatial index for static object collision.
|
||||||
/// Populated during landblock streaming; queried by the Transition system.
|
/// Populated during landblock streaming; queried by the Transition system.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue