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

@ -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]