diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 99948acb..8d945ca1 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 3c71623d..e07af556 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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. --- diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md index 2aba5560..711e4798 100644 --- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md +++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ed34c4bd..c8a0257e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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, diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs index 62ca502a..fd2747d7 100644 --- a/src/AcDream.App/World/LiveEntityPresentationController.cs +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -27,6 +27,7 @@ public sealed class LiveEntityPresentationController : IDisposable private readonly Action _clearInvalidTarget; private readonly Func<(int X, int Y)> _liveCenter; private readonly Action? _onShadowRestored; + private readonly Action _handlePartArrayEnterWorld; private readonly Dictionary _readyGenerationByGuid = new(); private readonly Dictionary _suspendedShadowGenerationByGuid = new(); private readonly Dictionary _activePlacementGenerationByGuid = new(); @@ -39,7 +40,8 @@ public sealed class LiveEntityPresentationController : IDisposable Action? setDirectChildrenNoDraw = null, Action? clearInvalidTarget = null, Func<(int X, int Y)>? liveCenter = null, - Action? onShadowRestored = null) + Action? onShadowRestored = null, + Action? 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; diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index 19ca05b3..1d3b93dd 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -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(dats.Get(humanSetup)); + MotionTable table = Assert.IsType(dats.Get(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 InvalidTargets { get; } = []; + public List PartArrayEnterWorld { get; } = []; + public List PartArrayShadowCounts { get; } = []; + public List 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? 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(