fix(vfx): bind effects after canonical placement
C3c created graphical effect, projectile, and static-animation sidecars before Runtime finished the entity's first SetPosition. One-shot F754/F755 packets could be discarded, projectiles could adopt a cell-less body, and animated statics could compete for body ownership. Keep effects behind an exact-incarnation presentation barrier, retry projectile/static binding on the committed visibility edge, and keep effect cells synchronized with canonical rebuckets. User verified spell, recall, arrow, projectile, portal, and static presentation; 90 focused App tests and the Release build pass.
This commit is contained in:
parent
1fc529cdcb
commit
f24532adf3
11 changed files with 417 additions and 50 deletions
|
|
@ -72,17 +72,52 @@ What does NOT go here:
|
||||||
longer hand-place ahead of publication); expected to resolve with the
|
longer hand-place ahead of publication); expected to resolve with the
|
||||||
O(changed)-clone slice — re-verify all three in its acceptance
|
O(changed)-clone slice — re-verify all three in its acceptance
|
||||||
session.
|
session.
|
||||||
- **#279 — OPEN — one-shot spell/effect scripts arriving during the
|
- **#279 — DONE (2026-08-03, user-verified) — one-shot spell/effect
|
||||||
suppressed-until-receipt window can be lost.** User-observed: spell
|
scripts arriving during the suppressed-until-receipt window were lost.**
|
||||||
particle effects intermittently missing (2026-08-02 smoke test). C3c
|
`EntityEffectController` now retains the mixed F754/F755 FIFO behind an
|
||||||
suppresses presentation for a created entity until its placement
|
exact-incarnation initial-presentation barrier and replays it only after
|
||||||
receipt binds it; a play-once VFX/script that fires while suppressed
|
canonical placement has bound the mesh, pose owner, and particle visibility
|
||||||
has no presentation to land on and never replays at bind time —
|
resources. Live rebuckets also keep the effect cell synchronized with the
|
||||||
"sometimes works" = the receipt won the race. Investigate retail's
|
entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were
|
||||||
pending-script handling for not-yet-in-world objects (HandleCreateObject
|
verified in the connected client; focused effect, projectile, and
|
||||||
tail / PlayScript queuing) and defer one-shot scripts to the
|
cell-transition tests cover the race.
|
||||||
presentation-binding moment. Route: presentation sink /
|
- **#280 — OPEN — portal reveal can expose an incompletely streamed distant
|
||||||
TryApplyInitialCreateCompletionPresentation.
|
landscape.** User-observed 2026-08-03: after some recalls, the nearby
|
||||||
|
destination is playable but terrain near the far end of the view continues
|
||||||
|
visibly building after portal space exits. The current outdoor reveal gate
|
||||||
|
is explicitly only `WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius =
|
||||||
|
1` (a 3x3 landblock neighborhood), while the normal configured view extends
|
||||||
|
substantially farther; this permits the world viewport to open before its
|
||||||
|
visible static destination is complete.
|
||||||
|
|
||||||
|
**Retail oracle:** `CellManager::PreFetchCells @ 0x00455820` sets
|
||||||
|
`blocking_for_cells` until `LScape::PreFetchCells @ 0x00505660` has walked
|
||||||
|
the configured `mid_radius` square and each required
|
||||||
|
`CLandBlock::PreFetchCells` / `CLandBlockInfo::PreFetchCells` building and
|
||||||
|
connected EnvCell dependency is available. While blocked,
|
||||||
|
`SmartBox::UseTime @ 0x00455410` checks prefetch status but does not advance
|
||||||
|
ordinary object maintenance, physics, landscape, game time, or ambient
|
||||||
|
audio; the portal viewport and UI remain live and may show retail's centered
|
||||||
|
"In Portal Space - Please Wait..." notice. Once the destination is ready,
|
||||||
|
retail resumes it behind the portal viewport during `TAS_TUNNEL_CONTINUE`
|
||||||
|
before the later tunnel-to-world reveal.
|
||||||
|
|
||||||
|
**Fix shape:** replace the hard-coded radius-one reveal requirement with a
|
||||||
|
retail-derived, quality-configured destination prefetch window and keep one
|
||||||
|
generation-scoped reservation across terrain, statics/buildings, EnvCells,
|
||||||
|
render publication, composite textures, and collision until that complete
|
||||||
|
visible window is ready. Preserve bounded asynchronous preparation and the
|
||||||
|
existing wait cue; never reveal early merely to meet a timeout. Do not wait
|
||||||
|
for an unknowable "all dynamic server objects delivered" condition—ACE has
|
||||||
|
no such terminal marker and some object delivery follows LoginComplete.
|
||||||
|
|
||||||
|
**Acceptance:** at every quality/view-distance setting, repeated login,
|
||||||
|
`/ls`, spell recall, and portal routes reveal no constructing terrain,
|
||||||
|
buildings, statics, interiors, missing composite textures, or nearby
|
||||||
|
collision; slow destinations remain in the authored portal presentation
|
||||||
|
with responsive UI until ready, then receive the existing hidden settling
|
||||||
|
interval before the world viewport appears. Dynamic monsters/items may
|
||||||
|
continue to arrive authoritatively after reveal.
|
||||||
|
|
||||||
## Current queue — 2026-07-27
|
## Current queue — 2026-07-27
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,25 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
d.WorldOrigin,
|
d.WorldOrigin,
|
||||||
d.EffectPoses);
|
d.EffectPoses);
|
||||||
var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot);
|
var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot);
|
||||||
|
(LiveEntityAnimationState Animation, PhysicsBody Body)?
|
||||||
|
ResolveLiveStaticOwner(WorldEntity entity)
|
||||||
|
{
|
||||||
|
if (entity.ServerGuid == 0
|
||||||
|
|| liveEntities?.TryGetRecord(
|
||||||
|
entity.ServerGuid,
|
||||||
|
out LiveEntityRecord record) != true
|
||||||
|
|| !ReferenceEquals(record.WorldEntity, entity)
|
||||||
|
|| !record.IsSpatiallyProjected
|
||||||
|
|| !record.IsSpatiallyVisible
|
||||||
|
|| record.AnimationRuntime
|
||||||
|
is not LiveEntityAnimationState animation
|
||||||
|
|| record.PhysicsBody is not { } body)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (animation, body);
|
||||||
|
}
|
||||||
var staticAnimationScheduler =
|
var staticAnimationScheduler =
|
||||||
new RetailStaticAnimatingObjectScheduler(
|
new RetailStaticAnimatingObjectScheduler(
|
||||||
content.AnimationLoader,
|
content.AnimationLoader,
|
||||||
|
|
@ -300,7 +319,8 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
d.EffectPoses.Publish,
|
d.EffectPoses.Publish,
|
||||||
staticResidency.IsResident,
|
staticResidency.IsResident,
|
||||||
(entity, body) => _ = staticRootCommitter.Commit(entity, body),
|
(entity, body) => _ = staticRootCommitter.Commit(entity, body),
|
||||||
staticResidency.ProjectionVersion);
|
staticResidency.ProjectionVersion,
|
||||||
|
ResolveLiveStaticOwner);
|
||||||
|
|
||||||
ScriptActivationInfo? ResolveActivation(WorldEntity entity)
|
ScriptActivationInfo? ResolveActivation(WorldEntity entity)
|
||||||
{
|
{
|
||||||
|
|
@ -453,7 +473,7 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
particleVisibility,
|
particleVisibility,
|
||||||
"particle projection visibility");
|
"particle projection visibility");
|
||||||
var placementVisibilitySinks = new List<
|
var placementVisibilitySinks = new List<
|
||||||
Action<LiveEntityRecord, bool>>(3)
|
Action<LiveEntityRecord, bool>>(4)
|
||||||
{
|
{
|
||||||
wbVisibility,
|
wbVisibility,
|
||||||
};
|
};
|
||||||
|
|
@ -463,6 +483,15 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
liveRenderProjections.OnProjectionVisibilityChanged);
|
liveRenderProjections.OnProjectionVisibilityChanged);
|
||||||
}
|
}
|
||||||
placementVisibilitySinks.Add(particleVisibility);
|
placementVisibilitySinks.Add(particleVisibility);
|
||||||
|
// Retail enters the CPhysicsObj before ProcessObjectNetBlobs.
|
||||||
|
// C3c's initial Runtime placement is the equivalent world-entry
|
||||||
|
// edge, so open/replay the one-shot F754/F755 barrier only after
|
||||||
|
// mesh poses and particle presentation have both been published.
|
||||||
|
placementVisibilitySinks.Add((record, visible) =>
|
||||||
|
{
|
||||||
|
if (visible)
|
||||||
|
entityEffects?.OnPresentationBound(record);
|
||||||
|
});
|
||||||
var placementProjection = new RuntimePlacementPresentationSink(
|
var placementProjection = new RuntimePlacementPresentationSink(
|
||||||
liveEntities,
|
liveEntities,
|
||||||
worldTransit,
|
worldTransit,
|
||||||
|
|
|
||||||
|
|
@ -181,12 +181,14 @@ internal sealed class ProjectileController
|
||||||
// animation workset, classification adopts that same body instead
|
// animation workset, classification adopts that same body instead
|
||||||
// of replacing it or replaying CreateObject vectors.
|
// of replacing it or replaying CreateObject vectors.
|
||||||
body = sharedBody;
|
body = sharedBody;
|
||||||
uint currentCellId = record.FullCellId;
|
// Retail has one CPhysicsObj, whose Position owns both objcell_id
|
||||||
Vector3 currentCellLocal = CellLocalFromWorld(
|
// and the cell-local frame. The graphical record's FullCellId is
|
||||||
body.Position,
|
// only the later projection receipt and is legitimately still
|
||||||
currentCellId,
|
// zero while a residence-managed Create awaits presentation.
|
||||||
liveCenterX,
|
// Validate and adopt the canonical body frame itself; successful
|
||||||
liveCenterY);
|
// classification projects that same cell into the sidecar below.
|
||||||
|
uint currentCellId = body.CellPosition.ObjCellId;
|
||||||
|
Vector3 currentCellLocal = body.CellPosition.Frame.Origin;
|
||||||
if (!IsFinite(body.Position)
|
if (!IsFinite(body.Position)
|
||||||
|| !IsFinite(body.Velocity)
|
|| !IsFinite(body.Velocity)
|
||||||
|| !IsFinite(body.Omega)
|
|| !IsFinite(body.Omega)
|
||||||
|
|
@ -268,7 +270,17 @@ internal sealed class ProjectileController
|
||||||
entity.SetPosition(body.Position);
|
entity.SetPosition(body.Position);
|
||||||
entity.Rotation = body.Orientation;
|
entity.Rotation = body.Orientation;
|
||||||
entity.ParentCellId = canonicalCellId;
|
entity.ParentCellId = canonicalCellId;
|
||||||
if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId)
|
// Classification can run from the projection-visible callback after
|
||||||
|
// Runtime has already installed this exact cell. Re-entering Rebucket
|
||||||
|
// from that callback would supersede the outer projection transaction
|
||||||
|
// merely to write the same bucket again. Only perform a spatial move
|
||||||
|
// when classification is actually changing residence.
|
||||||
|
bool alreadyProjectedInCanonicalCell = record.IsSpatiallyProjected
|
||||||
|
&& record.FullCellId == canonicalCellId;
|
||||||
|
if ((!alreadyProjectedInCanonicalCell
|
||||||
|
&& !_liveEntities.RebucketLiveEntity(
|
||||||
|
record.ServerGuid,
|
||||||
|
canonicalCellId))
|
||||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord)
|
|| !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord)
|
||||||
|| !ReferenceEquals(currentRecord, record)
|
|| !ReferenceEquals(currentRecord, record)
|
||||||
|| !ReferenceEquals(currentRecord.WorldEntity, entity)
|
|| !ReferenceEquals(currentRecord.WorldEntity, entity)
|
||||||
|
|
@ -848,10 +860,43 @@ internal sealed class ProjectileController
|
||||||
|
|
||||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||||
{
|
{
|
||||||
if (record.ProjectileRuntime is not RuntimeProjectile runtime
|
if (record.WorldEntity is not { } entity
|
||||||
|| record.WorldEntity is not { } entity
|
|
||||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||||
|| !ReferenceEquals(current, record)
|
|| !ReferenceEquals(current, record))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible
|
||||||
|
&& record.ProjectileRuntime is null
|
||||||
|
&& record.PhysicsBody is not null
|
||||||
|
&& (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0)
|
||||||
|
{
|
||||||
|
// A residence-managed Create builds its one CPhysicsObj before
|
||||||
|
// presentation, but Runtime intentionally withholds the committed
|
||||||
|
// cell frame until enter_world completes. Materialization may
|
||||||
|
// therefore observe the body while it is still cell-less and its
|
||||||
|
// eager TryBind correctly refuses that incomplete frame. The
|
||||||
|
// projection-visible edge is the first point at which both the
|
||||||
|
// canonical body and its authoritative placement are guaranteed
|
||||||
|
// to be committed, so retry classification here instead of
|
||||||
|
// fabricating a CreateObject-frame fallback.
|
||||||
|
Setup? setup = _setupResolver?.Resolve(
|
||||||
|
entity.SourceGfxObjOrSetupId);
|
||||||
|
if (setup is not null)
|
||||||
|
{
|
||||||
|
int liveCenterX = _origin?.CenterX ?? 0;
|
||||||
|
int liveCenterY = _origin?.CenterY ?? 0;
|
||||||
|
_ = TryBind(
|
||||||
|
record,
|
||||||
|
setup,
|
||||||
|
_lastFiniteGameTime,
|
||||||
|
liveCenterX,
|
||||||
|
liveCenterY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.ProjectileRuntime is not RuntimeProjectile runtime
|
||||||
|| !ReferenceEquals(current.ProjectileRuntime, runtime))
|
|| !ReferenceEquals(current.ProjectileRuntime, runtime))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
@ -933,20 +978,6 @@ internal sealed class ProjectileController
|
||||||
&& float.IsFinite(value.Y)
|
&& float.IsFinite(value.Y)
|
||||||
&& float.IsFinite(value.Z);
|
&& float.IsFinite(value.Z);
|
||||||
|
|
||||||
private static Vector3 CellLocalFromWorld(
|
|
||||||
Vector3 worldPosition,
|
|
||||||
uint cellId,
|
|
||||||
int liveCenterX,
|
|
||||||
int liveCenterY)
|
|
||||||
{
|
|
||||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
|
||||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
|
||||||
return worldPosition - new Vector3(
|
|
||||||
(landblockX - liveCenterX) * 192f,
|
|
||||||
(landblockY - liveCenterY) * 192f,
|
|
||||||
0f);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryGetCurrent(
|
private bool TryGetCurrent(
|
||||||
uint serverGuid,
|
uint serverGuid,
|
||||||
out LiveEntityRecord record,
|
out LiveEntityRecord record,
|
||||||
|
|
|
||||||
|
|
@ -837,12 +837,24 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
||||||
expectedCreateIntegrationVersion)
|
expectedCreateIntegrationVersion)
|
||||||
|| !ReferenceEquals(expectedRecord.WorldEntity, entity))
|
|| !ReferenceEquals(expectedRecord.WorldEntity, entity))
|
||||||
return false;
|
return false;
|
||||||
_projectiles.TryBind(
|
bool initialResidenceActive =
|
||||||
expectedRecord,
|
_runtime.HasActiveInitialCreateResidence(expectedCanonical);
|
||||||
setup,
|
// C3c first entry owns CPhysicsObj construction and SetPosition for a
|
||||||
_gameTime.CurrentScriptTime,
|
// residence-managed Create. Eager projectile classification used to
|
||||||
_origin.CenterX,
|
// acquire that same body here, before the conductor ran; the
|
||||||
_origin.CenterY);
|
// conductor then correctly rejected the unexpected owner and the
|
||||||
|
// missile remained permanently cell-less. The committed projection
|
||||||
|
// visibility edge retries TryBind after Runtime has constructed and
|
||||||
|
// placed the one canonical body.
|
||||||
|
if (!initialResidenceActive)
|
||||||
|
{
|
||||||
|
_projectiles.TryBind(
|
||||||
|
expectedRecord,
|
||||||
|
setup,
|
||||||
|
_gameTime.CurrentScriptTime,
|
||||||
|
_origin.CenterX,
|
||||||
|
_origin.CenterY);
|
||||||
|
}
|
||||||
|
|
||||||
if (!_runtime.IsCurrentCreateIntegration(
|
if (!_runtime.IsCurrentCreateIntegration(
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
|
|
@ -1033,6 +1045,15 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_runtime.HasActiveInitialCreateResidence(expectedRecord.Canonical))
|
||||||
|
{
|
||||||
|
// RuntimeRemoteFirstEntryState owns CPhysicsObj construction and
|
||||||
|
// SetPosition until the Create residence completes. The static
|
||||||
|
// scheduler retains this pending animation owner and binds the
|
||||||
|
// canonical body after projection becomes spatially visible.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
PhysicsBody body = _runtime.GetOrCreatePhysicsBody(
|
PhysicsBody body = _runtime.GetOrCreatePhysicsBody(
|
||||||
spawn.Guid,
|
spawn.Guid,
|
||||||
incarnation =>
|
incarnation =>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
private readonly Func<WorldEntity, bool> _isResident;
|
private readonly Func<WorldEntity, bool> _isResident;
|
||||||
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
|
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
|
||||||
private readonly Func<WorldEntity, ulong> _residencyVersion;
|
private readonly Func<WorldEntity, ulong> _residencyVersion;
|
||||||
|
private readonly Func<WorldEntity,
|
||||||
|
(LiveEntityAnimationState Animation, PhysicsBody Body)?>?
|
||||||
|
_resolveLiveOwner;
|
||||||
private readonly Dictionary<uint, Owner> _owners = new();
|
private readonly Dictionary<uint, Owner> _owners = new();
|
||||||
private readonly List<Owner> _snapshot = new();
|
private readonly List<Owner> _snapshot = new();
|
||||||
private readonly List<Owner> _hookSnapshot = new();
|
private readonly List<Owner> _hookSnapshot = new();
|
||||||
|
|
@ -63,7 +66,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
|
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
|
||||||
Func<WorldEntity, bool>? isResident = null,
|
Func<WorldEntity, bool>? isResident = null,
|
||||||
Action<WorldEntity, PhysicsBody>? commitLiveRoot = null,
|
Action<WorldEntity, PhysicsBody>? commitLiveRoot = null,
|
||||||
Func<WorldEntity, ulong>? residencyVersion = null)
|
Func<WorldEntity, ulong>? residencyVersion = null,
|
||||||
|
Func<WorldEntity,
|
||||||
|
(LiveEntityAnimationState Animation, PhysicsBody Body)?>?
|
||||||
|
resolveLiveOwner = null)
|
||||||
{
|
{
|
||||||
_animationLoader = animationLoader
|
_animationLoader = animationLoader
|
||||||
?? throw new ArgumentNullException(nameof(animationLoader));
|
?? throw new ArgumentNullException(nameof(animationLoader));
|
||||||
|
|
@ -74,6 +80,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
_isResident = isResident ?? (_ => true);
|
_isResident = isResident ?? (_ => true);
|
||||||
_commitLiveRoot = commitLiveRoot ?? ((_, _) => { });
|
_commitLiveRoot = commitLiveRoot ?? ((_, _) => { });
|
||||||
_residencyVersion = residencyVersion ?? (_ => 0UL);
|
_residencyVersion = residencyVersion ?? (_ => 0UL);
|
||||||
|
_resolveLiveOwner = resolveLiveOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal int Count => _owners.Count;
|
internal int Count => _owners.Count;
|
||||||
|
|
@ -363,12 +370,30 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
foreach (Owner owner in _snapshot)
|
foreach (Owner owner in _snapshot)
|
||||||
{
|
{
|
||||||
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|
||||||
|| !ReferenceEquals(current, owner)
|
|| !ReferenceEquals(current, owner))
|
||||||
|| owner.Sequencer is not { } sequencer)
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C3c: live Static objects are registered while their visual
|
||||||
|
// sidecar is hydrated, before Runtime has completed the initial
|
||||||
|
// CreateObject SetPosition transaction. The first-entry
|
||||||
|
// conductor owns construction of the canonical CPhysicsObj.
|
||||||
|
// Resolve and bind that already-placed owner lazily; constructing
|
||||||
|
// a second/eager body here makes the conductor reject authority
|
||||||
|
// and leaves portals, doors, and other animated statics cell-less.
|
||||||
|
if (owner.Sequencer is null
|
||||||
|
&& owner.Entity.ServerGuid != 0
|
||||||
|
&& _resolveLiveOwner?.Invoke(owner.Entity) is { } binding)
|
||||||
|
{
|
||||||
|
_ = BindLiveOwner(
|
||||||
|
owner.Entity,
|
||||||
|
binding.Animation,
|
||||||
|
binding.Body);
|
||||||
|
}
|
||||||
|
if (owner.Sequencer is not { } sequencer)
|
||||||
|
continue;
|
||||||
|
|
||||||
owner.ElapsedSinceUpdate += elapsedSeconds;
|
owner.ElapsedSinceUpdate += elapsedSeconds;
|
||||||
if (!_isResident(owner.Entity))
|
if (!_isResident(owner.Entity))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,14 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
private readonly Action<uint, uint?> _ownerSoundTableChanged;
|
private readonly Action<uint, uint?> _ownerSoundTableChanged;
|
||||||
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
|
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
|
||||||
private readonly HashSet<RuntimeEntityKey> _readyLiveOwners = [];
|
private readonly HashSet<RuntimeEntityKey> _readyLiveOwners = [];
|
||||||
|
// C3c constructs the App effect owner before Runtime's initial placement
|
||||||
|
// receipt binds its world presentation. During that one-time split-lifetime
|
||||||
|
// window, retail still considers the CPhysicsObj absent: SmartBox queues
|
||||||
|
// F754/F755 at 0x00452020/0x00452070, enters the object, then drains them
|
||||||
|
// through ProcessObjectNetBlobs in HandleCreateObject 0x00454C80. Keep the
|
||||||
|
// exact incarnation behind an equivalent barrier until the graphical
|
||||||
|
// placement publishes its pose and resource visibility.
|
||||||
|
private readonly HashSet<RuntimeEntityKey> _initialPresentationBarriers = [];
|
||||||
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
|
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
|
||||||
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
||||||
private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new();
|
private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new();
|
||||||
|
|
@ -90,6 +98,8 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
RefreshLiveAnchor(message.Guid, localId);
|
RefreshLiveAnchor(message.Guid, localId);
|
||||||
if (CanStartOwner(localId))
|
if (CanStartOwner(localId))
|
||||||
PlayDirect(localId, message.ScriptDid);
|
PlayDirect(localId, message.ScriptDid);
|
||||||
|
else if (IsWaitingForInitialPresentation(message.Guid))
|
||||||
|
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
||||||
|
|
@ -104,6 +114,12 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
RefreshLiveAnchor(message.Guid, localId);
|
RefreshLiveAnchor(message.Guid, localId);
|
||||||
if (CanStartOwner(localId))
|
if (CanStartOwner(localId))
|
||||||
PlayTyped(localId, message.RawScriptType, message.Intensity);
|
PlayTyped(localId, message.RawScriptType, message.Intensity);
|
||||||
|
else if (IsWaitingForInitialPresentation(message.Guid))
|
||||||
|
{
|
||||||
|
Enqueue(
|
||||||
|
message.Guid,
|
||||||
|
PendingEffect.Typed(message.RawScriptType, message.Intensity));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
|
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
|
||||||
|
|
@ -140,11 +156,45 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
RuntimeEntityKey key = RequireProjectionKey(record);
|
RuntimeEntityKey key = RequireProjectionKey(record);
|
||||||
_readyLiveOwners.Add(key);
|
_readyLiveOwners.Add(key);
|
||||||
_liveProfiles[key] = profile;
|
_liveProfiles[key] = profile;
|
||||||
|
if (record.MaterializationResidence is
|
||||||
|
LiveEntityMaterializationResidence.AwaitRuntimePlacement
|
||||||
|
&& !record.IsSpatiallyProjected)
|
||||||
|
{
|
||||||
|
_initialPresentationBarriers.Add(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_initialPresentationBarriers.Remove(key);
|
||||||
|
}
|
||||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the C3c-only network-script barrier after the initial placement
|
||||||
|
/// has published the entity pose and presentation resources, then replays
|
||||||
|
/// the retained mixed F754/F755 FIFO synchronously in arrival order.
|
||||||
|
/// </summary>
|
||||||
|
public bool OnPresentationBound(LiveEntityRecord record)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
if (record.ProjectionKey is not { } key
|
||||||
|
|| !_liveEntities.TryGetRecord(key, out LiveEntityRecord current)
|
||||||
|
|| !ReferenceEquals(current, record))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialPresentationBarriers.Remove(key);
|
||||||
|
if (!TryGetReadyLocalId(record.ServerGuid, out uint localId))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
RefreshLiveAnchor(record.ServerGuid, localId);
|
||||||
|
TryReplayPending(record.ServerGuid, localId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
|
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
|
||||||
public bool ReplayPendingForLiveEntity(uint serverGuid)
|
public bool ReplayPendingForLiveEntity(uint serverGuid)
|
||||||
{
|
{
|
||||||
|
|
@ -219,6 +269,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
{
|
{
|
||||||
_readyLiveOwners.Remove(key);
|
_readyLiveOwners.Remove(key);
|
||||||
_liveProfiles.Remove(key);
|
_liveProfiles.Remove(key);
|
||||||
|
_initialPresentationBarriers.Remove(key);
|
||||||
}
|
}
|
||||||
if (record.LocalEntityId is not { } localId)
|
if (record.LocalEntityId is not { } localId)
|
||||||
return;
|
return;
|
||||||
|
|
@ -241,6 +292,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
}
|
}
|
||||||
_readyLiveOwners.Clear();
|
_readyLiveOwners.Clear();
|
||||||
_liveProfiles.Clear();
|
_liveProfiles.Clear();
|
||||||
|
_initialPresentationBarriers.Clear();
|
||||||
_pendingByServerGuid.Clear();
|
_pendingByServerGuid.Clear();
|
||||||
_dirtyLiveOwners.Clear();
|
_dirtyLiveOwners.Clear();
|
||||||
_dirtyLiveOwnerOrder.Clear();
|
_dirtyLiveOwnerOrder.Clear();
|
||||||
|
|
@ -455,6 +507,11 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsWaitingForInitialPresentation(uint serverGuid) =>
|
||||||
|
_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||||
|
&& record.ProjectionKey is { } key
|
||||||
|
&& _initialPresentationBarriers.Contains(key);
|
||||||
|
|
||||||
private void OnEffectPoseChanged(uint localId)
|
private void OnEffectPoseChanged(uint localId)
|
||||||
{
|
{
|
||||||
if (_posePublishLocalId == localId)
|
if (_posePublishLocalId == localId)
|
||||||
|
|
|
||||||
|
|
@ -842,6 +842,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
||||||
// between two loaded buckets. Suppress those implementation details
|
// between two loaded buckets. Suppress those implementation details
|
||||||
// and publish only the final logical visibility edge.
|
// and publish only the final logical visibility edge.
|
||||||
record.IsSpatiallyProjected = true;
|
record.IsSpatiallyProjected = true;
|
||||||
|
bool hasExactDestinationCell = spatialCellOrLandblockId != 0u
|
||||||
|
&& (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu;
|
||||||
|
if (hasExactDestinationCell)
|
||||||
|
{
|
||||||
|
// Runtime's physics cell commit and the graphical sidecar are one
|
||||||
|
// SetPosition result. Retail CPhysicsObj::set_cell changes the
|
||||||
|
// CObjCell read by ShouldDrawParticles at the same edge; retaining
|
||||||
|
// the prior sidecar cell makes newly-created spell particles fail
|
||||||
|
// IsInView as soon as the player crosses an outdoor landcell.
|
||||||
|
entity.ParentCellId = spatialCellOrLandblockId;
|
||||||
|
entity.EffectCellId = spatialCellOrLandblockId;
|
||||||
|
}
|
||||||
Exception? spatialNotificationFailure = null;
|
Exception? spatialNotificationFailure = null;
|
||||||
uint priorRebucketingGuid = _rebucketingGuid;
|
uint priorRebucketingGuid = _rebucketingGuid;
|
||||||
_rebucketingGuid = serverGuid;
|
_rebucketingGuid = serverGuid;
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ public sealed class ProjectileControllerTests
|
||||||
Assert.Null(record.AnimationRuntime);
|
Assert.Null(record.AnimationRuntime);
|
||||||
var remote =
|
var remote =
|
||||||
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
||||||
remote.Body.Position = entity.Position;
|
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
|
||||||
remote.Body.Orientation = entity.Rotation;
|
remote.Body.Orientation = entity.Rotation;
|
||||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
|
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
|
||||||
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||||
|
|
@ -1006,6 +1006,8 @@ public sealed class ProjectileControllerTests
|
||||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||||
var remote =
|
var remote =
|
||||||
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
|
||||||
|
|
||||||
record.FinalPhysicsState = MissileState;
|
record.FinalPhysicsState = MissileState;
|
||||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||||
|
|
@ -1081,7 +1083,10 @@ public sealed class ProjectileControllerTests
|
||||||
Assert.Equal(new Vector3(0f, 0f, 2f), remote.Body.Omega);
|
Assert.Equal(new Vector3(0f, 0f, 2f), remote.Body.Omega);
|
||||||
remote.Body.set_velocity(new Vector3(8f, 0f, 0f));
|
remote.Body.set_velocity(new Vector3(8f, 0f, 0f));
|
||||||
remote.Body.Omega = new Vector3(0f, 0f, 3f);
|
remote.Body.Omega = new Vector3(0f, 0f, 3f);
|
||||||
remote.Body.Position = entity.Position;
|
remote.Body.SnapToCell(
|
||||||
|
startCell,
|
||||||
|
entity.Position,
|
||||||
|
new Vector3(191f, 10f, 50f));
|
||||||
remote.Body.State = record.FinalPhysicsState;
|
remote.Body.State = record.FinalPhysicsState;
|
||||||
PhysicsBody body = remote.Body;
|
PhysicsBody body = remote.Body;
|
||||||
|
|
||||||
|
|
@ -1142,7 +1147,7 @@ public sealed class ProjectileControllerTests
|
||||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||||
var remote =
|
var remote =
|
||||||
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
||||||
remote.Body.Position = entity.Position;
|
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
|
||||||
remote.Body.Orientation = entity.Rotation;
|
remote.Body.Orientation = entity.Rotation;
|
||||||
|
|
||||||
Assert.True(float.IsNaN(record.Snapshot.Physics!.Value.Velocity!.Value.X));
|
Assert.True(float.IsNaN(record.Snapshot.Physics!.Value.Velocity!.Value.X));
|
||||||
|
|
@ -1181,6 +1186,55 @@ public sealed class ProjectileControllerTests
|
||||||
Assert.Equal(MissileState, remote.Body.State);
|
Assert.Equal(MissileState, remote.Body.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResidencePlacementVisibility_RetriesProjectileBindingAfterBodyFrameCommits()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
var remote = fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
||||||
|
|
||||||
|
Assert.Null(record.ProjectileRuntime);
|
||||||
|
Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid));
|
||||||
|
|
||||||
|
remote.Body.Orientation = Quaternion.Identity;
|
||||||
|
remote.Body.set_velocity(new Vector3(10f, 0f, 0f));
|
||||||
|
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
|
||||||
|
|
||||||
|
Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellA));
|
||||||
|
|
||||||
|
Assert.NotNull(record.ProjectileRuntime);
|
||||||
|
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||||
|
Assert.True(record.ProjectileRuntime.Body.InWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SharedBodyCell_IsCanonicalBeforePresentationCellIsProjected()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
var remote = fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
|
||||||
|
|
||||||
|
Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid));
|
||||||
|
record.CanonicalLandblockId = 0u;
|
||||||
|
record.FullCellId = 0u;
|
||||||
|
Assert.Equal(0u, record.FullCellId);
|
||||||
|
remote.Body.Orientation = Quaternion.Identity;
|
||||||
|
remote.Body.set_velocity(new Vector3(10f, 0f, 0f));
|
||||||
|
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.TryBind(
|
||||||
|
record,
|
||||||
|
ProjectileSetup(),
|
||||||
|
currentTime: 1.0,
|
||||||
|
liveCenterX: 1,
|
||||||
|
liveCenterY: 1));
|
||||||
|
|
||||||
|
Assert.Equal(CellA, record.FullCellId);
|
||||||
|
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SharedRemoteMotionCell_DelegatesToCanonicalLiveRecord()
|
public void SharedRemoteMotionCell_DelegatesToCanonicalLiveRecord()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,50 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
||||||
Assert.Equal(referenceFrames[0], frames[0]);
|
Assert.Equal(referenceFrames[0], frames[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PendingLiveStaticOwner_BindsCanonicalRuntimeOwnerOnFirstTick()
|
||||||
|
{
|
||||||
|
var loader = new Loader();
|
||||||
|
loader.Add(AnimationId, TwoFrameAnimation());
|
||||||
|
Setup setup = MakeSetup();
|
||||||
|
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||||
|
var sequencer = new AnimationSequencer(
|
||||||
|
setup,
|
||||||
|
new MotionTable(),
|
||||||
|
loader);
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
Orientation = entity.Rotation,
|
||||||
|
};
|
||||||
|
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
||||||
|
LiveEntityAnimationState animation =
|
||||||
|
LiveState(entity, setup, sequencer);
|
||||||
|
int resolutions = 0;
|
||||||
|
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||||
|
loader,
|
||||||
|
(_, _) => { },
|
||||||
|
(_, _, _) => { },
|
||||||
|
resolveLiveOwner: candidate =>
|
||||||
|
{
|
||||||
|
Assert.Same(entity, candidate);
|
||||||
|
resolutions++;
|
||||||
|
return (animation, body);
|
||||||
|
});
|
||||||
|
Assert.True(scheduler.Register(entity, new ScriptActivationInfo(
|
||||||
|
ScriptId: 0,
|
||||||
|
PartTransforms: entity.IndexedPartTransforms,
|
||||||
|
PartAvailability: entity.IndexedPartAvailable,
|
||||||
|
Setup: setup,
|
||||||
|
DefaultAnimationId: AnimationId,
|
||||||
|
UsesStaticAnimationWorkset: true)));
|
||||||
|
|
||||||
|
scheduler.Tick(0.02f);
|
||||||
|
scheduler.Tick(0.02f);
|
||||||
|
|
||||||
|
Assert.Equal(1, resolutions);
|
||||||
|
Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LivePhysicsStaticOwner_DiscardsLongNonResidentIntervalOnReentry()
|
public void LivePhysicsStaticOwner_DiscardsLongNonResidentIntervalOnReentry()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Core.Vfx;
|
using AcDream.Core.Vfx;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Enums;
|
using DatReaderWriter.Enums;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
@ -129,6 +130,31 @@ public sealed class EntityEffectControllerTests
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LiveEntityRecord AwaitInitialPresentation(
|
||||||
|
uint guid = Guid,
|
||||||
|
ushort generation = 1,
|
||||||
|
EntityEffectProfile? profile = null)
|
||||||
|
{
|
||||||
|
WorldSession.EntitySpawn spawn = Spawn(guid, generation);
|
||||||
|
RuntimeEntityRecord canonical = Assert.IsType<RuntimeEntityRecord>(
|
||||||
|
Runtime.RegisterLiveEntity(spawn).Canonical);
|
||||||
|
WorldEntity entity = Assert.IsType<WorldEntity>(
|
||||||
|
Runtime.MaterializeLiveEntity(
|
||||||
|
canonical,
|
||||||
|
spawn.Position!.Value.LandblockId,
|
||||||
|
id => Entity(id, guid),
|
||||||
|
LiveEntityProjectionKind.World,
|
||||||
|
initializeProjection: exact =>
|
||||||
|
exact.EffectProfile = profile ?? LiveProfile(),
|
||||||
|
out LiveEntityRecord? record,
|
||||||
|
LiveEntityMaterializationResidence.AwaitRuntimePlacement));
|
||||||
|
Assert.Same(entity, record!.WorldEntity);
|
||||||
|
Assert.False(record.IsSpatiallyProjected);
|
||||||
|
Assert.False(record.IsSpatiallyVisible);
|
||||||
|
Assert.True(Controller.PrepareLiveEntityOwner(guid));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
public static EntityEffectProfile LiveProfile(
|
public static EntityEffectProfile LiveProfile(
|
||||||
uint tableDid = TableDid,
|
uint tableDid = TableDid,
|
||||||
uint rawDefaultType = RawType,
|
uint rawDefaultType = RawType,
|
||||||
|
|
@ -187,6 +213,32 @@ public sealed class EntityEffectControllerTests
|
||||||
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
|
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RuntimeInitialPlacementWindow_DefersPacketsUntilPresentationBinds()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
LiveEntityRecord record = fixture.AwaitInitialPresentation();
|
||||||
|
|
||||||
|
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||||
|
fixture.Controller.HandleTyped(
|
||||||
|
new PlayPhysicsScriptType(Guid, RawType, 0.5f));
|
||||||
|
|
||||||
|
Assert.Equal(2, fixture.Controller.PendingPacketCount);
|
||||||
|
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
|
||||||
|
|
||||||
|
// Runtime has committed the first frame and the graphical placement
|
||||||
|
// receipt is now publishing its pose/resources.
|
||||||
|
record.FullCellId = 0x01010001u;
|
||||||
|
record.IsSpatiallyProjected = true;
|
||||||
|
record.IsSpatiallyVisible = true;
|
||||||
|
Assert.True(fixture.Controller.OnPresentationBound(record));
|
||||||
|
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||||
|
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
|
||||||
|
|
||||||
|
fixture.Runner.Tick(0.0);
|
||||||
|
Assert.Equal([1u, 2u], EmitterIds(fixture.Sink));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
|
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2190,16 +2190,23 @@ public sealed class LiveEntityRuntimeTests
|
||||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||||
var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources());
|
var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources());
|
||||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010022u));
|
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010022u));
|
||||||
runtime.MaterializeLiveEntity(guid, 0x01010022u, id => Entity(id, guid));
|
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||||
|
guid,
|
||||||
|
0x01010022u,
|
||||||
|
id => Entity(id, guid))!;
|
||||||
|
|
||||||
runtime.RebucketLiveEntity(guid, 0x0102FFFFu);
|
runtime.RebucketLiveEntity(guid, 0x0102FFFFu);
|
||||||
|
|
||||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||||
Assert.Equal(0x01010022u, record.FullCellId);
|
Assert.Equal(0x01010022u, record.FullCellId);
|
||||||
Assert.Equal(0x0102FFFFu, record.CanonicalLandblockId);
|
Assert.Equal(0x0102FFFFu, record.CanonicalLandblockId);
|
||||||
|
Assert.Equal(0x01010022u, entity.ParentCellId);
|
||||||
|
Assert.Equal(0x01010022u, entity.EffectCellId);
|
||||||
|
|
||||||
runtime.RebucketLiveEntity(guid, 0x01020033u);
|
runtime.RebucketLiveEntity(guid, 0x01020033u);
|
||||||
Assert.Equal(0x01020033u, record.FullCellId);
|
Assert.Equal(0x01020033u, record.FullCellId);
|
||||||
|
Assert.Equal(0x01020033u, entity.ParentCellId);
|
||||||
|
Assert.Equal(0x01020033u, entity.EffectCellId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue