From f24532adf33427c774f997fa9e92074e4905c534 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 12:10:21 +0200 Subject: [PATCH] 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. --- docs/ISSUES.md | 57 ++++++++++--- .../LivePresentationComposition.cs | 33 +++++++- .../Physics/ProjectileController.cs | 79 +++++++++++++------ .../DatLiveEntityProjectionMaterializer.cs | 33 ++++++-- .../RetailStaticAnimatingObjectScheduler.cs | 31 +++++++- .../Rendering/Vfx/EntityEffectController.cs | 57 +++++++++++++ src/AcDream.App/World/LiveEntityRuntime.cs | 12 +++ .../Physics/ProjectileControllerTests.cs | 60 +++++++++++++- ...tailStaticAnimatingObjectSchedulerTests.cs | 44 +++++++++++ .../Vfx/EntityEffectControllerTests.cs | 52 ++++++++++++ .../World/LiveEntityRuntimeTests.cs | 9 ++- 11 files changed, 417 insertions(+), 50 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d098eb42..64357a9d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 7a4be2fd..f9244fde 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -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>(3) + Action>(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, diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index b083e00d..1538fe2a 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -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, diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 440c2900..13760355 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -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 => diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 9874090c..ca3f1b8a 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -53,6 +53,9 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram private readonly Func _isResident; private readonly Action _commitLiveRoot; private readonly Func _residencyVersion; + private readonly Func? + _resolveLiveOwner; private readonly Dictionary _owners = new(); private readonly List _snapshot = new(); private readonly List _hookSnapshot = new(); @@ -63,7 +66,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram Action, IReadOnlyList> publishPartPoses, Func? isResident = null, Action? commitLiveRoot = null, - Func? residencyVersion = null) + Func? residencyVersion = null, + Func? + 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)) { diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index 1d42cb5d..5a769558 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -36,6 +36,14 @@ public sealed class EntityEffectController : IAnimationHookSink, private readonly Action _ownerSoundTableChanged; private readonly Dictionary _liveProfiles = []; private readonly HashSet _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 _initialPresentationBarriers = []; private readonly Dictionary> _pendingByServerGuid = new(); private readonly Dictionary _staticOwners = new(); private readonly Dictionary _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; } + /// + /// 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. + /// + 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; + } + /// Replays the mixed F754/F755 FIFO after full object construction. 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) diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index c708c8d2..e0d88d4d 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -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; diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index 9d3041e9..e26fb000 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 0ed92521..7cfc97c7 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs index 32ed7f8b..1ad1ec21 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -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( + Runtime.RegisterLiveEntity(spawn).Canonical); + WorldEntity entity = Assert.IsType( + 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() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 7baa11ef..4aa33ce9 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -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]