fix(world): retire motion links at hidden transitions

Port CPhysicsObj::set_hidden's PartArray HandleEnterWorld boundary on both Hidden and UnHide. This strips linked recall/casting animations and aborts their pending completions before cell visibility changes, preventing spell-recall tails after portal travel without arrival resets or action classifiers.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-16 07:39:34 +02:00
parent 75acae02d6
commit bfb4b26dff
6 changed files with 185 additions and 21 deletions

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-07-15. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, authored effect-indicator buttons, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
**Status:** Living document. Updated 2026-07-16. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, authored effect-indicator buttons, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -593,7 +593,7 @@ Research: R9 + R12 + R13.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/view-plane transitions, forward-axis roll, direct portal→world viewport switch, projection warp, destination viewer reset, object/physics-before-network frame barrier, pre-network input movement/jump output, post-inbound AutonomousPosition, and non-advancing spatial reconciliation; recall-tail corrective visual gate pending 2026-07-16). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end dungeon round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/view-plane transitions, forward-axis roll, direct portal→world viewport switch, projection warp, destination viewer reset, object/physics-before-network frame barrier, pre-network input movement/jump output, post-inbound AutonomousPosition, non-advancing spatial reconciliation, and retail `set_hidden` PartArray timeline teardown on both Hidden and UnHide; `/ls` and spell-recall corrective visual gates passed 2026-07-16, with non-blocking visual polish deferred). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end dungeon round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.

View file

@ -301,7 +301,8 @@ recorded end-to-end dungeon round-trip user gate, plus the far-town
teleport-OUT arrival cascade residual (**#145-residual, REOPENED** —
capture-harness-first).
**2026-07-15 portal presentation completion (visual gate pending).** The
**2026-07-15 portal presentation completion (single-client visual gate passed
2026-07-16; non-blocking polish deferred).** The
temporary black tunnel cover is retired. F751 transit now renders retail's
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
@ -317,10 +318,19 @@ object, projectile, animation, final-pose, animation-hook, particle, and
owner-script work completes before the inbound network queue is dispatched.
Local movement/jump output is committed on that same pre-network side, while a
non-advancing post-network pass reconciles children and attached effect/light
anchors after authoritative root moves. This prevents ACE's teleport Hidden state
from freezing the final fraction of recall and replaying it after materialize,
without an arrival reset or Hidden-time animation exception. Automated recall
DAT conformance is green; destination-side visual confirmation remains.
anchors after authoritative root moves. The accepted Hidden and UnHide paths
now also port `CPhysicsObj::set_hidden @ 0x00514C60` completely. Hidden invokes
the PartArray boundary after its effect, child visibility, collision stop, and
cell hide; UnHide invokes it after effect/child/collision restoration but before
the cell becomes visible. Both route through `CPartArray::HandleEnterWorld`
`MotionTableManager::HandleEnterWorld`. That
strips every link animation and aborts pending motion completions before a
spell-cast recoil or recall remainder can survive portal space. This is the
generic retail visibility boundary, not a recall classifier, an arrival reset,
or a Hidden-time animation exception. Installed-DAT conformance covers both
the Lifestone recall boundary and a MagicPortal casting timeline. The user
confirmed `/ls` and spell-recall arrivals no longer replay their animation
tails; additional portal visual polish is explicitly deferred.
---

View file

@ -300,21 +300,45 @@ SmartBox.UseTime:
The exact call sites are `Client::UseTime` `0x00411C40` and
`SmartBox::UseTime` `0x00455410`, and `CommandInterpreter::UseTime`
`0x006B3BF0`. `SmartBox::DoSetState` `0x004520D0`
applies Hidden directly and does not advance or reset animation. Separately,
`0x006B3BF0`. `SmartBox::DoSetState` `0x004520D0` applies the accepted
state through `CPhysicsObj::set_state` `0x00514DD0`. Separately,
`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update`
while Hidden. Therefore the ordering boundary is load-bearing: the current
frame's animation must advance before an inbound Hidden transition can freeze
it.
while Hidden, so ordinary animation never advances behind the portal viewport.
ACE schedules lifestone recall teleport after the MotionTable-reported action
length. With the installed human DATs, the boundary is about 15.06024 seconds;
floating-point conversion can leave acdream at frame 149.999998 of the
0..149 recall node if it dispatches Hidden before the current object tick. On
UnHide that microscopic remainder becomes a visible destination-side recall
tail. Retail's object-before-network order retires the node first. The faithful
fix is that frame barrier, never an arrival-only Ready/reset command and never
advancing PartArray while Hidden.
tail if Hidden only suppresses drawing. Retail has a second, decisive boundary:
`CPhysicsObj::set_hidden` `0x00514C60` invokes
`CPartArray::HandleEnterWorld` `0x00517D70` on **both** Hidden and UnHide.
That delegates to `MotionTableManager::HandleEnterWorld` `0x0051BDD0`, which
removes every link animation from the sequence and drains every pending
completion as aborted. On the Hidden edge it runs after the Hidden PES, child
NoDraw propagation, collision stop, and cell hide. On UnHide it runs after
collision restoration and before the cell becomes visible. A casting recoil,
recall remainder, or authored stance transition therefore cannot resume after
portal space.
ACE also has timing differences when recall starts in combat. Its
`Player_Location` handlers call `SetCombatMode(NonCombat)` and immediately send
the NonCombat/Ready/recall command state. House, Lifestone, Allegiance Hometown,
and PK Arena schedule `Teleport` using only
`MotionTable.GetAnimationLength(recall)`. Retail
`MovementManager::unpack_movement` (`0x00524440`) and
`CMotionInterp::move_to_interpreted_state` (`0x005289C0`) preserve the authored
combat-to-NonCombat link before the recall action. For a human leaving Magic
stance that link is about 0.31 seconds, but ACE does not add it to the teleport
delay. The retail Hidden boundary intentionally tolerates that: whichever
authored links remain when teleport begins are retired by `set_hidden`, not
carried through the tunnel.
Marketplace is a separate ACE residual: its handler deliberately uses a fixed
14-second delay and comments out `GetAnimationLength` because the installed
animation is approximately 18.4 seconds. That can shorten the visible source-
side action, but the client must still apply the same retail Hidden teardown;
no Marketplace-specific classifier or portal-exit behavior is required.
## acdream integration translation
@ -355,6 +379,11 @@ advancing PartArray while Hidden.
by that inbound pass is likewise projected without receiving a late physics
tick; its first normal object tick occurs on the next update.
This is update-loop ordering, not portal-specific animation logic.
- `LiveEntityPresentationController` owns the complete accepted Hidden/UnHide
side-effect order. Both edges route the current local entity to its existing
`AnimationSequencer.Manager.HandleEnterWorld`, matching retail's PartArray
boundary for player, creature, and object animations without classifying the
preceding action.
- Destination residency remains acdream's asynchronous adaptation. It supplies
the same `EndTeleportAnimation` edge retail receives when its blocking cell
load completes; it does not alter presentation ordering.

View file

@ -2577,7 +2577,12 @@ public sealed class GameWindow : IDisposable
entityEffects.PlayTyped,
_equippedChildRenderer.SetDirectChildrenNoDraw,
ClearTargetForHiddenEntity,
() => (_liveCenterX, _liveCenterY));
() => (_liveCenterX, _liveCenterY),
handlePartArrayEnterWorld: localEntityId =>
{
if (_animatedEntities.TryGetValue(localEntityId, out var animated))
animated.Sequencer?.Manager.HandleEnterWorld();
});
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
_physicsEngine,
_liveEntities,

View file

@ -27,6 +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 Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
@ -39,7 +40,8 @@ public sealed class LiveEntityPresentationController : IDisposable
Action<uint, bool>? setDirectChildrenNoDraw = null,
Action<uint>? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
Action<uint>? onShadowRestored = null)
Action<uint>? onShadowRestored = null,
Action<uint>? handlePartArrayEnterWorld = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
@ -48,6 +50,7 @@ public sealed class LiveEntityPresentationController : IDisposable
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
_liveCenter = liveCenter ?? (() => (0, 0));
_onShadowRestored = onShadowRestored;
_handlePartArrayEnterWorld = handlePartArrayEnterWorld ?? (_ => { });
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
@ -223,12 +226,22 @@ public sealed class LiveEntityPresentationController : IDisposable
_shadows.Suspend(entity.Id);
if (!IsPlacementActive(record))
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
// Retail CPhysicsObj::set_hidden @ 0x00514C60 calls
// CPartArray::HandleEnterWorld after hiding the object
// from its cell. Despite the name, this is the motion
// timeline boundary: it strips link animations and
// aborts every pending completion through
// MotionTableManager::HandleEnterWorld @ 0x0051BDD0.
_handlePartArrayEnterWorld(entity.Id);
_clearInvalidTarget(record.ServerGuid);
break;
case RetailHiddenTransition.BecameVisible:
_playTyped(entity.Id, UnHideScriptType, 1f);
_setDirectChildrenNoDraw(record.ServerGuid, false);
// Retail invokes the same PartArray boundary before
// CObjCell::unhide_object restores cell visibility.
_handlePartArrayEnterWorld(entity.Id);
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
break;

View file

@ -1,11 +1,15 @@
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content.Vfx;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit.Sdk;
namespace AcDream.App.Tests.World;
@ -36,6 +40,11 @@ public sealed class LiveEntityPresentationControllerTests
fixture.TypedPlays);
Assert.Equal([(Fixture.Guid, true)], fixture.ChildNoDraw);
Assert.Equal([Fixture.Guid], fixture.InvalidTargets);
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
Assert.Equal([0], fixture.PartArrayShadowCounts);
Assert.Equal(
["effect:76", "children:True", "part-array", "target"],
fixture.PresentationOrder);
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.False(fixture.Entity.IsDrawVisible);
@ -52,11 +61,87 @@ public sealed class LiveEntityPresentationControllerTests
],
fixture.TypedPlays);
Assert.Equal([(Fixture.Guid, true), (Fixture.Guid, false)], fixture.ChildNoDraw);
Assert.Equal(
[fixture.Entity.Id, fixture.Entity.Id],
fixture.PartArrayEnterWorld);
Assert.Equal([0, 0], fixture.PartArrayShadowCounts);
Assert.Equal(
[
"effect:76", "children:True", "part-array", "target",
"effect:75", "children:False", "part-array",
],
fixture.PresentationOrder);
Assert.Equal(1, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Entity.IsDrawVisible);
Assert.Same(fixture.Entity, Assert.Single(fixture.Runtime.WorldEntities).Value);
}
[Fact]
public void SpellRecall_HiddenAndUnHide_RetireMagicTimelineThroughController()
{
const uint humanSetup = 0x02000001u;
const uint humanMotionTable = 0x09000001u;
const uint magic = 0x80000049u;
const uint ready = 0x41000003u;
const uint magicPortal = 0x40000038u;
string datDir = @"C:\Turbine\Asheron's Call";
if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
throw SkipException.ForSkip("Installed retail DATs are required.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(humanSetup));
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(humanMotionTable));
var sequencer = new AnimationSequencer(
setup,
table,
new RetailAnimationLoader(dats));
var fixture = new Fixture(
PhysicsStateFlags.ReportCollisions,
_ => sequencer.Manager.HandleEnterWorld());
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
sequencer.SetCycle(magic, ready);
sequencer.PlayAction(magicPortal);
Assert.False(sequencer.CurrentNodeDiag.IsLooping);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(
Fixture.Guid,
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
1,
2),
out _,
out _));
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
Assert.True(sequencer.CurrentNodeDiag.IsLooping);
Assert.Empty(sequencer.Manager.PendingAnimations);
// A motion accepted while the mesh is hidden is retired again at
// retail's matching UnHide PartArray boundary before cell visibility
// returns.
var unhideSequencer = new AnimationSequencer(
setup,
table,
new RetailAnimationLoader(dats));
var unhideFixture = new Fixture(
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions,
_ => unhideSequencer.Manager.HandleEnterWorld());
Assert.True(unhideFixture.Controller.OnLiveEntityReady(Fixture.Guid));
unhideSequencer.SetCycle(magic, ready);
unhideSequencer.PlayAction(magicPortal);
Assert.False(unhideSequencer.CurrentNodeDiag.IsLooping);
Assert.True(unhideFixture.Runtime.TryApplyState(
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
out _,
out _));
Assert.True(unhideFixture.Controller.OnStateAccepted(Fixture.Guid));
Assert.True(unhideSequencer.CurrentNodeDiag.IsLooping);
Assert.Empty(unhideSequencer.Manager.PendingAnimations);
Assert.Equal([0], fixture.PartArrayShadowCounts);
Assert.Equal([0, 0], unhideFixture.PartArrayShadowCounts);
Assert.Equal(1, unhideFixture.Shadows.TotalRegistered);
}
[Fact]
public void IdenticalOrStaleState_DoesNotReplayHiddenEffect()
{
@ -76,6 +161,7 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Single(fixture.TypedPlays);
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
fixture.TypedPlays[0].Type);
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
}
[Fact]
@ -166,13 +252,18 @@ public sealed class LiveEntityPresentationControllerTests
public List<(uint Owner, uint Type, float Intensity)> TypedPlays { get; } = [];
public List<(uint Parent, bool NoDraw)> ChildNoDraw { get; } = [];
public List<uint> InvalidTargets { get; } = [];
public List<uint> PartArrayEnterWorld { get; } = [];
public List<int> PartArrayShadowCounts { get; } = [];
public List<string> PresentationOrder { get; } = [];
public GpuWorldState Spatial { get; }
public LiveEntityRuntime Runtime { get; }
public ShadowObjectRegistry Shadows { get; } = new();
public WorldEntity Entity { get; }
public LiveEntityPresentationController Controller { get; }
public Fixture(PhysicsStateFlags initialState)
public Fixture(
PhysicsStateFlags initialState,
Action<uint>? onPartArrayEnterWorld = null)
{
Spatial = new GpuWorldState();
Spatial.AddLandblock(new LoadedLandblock(
@ -214,11 +305,27 @@ public sealed class LiveEntityPresentationControllerTests
(owner, type, intensity) =>
{
TypedPlays.Add((owner, type, intensity));
PresentationOrder.Add($"effect:{type:X2}");
return true;
},
(parent, noDraw) => ChildNoDraw.Add((parent, noDraw)),
InvalidTargets.Add,
() => (1, 1));
(parent, noDraw) =>
{
ChildNoDraw.Add((parent, noDraw));
PresentationOrder.Add($"children:{noDraw}");
},
guid =>
{
InvalidTargets.Add(guid);
PresentationOrder.Add("target");
},
() => (1, 1),
handlePartArrayEnterWorld: localEntityId =>
{
PartArrayEnterWorld.Add(localEntityId);
PartArrayShadowCounts.Add(Shadows.TotalRegistered);
PresentationOrder.Add("part-array");
onPartArrayEnterWorld?.Invoke(localEntityId);
});
}
internal static WorldSession.EntitySpawn Spawn(