fix(portal): synchronize destination presentation state
This commit is contained in:
parent
4b1bceefbb
commit
e95f55f25b
42 changed files with 2815 additions and 288 deletions
|
|
@ -566,9 +566,7 @@ public sealed class AnimationSequencer
|
|||
if (_core.CurrAnim == null && rootMotionFrame is null)
|
||||
return BuildIdentityFrame(partCount);
|
||||
if (dt <= 0f)
|
||||
return _core.CurrAnim == null
|
||||
? BuildIdentityFrame(partCount)
|
||||
: BuildBlendedFrame();
|
||||
return SampleCurrentPose();
|
||||
|
||||
_core.Update(dt, rootMotionFrame);
|
||||
|
||||
|
|
@ -577,6 +575,19 @@ public sealed class AnimationSequencer
|
|||
: BuildBlendedFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples the current sequence frame without advancing animation time or
|
||||
/// dispatching hooks. This is retail's hidden-object
|
||||
/// <c>CPhysicsObj::set_frame -> CPartArray::SetFrame -> UpdateParts</c>
|
||||
/// path: <c>HandleEnterWorld</c> may move the sequence cursor from a completed
|
||||
/// link to the cyclic tail, and the hidden object must recompose that new pose
|
||||
/// while PartArray time remains frozen.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PartTransform> SampleCurrentPose()
|
||||
=> _core.CurrAnim == null
|
||||
? BuildIdentityFrame(_setup.Parts.Count)
|
||||
: BuildBlendedFrame();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve and clear the list of hooks that fired since the last call.
|
||||
/// Empty when no frame boundary was crossed. Safe to call multiple
|
||||
|
|
|
|||
|
|
@ -200,19 +200,63 @@ public sealed class PhysicsBody
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the position and full cell returned by a successful transition.
|
||||
/// Retail <c>CPhysicsObj::SetPositionInternal(CTransition const*)</c>
|
||||
/// (<c>0x00515330</c>) always commits both
|
||||
/// <c>sphere_path.curr_pos.objcell_id</c> and its frame, including EnvCells.
|
||||
/// The ordinary <see cref="Position"/> setter first carries the accepted world
|
||||
/// displacement into the landblock-relative frame; this method then adopts the
|
||||
/// resolver's exact cell identity. Outdoor frames are canonicalized across 24 m
|
||||
/// cells and 192 m landblock boundaries. Indoor frames retain their carried
|
||||
/// landblock-relative origin and adopt the resolved EnvCell id.
|
||||
/// </summary>
|
||||
public void CommitTransitionPosition(uint resolvedCellId, Vector3 worldPosition)
|
||||
{
|
||||
Position = worldPosition;
|
||||
|
||||
if (CellPosition.ObjCellId == 0 || resolvedCellId == 0)
|
||||
return;
|
||||
|
||||
uint cell = resolvedCellId;
|
||||
Vector3 local = CellPosition.Frame.Origin;
|
||||
if ((cell & 0xFFFFu) is >= 1u and <= 0x40u
|
||||
&& !LandDefs.AdjustToOutside(ref cell, ref local))
|
||||
{
|
||||
// At the map edge retail's transition does not yield a successful
|
||||
// outside cell. Preserve the carried frame instead of inventing one.
|
||||
return;
|
||||
}
|
||||
|
||||
CellPosition = new Position(
|
||||
cell,
|
||||
new CellFrame(local, CellPosition.Frame.Orientation));
|
||||
}
|
||||
|
||||
// Mirror a world-position translation into the cell-relative frame. Velocity is
|
||||
// frame-invariant under translation, so the same delta applies to the local origin.
|
||||
// AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m
|
||||
// cell crossings) AND wraps + bumps the landblock on a 192 m crossing — the outdoor
|
||||
// membership + canonicalization in one call (get_outside_lcoord + lcoord_to_gid +
|
||||
// [0,192) wrap). Idempotent within a cell. Runs only for a SEEDED OUTDOOR cell;
|
||||
// unseeded bodies (ObjCellId==0, e.g. remote entities) and indoor cells are skipped.
|
||||
// [0,192) wrap). Idempotent within a cell. A SEEDED indoor body carries the same
|
||||
// landblock-relative delta but keeps its EnvCell identity until the transition result
|
||||
// commits the exact destination via CommitTransitionPosition. Unseeded bodies are skipped.
|
||||
private void SyncCellPositionDelta(Vector3 delta)
|
||||
{
|
||||
uint cell = CellPosition.ObjCellId;
|
||||
if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
|
||||
if (cell == 0)
|
||||
return;
|
||||
|
||||
Vector3 local = CellPosition.Frame.Origin + delta;
|
||||
|
||||
if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
|
||||
{
|
||||
CellPosition = new Position(
|
||||
cell,
|
||||
new CellFrame(local, CellPosition.Frame.Orientation));
|
||||
return;
|
||||
}
|
||||
|
||||
uint adjusted = cell;
|
||||
if (LandDefs.AdjustToOutside(ref adjusted, ref local))
|
||||
CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation));
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ public sealed class PhysicsEngine
|
|||
public void RemoveLandblock(uint landblockId)
|
||||
{
|
||||
_landblocks.Remove(landblockId);
|
||||
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
|
||||
ShadowObjects.RemoveLandblock(landblockId);
|
||||
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply
|
||||
|
||||
|
|
@ -141,6 +142,41 @@ public sealed class PhysicsEngine
|
|||
DataCache?.CellGraph.RemoveLandblock(landblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retire a Near landblock's indoor-cell and static-object collision layer
|
||||
/// while preserving its terrain surface and world offset for Far-tier use.
|
||||
/// The corresponding render-side demotion preserves the terrain slot too.
|
||||
/// </summary>
|
||||
public void DemoteLandblockToTerrain(uint landblockId)
|
||||
{
|
||||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
if (_landblocks.TryGetValue(canonical, out var landblock))
|
||||
{
|
||||
_landblocks[canonical] = landblock with
|
||||
{
|
||||
Cells = Array.Empty<CellSurface>(),
|
||||
Portals = Array.Empty<PortalPlane>(),
|
||||
};
|
||||
}
|
||||
|
||||
// Static footprints can flood into adjacent landblocks. Retire them by
|
||||
// logical owner before removing rows by cell prefix, otherwise a mesh
|
||||
// demoted out of view can remain as invisible collision across a seam.
|
||||
ShadowObjects.DeregisterStaticOwnersForLandblock(canonical);
|
||||
ShadowObjects.RemoveLandblock(canonical);
|
||||
DataCache?.RemoveCellsForLandblock(canonical);
|
||||
DataCache?.RemoveBuildingsForLandblock(canonical);
|
||||
DataCache?.CellGraph.RemoveEnvCellsForLandblock(canonical);
|
||||
|
||||
if (DataCache?.CellGraph is { } graph
|
||||
&& graph.CurrCell is { } current
|
||||
&& (current.Id & 0xFFFF0000u) == (canonical & 0xFFFF0000u)
|
||||
&& (current.Id & 0xFFFFu) >= 0x0100u)
|
||||
{
|
||||
graph.CurrCell = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the landblock that contains the given world-space XY position and
|
||||
/// return its ID plus world-space origin offsets. Returns false when no
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ public sealed class ShadowObjectRegistry
|
|||
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
|
||||
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
|
||||
private readonly HashSet<uint> _suspendedEntities = new();
|
||||
// Rows withdrawn because a touched landblock streamed out. The owner may
|
||||
// be seeded in an adjacent still-resident landblock, so its remaining rows
|
||||
// cannot by themselves tell RefloodLandblock that this prefix needs repair.
|
||||
private readonly Dictionary<uint, HashSet<uint>> _withdrawnPrefixesByOwner = new();
|
||||
|
||||
/// <summary>
|
||||
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
|
||||
|
|
@ -367,7 +371,7 @@ public sealed class ShadowObjectRegistry
|
|||
public void RefloodLandblock(uint landblockId)
|
||||
{
|
||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
var toReflood = new List<uint>();
|
||||
var toReflood = new HashSet<uint>();
|
||||
|
||||
foreach (var kvp in _entityReg)
|
||||
{
|
||||
|
|
@ -390,11 +394,17 @@ public sealed class ShadowObjectRegistry
|
|||
}
|
||||
}
|
||||
}
|
||||
if (_withdrawnPrefixesByOwner.TryGetValue(kvp.Key, out var prefixes)
|
||||
&& prefixes.Contains(lbPrefix))
|
||||
{
|
||||
toReflood.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (uint entityId in toReflood)
|
||||
{
|
||||
var reg = _entityReg[entityId];
|
||||
_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawnBeforeReflood);
|
||||
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
|
||||
{
|
||||
RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes,
|
||||
|
|
@ -408,6 +418,22 @@ public sealed class ShadowObjectRegistry
|
|||
reg.CollisionType, reg.CylHeight, reg.Scale,
|
||||
reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic);
|
||||
}
|
||||
|
||||
// Register is also the authoritative movement/replacement API and
|
||||
// therefore clears obsolete markers. Only this streaming reflood
|
||||
// operation preserves the still-missing prefixes across replacement.
|
||||
if (withdrawnBeforeReflood is not null)
|
||||
_withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood;
|
||||
|
||||
if (_entityToCells.TryGetValue(entityId, out var refreshedCells)
|
||||
&& refreshedCells.Exists(cell =>
|
||||
(cell & 0xFFFF0000u) == lbPrefix)
|
||||
&& _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
|
||||
{
|
||||
withdrawn.Remove(lbPrefix);
|
||||
if (withdrawn.Count == 0)
|
||||
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,6 +501,29 @@ public sealed class ShadowObjectRegistry
|
|||
_entityShapes.Remove(entityId);
|
||||
_entityReg.Remove(entityId);
|
||||
_suspendedEntities.Remove(entityId);
|
||||
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logically tear down every static object owned by a landblock, including
|
||||
/// shadow rows flooded into adjacent landblocks. Dynamic/server-live owners
|
||||
/// are deliberately retained for spatial reflood after streaming changes.
|
||||
/// </summary>
|
||||
public void DeregisterStaticOwnersForLandblock(uint landblockId)
|
||||
{
|
||||
uint prefix = landblockId & 0xFFFF0000u;
|
||||
var owners = new List<uint>();
|
||||
foreach (var (entityId, registration) in _entityReg)
|
||||
{
|
||||
if (registration.IsStatic
|
||||
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
|
||||
{
|
||||
owners.Add(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (uint entityId in owners)
|
||||
Deregister(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -491,6 +540,18 @@ public sealed class ShadowObjectRegistry
|
|||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
var toRemove = new List<uint>();
|
||||
|
||||
foreach (var (entityId, cells) in _entityToCells)
|
||||
{
|
||||
if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix))
|
||||
continue;
|
||||
if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
|
||||
{
|
||||
withdrawn = new HashSet<uint>();
|
||||
_withdrawnPrefixesByOwner[entityId] = withdrawn;
|
||||
}
|
||||
withdrawn.Add(lbPrefix);
|
||||
}
|
||||
|
||||
foreach (var kvp in _cells)
|
||||
{
|
||||
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
|
||||
|
|
@ -520,6 +581,7 @@ public sealed class ShadowObjectRegistry
|
|||
_entityShapes.Remove(eid);
|
||||
_entityReg.Remove(eid);
|
||||
_suspendedEntities.Remove(eid);
|
||||
_withdrawnPrefixesByOwner.Remove(eid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -549,6 +611,18 @@ public sealed class ShadowObjectRegistry
|
|||
/// </summary>
|
||||
public int RetainedRegistrationCount => _entityReg.Count;
|
||||
|
||||
/// <summary>Number of owner/prefix repair markers awaiting a future reflood.</summary>
|
||||
public int WithdrawnPrefixMarkerCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var prefixes in _withdrawnPrefixesByOwner.Values)
|
||||
count += prefixes.Count;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
|
||||
public int SuspendedRegistrationCount => _suspendedEntities.Count;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ public sealed class CellGraph
|
|||
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove only a landblock's indoor environment cells while preserving its
|
||||
/// outdoor terrain registration. Used by Near-to-Far streaming demotion.
|
||||
/// </summary>
|
||||
public void RemoveEnvCellsForLandblock(uint landblockPrefix)
|
||||
{
|
||||
uint lb = landblockPrefix & 0xFFFF0000u;
|
||||
foreach (var id in new List<uint>(_envCells.Keys))
|
||||
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
/// <summary>The universal id->cell resolver (retail CObjCell::GetVisible).</summary>
|
||||
public ObjCell? GetVisible(uint id)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ public static class LandblockLoader
|
|||
|
||||
// When landblockId is non-zero, namespace stab Ids globally:
|
||||
// 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte
|
||||
// matching the scenery (0x80XXYY00) and interior (0x40XXYY00) patterns
|
||||
// in GameWindow.cs. The 0xC0 top byte distinguishes stabs from those.
|
||||
// distinct from the 0x8XXYYIII scenery and 0x4XXYYIII interior
|
||||
// namespaces in GameWindow.cs. The 0xC0 top byte distinguishes stabs.
|
||||
//
|
||||
// Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the
|
||||
// legacy starting-from-1 monotonic Ids — compatible with their assertions
|
||||
|
|
|
|||
39
src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs
Normal file
39
src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Allocates stable, collision-free ids for procedurally generated scenery.
|
||||
///
|
||||
/// <para>
|
||||
/// The top nibble is fixed at <c>0x8</c>, so bit 31 continues to identify
|
||||
/// procedural scenery to every renderer and physics consumer. The remaining
|
||||
/// 28 bits are X(8), Y(8), and a 12-bit per-landblock counter:
|
||||
/// <c>0x8XXYYIII</c>. No consumer decodes the former byte-aligned
|
||||
/// <c>0x80XXYYII</c> layout; they classify only on bit 31.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The old 8-bit counter aborted generation after 256 drawable spawns. That
|
||||
/// exception rejected the entire streamed landblock before render publication,
|
||||
/// exposing the grey clear color as portal space ended. A 12-bit counter gives
|
||||
/// 4096 entries while retaining full landblock coordinates. Overflow fails
|
||||
/// before it can alias the next landblock.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ProceduralSceneryIdAllocator
|
||||
{
|
||||
public const uint MaxCounter = 0xFFFu;
|
||||
|
||||
public static uint Base(uint landblockX, uint landblockY)
|
||||
=> 0x80000000u
|
||||
| ((landblockX & 0xFFu) << 20)
|
||||
| ((landblockY & 0xFFu) << 12);
|
||||
|
||||
public static uint Allocate(uint landblockX, uint landblockY, ref uint counter)
|
||||
{
|
||||
if (counter > MaxCounter)
|
||||
throw new InvalidDataException(
|
||||
$"Landblock ({landblockX & 0xFFu:X2},{landblockY & 0xFFu:X2}) exceeds the 4096-entry procedural scenery id namespace.");
|
||||
|
||||
return Base(landblockX, landblockY) + counter++;
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +99,8 @@ public sealed class TeleportAnimSequencer
|
|||
|
||||
/// <summary>
|
||||
/// Advance the machine by <paramref name="dt"/> seconds.
|
||||
/// <paramref name="worldReady"/> = destination collision landblock is resident.
|
||||
/// <paramref name="worldReady"/> = the complete destination-load barrier is
|
||||
/// satisfied (Near-tier render meshes/textures plus collision residency).
|
||||
/// Returns the current snapshot + edge-triggered events fired THIS tick.
|
||||
/// </summary>
|
||||
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue