fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -2714,8 +2714,8 @@ public sealed class GameWindow : IDisposable
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
@ -2743,10 +2743,12 @@ public sealed class GameWindow : IDisposable
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_envCellRenderer?.RemoveLandblock(id); // Phase A8
},
demoteNearLayer: DemoteLoadedLandblockToTerrain,
// #138: restore retained server objects when a landblock reloads
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it thinks we still know, so we re-project them ourselves.
onLandblockLoaded: RehydrateServerEntitiesForLandblock);
onLandblockLoaded: RehydrateServerEntitiesForLandblock,
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -7058,14 +7060,12 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// 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
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
/// single destination landblock — so portal space exits onto a loaded, collidable world
/// (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)
/// worldReady for the TAS transit: is the player's teleport destination BOTH rendered
/// and collidable so we can materialize? Retail crosses this edge after its blocking cell
/// load. acdream's async equivalent must join the render publication barrier
/// (<see cref="StreamingController.IsRenderNeighborhoodResident"/>) with physics
/// residency. Indoor destinations require the center render landblock + EnvCell; outdoor
/// destinations require both domains for the priority near ring. An impossible claim
/// returns true so the TAS stops holding and the forced placement surfaces the failure
/// loudly rather than holding forever.
/// </summary>
@ -7073,9 +7073,14 @@ public sealed class GameWindow : IDisposable
{
if (IsSpawnClaimUnhydratable(destCell)) return true;
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
int renderRadius = indoor ? 0 : TeleportNearRingRadius;
bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
destCell,
renderRadius) == true;
return indoor
? _physicsEngine.IsSpawnCellReady(destCell)
: _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
: renderReady
&& _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
@ -7560,6 +7565,13 @@ public sealed class GameWindow : IDisposable
completedEnvCells);
}
private void EnsureEnvCellMeshesAfterPin(
AcDream.App.Rendering.Wb.EnvCellLandblockBuild build)
{
if (_wbMeshAdapter?.MeshManager is { } meshManager)
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager);
}
/// <summary>
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
@ -7682,15 +7694,13 @@ public sealed class GameWindow : IDisposable
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
// 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.
// Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
// 256 slots per landblock is enough (SceneryGenerator caps ~200).
// Per-landblock id namespace. The top nibble identifies procedural
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
// DAT-generated landblocks from overflowing into a neighbour's range.
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
uint localIndex = 0;
uint sceneryCounter = 0;
foreach (var spawn in spawns)
{
@ -7836,13 +7846,12 @@ public sealed class GameWindow : IDisposable
}
}
if (localIndex > 0xFFu)
throw new InvalidDataException(
$"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace.");
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdBase + localIndex++,
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
lbXByte,
lbYByte,
ref sceneryCounter),
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
Rotation = spawn.Rotation,
@ -8135,6 +8144,31 @@ public sealed class GameWindow : IDisposable
return result;
}
/// <summary>
/// Retire the Near-only presentation and collision layer while preserving
/// the terrain slot/surface used by Far streaming. StreamingController calls
/// this before GpuWorldState drops static entities so owner-based resources
/// can be released exactly once.
/// </summary>
private void DemoteLoadedLandblockToTerrain(uint landblockId)
{
if (_worldState.TryGetLandblock(landblockId, out var landblock))
{
foreach (var entity in landblock!.Entities)
{
if (entity.ServerGuid != 0)
continue;
_lightingSink?.UnregisterOwner(entity.Id);
_translucencyFades.ClearEntity(entity.Id);
}
}
_physicsEngine.DemoteLandblockToTerrain(landblockId);
_cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu);
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
_envCellRenderer?.RemoveLandblock(landblockId);
}
/// <summary>
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
/// whenever a new landblock's terrain + entities are ready for GPU upload.
@ -8568,7 +8602,7 @@ public sealed class GameWindow : IDisposable
// 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP
// collision covers the whole structure; the mesh-AABB-fallback
// path below is for canopy-only-BSP procedural scenery
// (0x80XXYY00+n) and produces a redundant 1.5m-clamped
// (0x8XXYYIII) and produces a redundant 1.5m-clamped
// invisible disc at the stab's mesh origin — the user-reported
// "thin air" collision inside cottages. Gate the fallback to
// exclude stabs. Spec:
@ -10746,20 +10780,15 @@ public sealed class GameWindow : IDisposable
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
continue;
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
// no PartArray animation and no physics integration, but
// PositionManager::adjust_offset plus the manager tail still run.
// Scripts and particles tick in their shared passes below.
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
{
// Hidden suppresses the PartArray/mesh, not the effect owner.
// Remote PositionManager composition already ran in the
// unconditional live-entity pass; publish again for the local
// player and for retained objects without remote motion.
// Frozen part-local poses remain unchanged until UnHide.
_effectPoses.UpdateRoot(ae.Entity);
continue;
}
// Retail Hidden suppresses PartArray TIME ADVANCE, not part-pose
// composition. set_hidden -> HandleEnterWorld can move CSequence's
// cursor from a finished link/action to the first cyclic node; the
// next hidden CPhysicsObj::UpdateObjectInternal (0x005156B0)
// performs UpdatePositionInternal and then reaches set_frame ->
// CPartArray::UpdateParts, publishing that newly-current pose before
// the zero-time Hidden PES creates its silhouette particles.
bool hidden = serverGuid != 0
&& _liveEntities?.IsHidden(serverGuid) == true;
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant
@ -10774,7 +10803,8 @@ public sealed class GameWindow : IDisposable
// runaway when the sequencer's velocity and the server's reality
// disagree (e.g. server is rubber-banding the entity). Retail
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
if (ae.Sequencer is not null
if (!hidden
&& ae.Sequencer is not null
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
@ -10839,7 +10869,9 @@ public sealed class GameWindow : IDisposable
rmDiag.LastSeqStateLogTime = nowSec;
}
}
seqFrames = ae.Sequencer.Advance(dt);
seqFrames = hidden
? ae.Sequencer.SampleCurrentPose()
: ae.Sequencer.Advance(dt);
// Capture hooks now, but deliver them only after every final
// part transform and equipped-child root has been published.
@ -10847,21 +10879,25 @@ public sealed class GameWindow : IDisposable
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
if (!hidden)
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
else
{
// Legacy path (entities without a MotionTable / sequencer).
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
ae.CurrFrame += dt * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
if (span <= 0 && !hidden) continue;
if (!hidden)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
ae.CurrFrame += dt * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
int partCount = ae.PartTemplate.Count;
@ -12357,8 +12393,8 @@ public sealed class GameWindow : IDisposable
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
@ -12381,7 +12417,9 @@ public sealed class GameWindow : IDisposable
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_envCellRenderer?.RemoveLandblock(id); // Phase A8
});
},
demoteNearLayer: DemoteLoadedLandblockToTerrain,
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
Console.WriteLine(