diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index fecdc1a7..62f57d6d 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -397,8 +397,11 @@ replacement, including present-zero clearing. Core `PhysicsScriptRunner` mirrors serial FIFO per owner: duplicate plays append, owners progress independently, `CallPES` uses an injected uniform delay, and every hook traverses the shared `AnimationHookRouter`. Existing queues on cell-less or Frozen live roots do -not advance; a new play for an already-created cell-less object is dropped as in -retail. Attached children advance through their eligible parent. Every effect +not advance; a new ordinary F754/F755/default play for an already-created +cell-less object is dropped as in retail. Hidden/UnHide is the retail exception: +`CPhysicsObj::set_hidden` resolves the typed script and calls +`play_script_internal`, queuing it until owner eligibility returns. Attached +children advance through their eligible parent. Every effect resource uses the canonical, globally unique `WorldEntity.Id`; static allocators fail before their namespace can wrap. `EntityEffectPoseRegistry` publishes the final root and indexed rigid animated-part transforms after animation diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 8d945ca1..9fba3b24 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -531,6 +531,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ Wave 4.6 item giving (starter-dungeon live gate passed 2026-07-13).** SmartBox drag release now preserves the exact release coordinates, world-picks the target, and runs the existing retail `ItemHolder::AttemptPlaceIn3D` policy. NPC/creature targets send exact `GiveObjectRequest 0x00CD(target,item,amount)` bytes, selected partial stacks use the shared split quantity, and the object table waits for ACE's authoritative remove/stack-size response. `PlayerDescription.Options1` supplies the real `DragItemOnPlayerOpensSecureTrade` option. Issue #216; research: `docs/research/2026-07-13-retail-give-item-pseudocode.md`. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. - **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary. +- **Missile/portal VFX cell-less rule clarified 2026-07-16.** The Steps 0–5 statement that later plays drop while an existing owner is cell-less applies only to ordinary F754/F755/default playback. Hidden/UnHide is retail's internal exception: `CPhysicsObj::set_hidden` resolves the typed script and calls `play_script_internal`, queuing it until scheduler eligibility returns. This preserves the human table's purple portal materialization across asynchronous destination placement. - **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. - **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. `MagicCatalog` now projects all 6,266 end-of-retail SpellTable records into the shared Core metadata table; the incomplete 3,956-row CSV is test/research-only and AP-17 is retired. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The favorite bar now ports retail horizontal empty-cell padding, shared UIItem 1–9 shortcut overlays, and the Cast button's three independently reflowed DAT face segments. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`, `docs/research/2026-07-15-retail-spell-catalog-pseudocode.md`. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index e07af556..e9e84748 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -524,8 +524,10 @@ include dungeons. routes default/part-default hooks through the complete hook fan-out. The update-frame clock is published before packet/default dispatch and republished before animation hooks. Existing cell-less/Frozen queues pause and resume with - retail eligibility, while new plays on an already-created cell-less owner drop; - attached children advance through their parent. Every sink shares canonical + retail eligibility, while new ordinary F754/F755/default plays on an + already-created cell-less owner drop. Hidden/UnHide bypasses that ordinary + gate through retail `set_hidden` -> `play_script_internal`, queuing until + eligibility returns. Attached children advance through their parent. Every sink shares canonical `WorldEntity.Id`, and static allocator overflow fails before aliasing another landblock. Live SoundTable ownership is installed independently of animation eligibility and replaced/cleared on each PhysicsDesc application; DAT statics 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 711e4798..a3614471 100644 --- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md +++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md @@ -271,6 +271,14 @@ PlayerTeleported(): SendMovementEvent() ``` +The numeric `0x3c` is the internal `ITeleported` completion status, not a +player-visible error. `ClientCommunicationSystem::HandleFailureEvent` +`0x00571990` has a visible case for `0x3d` (`YouChargedTooFar`) but no case for +`0x3b` (`ILeftTheWorld`) or `0x3c`; both fall through without adding scroll +text. ACE also sends `ITeleported` after a successful portal use. acdream must +consume those two control statuses silently rather than formatting an unknown +`WeenieError` line. + The viewer reset copies the player's complete position into both `viewer` and `viewer_sought_position`. Subsequent ordinary camera updates extend the chase boom outward through the same damped and swept path used after camera @@ -384,6 +392,12 @@ no Marketplace-specific classifier or portal-exit behavior is required. `AnimationSequencer.Manager.HandleEnterWorld`, matching retail's PartArray boundary for player, creature, and object animations without classifying the preceding action. +- Hidden/UnHide typed scripts use retail's internal path, not ordinary + `CPhysicsObj::play_script` (`0x00513260`). `set_hidden` resolves the current + PhysicsScriptTable and calls `play_script_internal` even when the object has + no current cell. The serial script remains paused until destination re-entry; + this preserves table `0x34000004`'s purple Hidden emitters across asynchronous + portal placement. Ordinary F755/default-hook playback retains the cell gate. - 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 c8a0257e..ffb432bb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2574,7 +2574,7 @@ public sealed class GameWindow : IDisposable _liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController( _liveEntities, _physicsEngine.ShadowObjects, - entityEffects.PlayTyped, + entityEffects.PlayTypedFromHiddenTransition, _equippedChildRenderer.SetDirectChildrenNoDraw, ClearTargetForHiddenEntity, () => (_liveCenterX, _liveCenterY), @@ -3496,6 +3496,9 @@ public sealed class GameWindow : IDisposable ? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}" : "no-pos"; string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; + string physicsTableStr = spawn.Physics?.PhysicsScriptTableId is { } pe + ? $"0x{pe:X8}" + : "no-petable"; string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype"; int animPartCount = spawn.AnimPartChanges?.Count ?? 0; @@ -3503,7 +3506,8 @@ public sealed class GameWindow : IDisposable int subPalCount = spawn.SubPalettes?.Count ?? 0; Console.WriteLine( $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + - $"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); + $"petable={physicsTableStr} itemType={itemTypeStr} animParts={animPartCount} " + + $"texChanges={texChangeCount} subPalettes={subPalCount}"); } // Target the statue specifically for full diagnostic dump: Name match diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index 792cc8d6..95da8d45 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -256,8 +256,36 @@ public sealed class EntityEffectController : IAnimationHookSink public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity) { - if (!CanStartOwner(ownerLocalId) - || !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile) + // Retail CPhysicsObj::play_script @ 0x00513260 does not enqueue for a + // cell-less object. Network F755/default-hook playback comes through + // this ordinary path. + if (!CanStartOwner(ownerLocalId)) + return false; + return ResolveAndQueueTyped(ownerLocalId, rawScriptType, intensity); + } + + /// + /// Queues a typed script from CPhysicsObj::set_hidden without the + /// ordinary play_script cell gate. + /// + /// + /// Retail set_hidden at 0x00514C60 resolves Hidden/UnHide + /// directly and calls play_script_internal. This is load-bearing + /// during portal travel: the owner may already be cell-less while its + /// purple materialization script must remain queued for destination entry. + /// + public bool PlayTypedFromHiddenTransition( + uint ownerLocalId, + uint rawScriptType, + float intensity) + => ResolveAndQueueTyped(ownerLocalId, rawScriptType, intensity); + + private bool ResolveAndQueueTyped( + uint ownerLocalId, + uint rawScriptType, + float intensity) + { + if (!_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile) || profile.CurrentPhysicsScriptTableDid is not { } tableDid) { DiagnosticSink?.Invoke( diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index f02364ff..e6294895 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -139,6 +139,9 @@ public sealed class ChatLog /// public void OnWeenieError(uint errorId, string? param) { + if (WeenieErrorMessages.IsSilentClientControlStatus(errorId)) + return; + // Phase I (post-launch fix): translate the wire code into the // retail-faithful template via WeenieErrorMessages. Many codes // are *informational* (e.g. 0x051B "You have entered the X diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index c6bf7342..a238922e 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -32,6 +32,20 @@ namespace AcDream.Core.Chat; /// public static class WeenieErrorMessages { + /// + /// Returns true for internal client-control statuses that retail consumes + /// without adding a line to the chat scroll. + /// + /// + /// ClientCommunicationSystem::HandleFailureEvent + /// (0x00571990) has no display case for ILeftTheWorld + /// (0x003B) or ITeleported (0x003C). ACE sends the latter + /// after a successful portal use; presenting it as a failure is therefore + /// an acdream-only artifact. + /// + public static bool IsSilentClientControlStatus(uint errorCode) => + errorCode is 0x003Bu or 0x003Cu; + /// /// Format a WeenieError / WeenieErrorWithString into a human-readable /// system-message string. diff --git a/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs b/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs index a53af9e4..3bf42778 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs @@ -153,6 +153,20 @@ public sealed class RetailParticleGeometryClassifierTests } } + [Fact] + public void InstalledHumanEffectTable_MapsPortalVisibilityScripts() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + throw SkipException.ForSkip("Installed client_portal.dat is required."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var resolver = new PhysicsScriptTableResolver(id => dats.Get(id)); + Assert.Equal(0x33000332u, resolver.Resolve(0x34000004u, 0x74u, 1f)); + Assert.Equal(0x3300032Fu, resolver.Resolve(0x34000004u, 0x75u, 1f)); + Assert.Equal(0x33000331u, resolver.Resolve(0x34000004u, 0x76u, 1f)); + } + private static string? ResolveDatDir() { string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs index 4b46cea9..c9328029 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -222,6 +222,30 @@ public sealed class EntityEffectControllerTests Assert.Equal([1u], EmitterIds(fixture.Sink)); } + [Fact] + public void CelllessOwner_HiddenTransitionQueuesTypedScriptUntilReentry() + { + var fixture = new Fixture(); + WorldEntity entity = fixture.ReadyLive(); + Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(Guid)); + + // set_hidden resolves the table and calls play_script_internal + // directly; it does not use CPhysicsObj::play_script's cell gate. + Assert.True(fixture.Controller.PlayTypedFromHiddenTransition( + entity.Id, + RawType, + 0.5f)); + Assert.Equal(1, fixture.Runner.ActiveScriptCount); + + fixture.Runner.Tick(1.0); + Assert.Empty(fixture.Sink.Calls); + + Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x01010001u)); + fixture.Runner.Tick(1.0); + + Assert.Equal([2u], EmitterIds(fixture.Sink)); + } + [Fact] public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt() { diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 96b69868..6e93239d 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -518,6 +518,21 @@ public sealed class GameEventWiringTests Assert.Contains("0x009C", e.Text); } + [Theory] + [InlineData(0x003Bu)] // ILeftTheWorld + [InlineData(0x003Cu)] // ITeleported + public void WireAll_RetailSilentClientControlStatus_DoesNotReachChat(uint code) + { + var (dispatcher, _, _, _, chat) = MakeAll(); + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, code); + + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.WeenieError, payload))!.Value); + + Assert.Equal(0, chat.Count); + } + [Fact] public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation() { diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs index 7647939a..04b778f5 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs @@ -152,6 +152,18 @@ public sealed class ChatLogTests Assert.Contains("Mana Stone", e.Text); } + [Theory] + [InlineData(0x003Bu)] // ILeftTheWorld + [InlineData(0x003Cu)] // ITeleported + public void OnWeenieError_RetailSilentClientControlStatus_DoesNotAppend(uint code) + { + var log = new ChatLog(); + + log.OnWeenieError(code, param: null); + + Assert.Empty(log.Snapshot()); + } + [Fact] public void OnLocalSpeech_EmptySender_SubstitutesYou() {