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:
parent
75acae02d6
commit
bfb4b26dff
6 changed files with 185 additions and 21 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# acdream — strategic roadmap
|
# 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.
|
**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.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.
|
- **✓ 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.**
|
- **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.
|
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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** —
|
teleport-OUT arrival cascade residual (**#145-residual, REOPENED** —
|
||||||
capture-harness-first).
|
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
|
temporary black tunnel cover is retired. F751 transit now renders retail's
|
||||||
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
|
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
|
||||||
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
|
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.
|
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
|
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
|
non-advancing post-network pass reconciles children and attached effect/light
|
||||||
anchors after authoritative root moves. This prevents ACE's teleport Hidden state
|
anchors after authoritative root moves. The accepted Hidden and UnHide paths
|
||||||
from freezing the final fraction of recall and replaying it after materialize,
|
now also port `CPhysicsObj::set_hidden @ 0x00514C60` completely. Hidden invokes
|
||||||
without an arrival reset or Hidden-time animation exception. Automated recall
|
the PartArray boundary after its effect, child visibility, collision stop, and
|
||||||
DAT conformance is green; destination-side visual confirmation remains.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -300,21 +300,45 @@ SmartBox.UseTime:
|
||||||
|
|
||||||
The exact call sites are `Client::UseTime` `0x00411C40` and
|
The exact call sites are `Client::UseTime` `0x00411C40` and
|
||||||
`SmartBox::UseTime` `0x00455410`, and `CommandInterpreter::UseTime`
|
`SmartBox::UseTime` `0x00455410`, and `CommandInterpreter::UseTime`
|
||||||
`0x006B3BF0`. `SmartBox::DoSetState` `0x004520D0`
|
`0x006B3BF0`. `SmartBox::DoSetState` `0x004520D0` applies the accepted
|
||||||
applies Hidden directly and does not advance or reset animation. Separately,
|
state through `CPhysicsObj::set_state` `0x00514DD0`. Separately,
|
||||||
`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update`
|
`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update`
|
||||||
while Hidden. Therefore the ordering boundary is load-bearing: the current
|
while Hidden, so ordinary animation never advances behind the portal viewport.
|
||||||
frame's animation must advance before an inbound Hidden transition can freeze
|
|
||||||
it.
|
|
||||||
|
|
||||||
ACE schedules lifestone recall teleport after the MotionTable-reported action
|
ACE schedules lifestone recall teleport after the MotionTable-reported action
|
||||||
length. With the installed human DATs, the boundary is about 15.06024 seconds;
|
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
|
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
|
0..149 recall node if it dispatches Hidden before the current object tick. On
|
||||||
UnHide that microscopic remainder becomes a visible destination-side recall
|
UnHide that microscopic remainder becomes a visible destination-side recall
|
||||||
tail. Retail's object-before-network order retires the node first. The faithful
|
tail if Hidden only suppresses drawing. Retail has a second, decisive boundary:
|
||||||
fix is that frame barrier, never an arrival-only Ready/reset command and never
|
`CPhysicsObj::set_hidden` `0x00514C60` invokes
|
||||||
advancing PartArray while Hidden.
|
`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
|
## acdream integration translation
|
||||||
|
|
||||||
|
|
@ -355,6 +379,11 @@ advancing PartArray while Hidden.
|
||||||
by that inbound pass is likewise projected without receiving a late physics
|
by that inbound pass is likewise projected without receiving a late physics
|
||||||
tick; its first normal object tick occurs on the next update.
|
tick; its first normal object tick occurs on the next update.
|
||||||
This is update-loop ordering, not portal-specific animation logic.
|
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
|
- Destination residency remains acdream's asynchronous adaptation. It supplies
|
||||||
the same `EndTeleportAnimation` edge retail receives when its blocking cell
|
the same `EndTeleportAnimation` edge retail receives when its blocking cell
|
||||||
load completes; it does not alter presentation ordering.
|
load completes; it does not alter presentation ordering.
|
||||||
|
|
|
||||||
|
|
@ -2577,7 +2577,12 @@ public sealed class GameWindow : IDisposable
|
||||||
entityEffects.PlayTyped,
|
entityEffects.PlayTyped,
|
||||||
_equippedChildRenderer.SetDirectChildrenNoDraw,
|
_equippedChildRenderer.SetDirectChildrenNoDraw,
|
||||||
ClearTargetForHiddenEntity,
|
ClearTargetForHiddenEntity,
|
||||||
() => (_liveCenterX, _liveCenterY));
|
() => (_liveCenterX, _liveCenterY),
|
||||||
|
handlePartArrayEnterWorld: localEntityId =>
|
||||||
|
{
|
||||||
|
if (_animatedEntities.TryGetValue(localEntityId, out var animated))
|
||||||
|
animated.Sequencer?.Manager.HandleEnterWorld();
|
||||||
|
});
|
||||||
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
|
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
|
||||||
_physicsEngine,
|
_physicsEngine,
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ public sealed class LiveEntityPresentationController : IDisposable
|
||||||
private readonly Action<uint> _clearInvalidTarget;
|
private readonly Action<uint> _clearInvalidTarget;
|
||||||
private readonly Func<(int X, int Y)> _liveCenter;
|
private readonly Func<(int X, int Y)> _liveCenter;
|
||||||
private readonly Action<uint>? _onShadowRestored;
|
private readonly Action<uint>? _onShadowRestored;
|
||||||
|
private readonly Action<uint> _handlePartArrayEnterWorld;
|
||||||
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
|
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
|
||||||
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
|
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
|
||||||
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
|
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
|
||||||
|
|
@ -39,7 +40,8 @@ public sealed class LiveEntityPresentationController : IDisposable
|
||||||
Action<uint, bool>? setDirectChildrenNoDraw = null,
|
Action<uint, bool>? setDirectChildrenNoDraw = null,
|
||||||
Action<uint>? clearInvalidTarget = null,
|
Action<uint>? clearInvalidTarget = null,
|
||||||
Func<(int X, int Y)>? liveCenter = 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));
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||||
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||||
|
|
@ -48,6 +50,7 @@ public sealed class LiveEntityPresentationController : IDisposable
|
||||||
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
|
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
|
||||||
_liveCenter = liveCenter ?? (() => (0, 0));
|
_liveCenter = liveCenter ?? (() => (0, 0));
|
||||||
_onShadowRestored = onShadowRestored;
|
_onShadowRestored = onShadowRestored;
|
||||||
|
_handlePartArrayEnterWorld = handlePartArrayEnterWorld ?? (_ => { });
|
||||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,12 +226,22 @@ public sealed class LiveEntityPresentationController : IDisposable
|
||||||
_shadows.Suspend(entity.Id);
|
_shadows.Suspend(entity.Id);
|
||||||
if (!IsPlacementActive(record))
|
if (!IsPlacementActive(record))
|
||||||
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
|
_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);
|
_clearInvalidTarget(record.ServerGuid);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case RetailHiddenTransition.BecameVisible:
|
case RetailHiddenTransition.BecameVisible:
|
||||||
_playTyped(entity.Id, UnHideScriptType, 1f);
|
_playTyped(entity.Id, UnHideScriptType, 1f);
|
||||||
_setDirectChildrenNoDraw(record.ServerGuid, false);
|
_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))
|
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
|
||||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
using AcDream.App.World;
|
using AcDream.App.World;
|
||||||
|
using AcDream.Content.Vfx;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.World;
|
namespace AcDream.App.Tests.World;
|
||||||
|
|
||||||
|
|
@ -36,6 +40,11 @@ public sealed class LiveEntityPresentationControllerTests
|
||||||
fixture.TypedPlays);
|
fixture.TypedPlays);
|
||||||
Assert.Equal([(Fixture.Guid, true)], fixture.ChildNoDraw);
|
Assert.Equal([(Fixture.Guid, true)], fixture.ChildNoDraw);
|
||||||
Assert.Equal([Fixture.Guid], fixture.InvalidTargets);
|
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.Equal(0, fixture.Shadows.TotalRegistered);
|
||||||
Assert.False(fixture.Entity.IsDrawVisible);
|
Assert.False(fixture.Entity.IsDrawVisible);
|
||||||
|
|
||||||
|
|
@ -52,11 +61,87 @@ public sealed class LiveEntityPresentationControllerTests
|
||||||
],
|
],
|
||||||
fixture.TypedPlays);
|
fixture.TypedPlays);
|
||||||
Assert.Equal([(Fixture.Guid, true), (Fixture.Guid, false)], fixture.ChildNoDraw);
|
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.Equal(1, fixture.Shadows.TotalRegistered);
|
||||||
Assert.True(fixture.Entity.IsDrawVisible);
|
Assert.True(fixture.Entity.IsDrawVisible);
|
||||||
Assert.Same(fixture.Entity, Assert.Single(fixture.Runtime.WorldEntities).Value);
|
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]
|
[Fact]
|
||||||
public void IdenticalOrStaleState_DoesNotReplayHiddenEffect()
|
public void IdenticalOrStaleState_DoesNotReplayHiddenEffect()
|
||||||
{
|
{
|
||||||
|
|
@ -76,6 +161,7 @@ public sealed class LiveEntityPresentationControllerTests
|
||||||
Assert.Single(fixture.TypedPlays);
|
Assert.Single(fixture.TypedPlays);
|
||||||
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
|
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
|
||||||
fixture.TypedPlays[0].Type);
|
fixture.TypedPlays[0].Type);
|
||||||
|
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -166,13 +252,18 @@ public sealed class LiveEntityPresentationControllerTests
|
||||||
public List<(uint Owner, uint Type, float Intensity)> TypedPlays { get; } = [];
|
public List<(uint Owner, uint Type, float Intensity)> TypedPlays { get; } = [];
|
||||||
public List<(uint Parent, bool NoDraw)> ChildNoDraw { get; } = [];
|
public List<(uint Parent, bool NoDraw)> ChildNoDraw { get; } = [];
|
||||||
public List<uint> InvalidTargets { 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 GpuWorldState Spatial { get; }
|
||||||
public LiveEntityRuntime Runtime { get; }
|
public LiveEntityRuntime Runtime { get; }
|
||||||
public ShadowObjectRegistry Shadows { get; } = new();
|
public ShadowObjectRegistry Shadows { get; } = new();
|
||||||
public WorldEntity Entity { get; }
|
public WorldEntity Entity { get; }
|
||||||
public LiveEntityPresentationController Controller { get; }
|
public LiveEntityPresentationController Controller { get; }
|
||||||
|
|
||||||
public Fixture(PhysicsStateFlags initialState)
|
public Fixture(
|
||||||
|
PhysicsStateFlags initialState,
|
||||||
|
Action<uint>? onPartArrayEnterWorld = null)
|
||||||
{
|
{
|
||||||
Spatial = new GpuWorldState();
|
Spatial = new GpuWorldState();
|
||||||
Spatial.AddLandblock(new LoadedLandblock(
|
Spatial.AddLandblock(new LoadedLandblock(
|
||||||
|
|
@ -214,11 +305,27 @@ public sealed class LiveEntityPresentationControllerTests
|
||||||
(owner, type, intensity) =>
|
(owner, type, intensity) =>
|
||||||
{
|
{
|
||||||
TypedPlays.Add((owner, type, intensity));
|
TypedPlays.Add((owner, type, intensity));
|
||||||
|
PresentationOrder.Add($"effect:{type:X2}");
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
(parent, noDraw) => ChildNoDraw.Add((parent, noDraw)),
|
(parent, noDraw) =>
|
||||||
InvalidTargets.Add,
|
{
|
||||||
() => (1, 1));
|
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(
|
internal static WorldSession.EntitySpawn Spawn(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue