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:
Erik 2026-08-03 12:10:21 +02:00
parent 1fc529cdcb
commit f24532adf3
11 changed files with 417 additions and 50 deletions

View file

@ -72,17 +72,52 @@ What does NOT go here:
longer hand-place ahead of publication); expected to resolve with the
O(changed)-clone slice — re-verify all three in its acceptance
session.
- **#279 — OPEN — one-shot spell/effect scripts arriving during the
suppressed-until-receipt window can be lost.** User-observed: spell
particle effects intermittently missing (2026-08-02 smoke test). C3c
suppresses presentation for a created entity until its placement
receipt binds it; a play-once VFX/script that fires while suppressed
has no presentation to land on and never replays at bind time —
"sometimes works" = the receipt won the race. Investigate retail's
pending-script handling for not-yet-in-world objects (HandleCreateObject
tail / PlayScript queuing) and defer one-shot scripts to the
presentation-binding moment. Route: presentation sink /
TryApplyInitialCreateCompletionPresentation.
- **#279 — DONE (2026-08-03, user-verified) — one-shot spell/effect
scripts arriving during the suppressed-until-receipt window were lost.**
`EntityEffectController` now retains the mixed F754/F755 FIFO behind an
exact-incarnation initial-presentation barrier and replays it only after
canonical placement has bound the mesh, pose owner, and particle visibility
resources. Live rebuckets also keep the effect cell synchronized with the
entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were
verified in the connected client; focused effect, projectile, and
cell-transition tests cover the race.
- **#280 — OPEN — portal reveal can expose an incompletely streamed distant
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

View file

@ -293,6 +293,25 @@ internal sealed class LivePresentationCompositionPhase
d.WorldOrigin,
d.EffectPoses);
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 =
new RetailStaticAnimatingObjectScheduler(
content.AnimationLoader,
@ -300,7 +319,8 @@ internal sealed class LivePresentationCompositionPhase
d.EffectPoses.Publish,
staticResidency.IsResident,
(entity, body) => _ = staticRootCommitter.Commit(entity, body),
staticResidency.ProjectionVersion);
staticResidency.ProjectionVersion,
ResolveLiveStaticOwner);
ScriptActivationInfo? ResolveActivation(WorldEntity entity)
{
@ -453,7 +473,7 @@ internal sealed class LivePresentationCompositionPhase
particleVisibility,
"particle projection visibility");
var placementVisibilitySinks = new List<
Action<LiveEntityRecord, bool>>(3)
Action<LiveEntityRecord, bool>>(4)
{
wbVisibility,
};
@ -463,6 +483,15 @@ internal sealed class LivePresentationCompositionPhase
liveRenderProjections.OnProjectionVisibilityChanged);
}
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(
liveEntities,
worldTransit,

View file

@ -181,12 +181,14 @@ internal sealed class ProjectileController
// animation workset, classification adopts that same body instead
// of replacing it or replaying CreateObject vectors.
body = sharedBody;
uint currentCellId = record.FullCellId;
Vector3 currentCellLocal = CellLocalFromWorld(
body.Position,
currentCellId,
liveCenterX,
liveCenterY);
// Retail has one CPhysicsObj, whose Position owns both objcell_id
// and the cell-local frame. The graphical record's FullCellId is
// only the later projection receipt and is legitimately still
// zero while a residence-managed Create awaits presentation.
// Validate and adopt the canonical body frame itself; successful
// classification projects that same cell into the sidecar below.
uint currentCellId = body.CellPosition.ObjCellId;
Vector3 currentCellLocal = body.CellPosition.Frame.Origin;
if (!IsFinite(body.Position)
|| !IsFinite(body.Velocity)
|| !IsFinite(body.Omega)
@ -268,7 +270,17 @@ internal sealed class ProjectileController
entity.SetPosition(body.Position);
entity.Rotation = body.Orientation;
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)
|| !ReferenceEquals(currentRecord, record)
|| !ReferenceEquals(currentRecord.WorldEntity, entity)
@ -848,10 +860,43 @@ internal sealed class ProjectileController
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| record.WorldEntity is not { } entity
if (record.WorldEntity is not { } entity
|| !_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))
{
return;
@ -933,20 +978,6 @@ internal sealed class ProjectileController
&& float.IsFinite(value.Y)
&& 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(
uint serverGuid,
out LiveEntityRecord record,

View file

@ -837,12 +837,24 @@ internal sealed class DatLiveEntityProjectionMaterializer
expectedCreateIntegrationVersion)
|| !ReferenceEquals(expectedRecord.WorldEntity, entity))
return false;
_projectiles.TryBind(
expectedRecord,
setup,
_gameTime.CurrentScriptTime,
_origin.CenterX,
_origin.CenterY);
bool initialResidenceActive =
_runtime.HasActiveInitialCreateResidence(expectedCanonical);
// C3c first entry owns CPhysicsObj construction and SetPosition for a
// residence-managed Create. Eager projectile classification used to
// acquire that same body here, before the conductor ran; the
// 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(
expectedRecord,
@ -1033,6 +1045,15 @@ internal sealed class DatLiveEntityProjectionMaterializer
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(
spawn.Guid,
incarnation =>

View file

@ -53,6 +53,9 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
private readonly Func<WorldEntity, bool> _isResident;
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
private readonly Func<WorldEntity, ulong> _residencyVersion;
private readonly Func<WorldEntity,
(LiveEntityAnimationState Animation, PhysicsBody Body)?>?
_resolveLiveOwner;
private readonly Dictionary<uint, Owner> _owners = new();
private readonly List<Owner> _snapshot = new();
private readonly List<Owner> _hookSnapshot = new();
@ -63,7 +66,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
Func<WorldEntity, bool>? isResident = 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
?? throw new ArgumentNullException(nameof(animationLoader));
@ -74,6 +80,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
_isResident = isResident ?? (_ => true);
_commitLiveRoot = commitLiveRoot ?? ((_, _) => { });
_residencyVersion = residencyVersion ?? (_ => 0UL);
_resolveLiveOwner = resolveLiveOwner;
}
internal int Count => _owners.Count;
@ -363,12 +370,30 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
foreach (Owner owner in _snapshot)
{
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|| !ReferenceEquals(current, owner)
|| owner.Sequencer is not { } sequencer)
|| !ReferenceEquals(current, owner))
{
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;
if (!_isResident(owner.Entity))
{

View file

@ -36,6 +36,14 @@ public sealed class EntityEffectController : IAnimationHookSink,
private readonly Action<uint, uint?> _ownerSoundTableChanged;
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
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, WorldEntity> _staticOwners = new();
private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new();
@ -90,6 +98,8 @@ public sealed class EntityEffectController : IAnimationHookSink,
RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId))
PlayDirect(localId, message.ScriptDid);
else if (IsWaitingForInitialPresentation(message.Guid))
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
return;
}
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
@ -104,6 +114,12 @@ public sealed class EntityEffectController : IAnimationHookSink,
RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId))
PlayTyped(localId, message.RawScriptType, message.Intensity);
else if (IsWaitingForInitialPresentation(message.Guid))
{
Enqueue(
message.Guid,
PendingEffect.Typed(message.RawScriptType, message.Intensity));
}
return;
}
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
@ -140,11 +156,45 @@ public sealed class EntityEffectController : IAnimationHookSink,
RuntimeEntityKey key = RequireProjectionKey(record);
_readyLiveOwners.Add(key);
_liveProfiles[key] = profile;
if (record.MaterializationResidence is
LiveEntityMaterializationResidence.AwaitRuntimePlacement
&& !record.IsSpatiallyProjected)
{
_initialPresentationBarriers.Add(key);
}
else
{
_initialPresentationBarriers.Remove(key);
}
_runner.SetOwnerAnchor(entity.Id, entity.Position);
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
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>
public bool ReplayPendingForLiveEntity(uint serverGuid)
{
@ -219,6 +269,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
{
_readyLiveOwners.Remove(key);
_liveProfiles.Remove(key);
_initialPresentationBarriers.Remove(key);
}
if (record.LocalEntityId is not { } localId)
return;
@ -241,6 +292,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
}
_readyLiveOwners.Clear();
_liveProfiles.Clear();
_initialPresentationBarriers.Clear();
_pendingByServerGuid.Clear();
_dirtyLiveOwners.Clear();
_dirtyLiveOwnerOrder.Clear();
@ -455,6 +507,11 @@ public sealed class EntityEffectController : IAnimationHookSink,
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)
{
if (_posePublishLocalId == localId)

View file

@ -842,6 +842,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// between two loaded buckets. Suppress those implementation details
// and publish only the final logical visibility edge.
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;
uint priorRebucketingGuid = _rebucketingGuid;
_rebucketingGuid = serverGuid;

View file

@ -107,7 +107,7 @@ public sealed class ProjectileControllerTests
Assert.Null(record.AnimationRuntime);
var remote =
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
remote.Body.Position = entity.Position;
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
remote.Body.Orientation = entity.Rotation;
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
@ -1006,6 +1006,8 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
var remote =
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
WorldEntity entity = record.WorldEntity!;
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
@ -1081,7 +1083,10 @@ public sealed class ProjectileControllerTests
Assert.Equal(new Vector3(0f, 0f, 2f), remote.Body.Omega);
remote.Body.set_velocity(new Vector3(8f, 0f, 0f));
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;
PhysicsBody body = remote.Body;
@ -1142,7 +1147,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
var remote =
fixture.Live.GetOrCreateRemoteMotionRuntime(Guid);
remote.Body.Position = entity.Position;
remote.Body.SnapToCell(CellA, entity.Position, entity.Position);
remote.Body.Orientation = entity.Rotation;
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);
}
[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]
public void SharedRemoteMotionCell_DelegatesToCanonicalLiveRecord()
{

View file

@ -239,6 +239,50 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
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]
public void LivePhysicsStaticOwner_DiscardsLongNonResidentIntervalOnReentry()
{

View file

@ -9,6 +9,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@ -129,6 +130,31 @@ public sealed class EntityEffectControllerTests
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(
uint tableDid = TableDid,
uint rawDefaultType = RawType,
@ -187,6 +213,32 @@ public sealed class EntityEffectControllerTests
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]
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
{

View file

@ -2190,16 +2190,23 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources());
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);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.Equal(0x01010022u, record.FullCellId);
Assert.Equal(0x0102FFFFu, record.CanonicalLandblockId);
Assert.Equal(0x01010022u, entity.ParentCellId);
Assert.Equal(0x01010022u, entity.EffectCellId);
runtime.RebucketLiveEntity(guid, 0x01020033u);
Assert.Equal(0x01020033u, record.FullCellId);
Assert.Equal(0x01020033u, entity.ParentCellId);
Assert.Equal(0x01020033u, entity.EffectCellId);
}
[Fact]