fix(runtime): restore interaction completion ownership

Preserve prepublication local motion completion, require the PartArray enter-world lifecycle port, and balance deferred Use busy ownership across dispatch and cancellation. Reconcile the completed GameWindow connected gates and add regression coverage.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 05:51:51 +02:00
parent 5c955c36ec
commit f9736ece6c
26 changed files with 675 additions and 68 deletions

View file

@ -0,0 +1,22 @@
namespace AcDream.App.World;
/// <summary>
/// Strongly typed retail PartArray world-entry boundary.
/// </summary>
/// <remarks>
/// <c>CPhysicsObj::set_hidden @ 0x00514C60</c> invokes
/// <c>CPartArray::HandleEnterWorld @ 0x00517D70</c> on both Hidden edges.
/// This dedicated port prevents that mandatory lifecycle callback from being
/// confused with neighboring <see cref="Action{T}"/> presentation callbacks.
/// </remarks>
public sealed class LiveEntityPartArrayEnterWorldPort
{
private readonly Action<uint> _handleEnterWorld;
public LiveEntityPartArrayEnterWorldPort(Action<uint> handleEnterWorld) =>
_handleEnterWorld = handleEnterWorld
?? throw new ArgumentNullException(nameof(handleEnterWorld));
public void HandleEnterWorld(uint localEntityId) =>
_handleEnterWorld(localEntityId);
}

View file

@ -27,7 +27,7 @@ public sealed class LiveEntityPresentationController : IDisposable
private readonly Action<uint> _clearInvalidTarget;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<uint>? _onShadowRestored;
private readonly Action<uint> _handlePartArrayEnterWorld;
private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
@ -38,20 +38,21 @@ public sealed class LiveEntityPresentationController : IDisposable
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows,
Func<uint, uint, float, bool> playTyped,
LiveEntityPartArrayEnterWorldPort partArrayEnterWorld,
Action<uint, bool>? setDirectChildrenNoDraw = null,
Action<uint>? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
Action<uint>? onShadowRestored = null,
Action<uint>? handlePartArrayEnterWorld = null)
Action<uint>? onShadowRestored = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
_partArrayEnterWorld = partArrayEnterWorld
?? throw new ArgumentNullException(nameof(partArrayEnterWorld));
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
_liveCenter = liveCenter ?? (() => (0, 0));
_onShadowRestored = onShadowRestored;
_handlePartArrayEnterWorld = handlePartArrayEnterWorld ?? (_ => { });
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
@ -262,7 +263,7 @@ public sealed class LiveEntityPresentationController : IDisposable
// timeline boundary: it strips link animations and
// aborts every pending completion through
// MotionTableManager::HandleEnterWorld @ 0x0051BDD0.
_handlePartArrayEnterWorld(entity.Id);
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
_clearInvalidTarget(record.ServerGuid);
@ -279,7 +280,7 @@ public sealed class LiveEntityPresentationController : IDisposable
return false;
// Retail invokes the same PartArray boundary before
// CObjCell::unhide_object restores cell visibility.
_handlePartArrayEnterWorld(entity.Id);
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
bool restored = !IsPlacementActive(record)