fix(world): preserve portal materialization effects

Route accepted Hidden and UnHide transitions through retail's internal typed-script path so their DAT scripts queue while the owner is cell-less and resume at the destination. Consume ACE's successful teleport control statuses silently, matching retail HandleFailureEvent.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-16 08:14:48 +02:00
parent bfb4b26dff
commit 4b1bceefbb
12 changed files with 142 additions and 8 deletions

View file

@ -397,8 +397,11 @@ replacement, including present-zero clearing. Core `PhysicsScriptRunner` mirrors
serial FIFO per owner: duplicate plays append, owners progress independently, serial FIFO per owner: duplicate plays append, owners progress independently,
`CallPES` uses an injected uniform delay, and every hook traverses the shared `CallPES` uses an injected uniform delay, and every hook traverses the shared
`AnimationHookRouter`. Existing queues on cell-less or Frozen live roots do `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 not advance; a new ordinary F754/F755/default play for an already-created
retail. Attached children advance through their eligible parent. Every effect 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 resource uses the canonical, globally unique `WorldEntity.Id`; static
allocators fail before their namespace can wrap. `EntityEffectPoseRegistry` allocators fail before their namespace can wrap. `EntityEffectPoseRegistry`
publishes the final root and indexed rigid animated-part transforms after animation publishes the final root and indexed rigid animated-part transforms after animation

View file

@ -531,6 +531,7 @@ behavior. Estimated 1726 days focused work, 35 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`. - **✓ 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`. - **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 05 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 campaign Steps 05 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 05 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 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. - **✓ 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 19 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`. - **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 19 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`.

View file

@ -524,8 +524,10 @@ include dungeons.
routes default/part-default hooks through the complete hook fan-out. The routes default/part-default hooks through the complete hook fan-out. The
update-frame clock is published before packet/default dispatch and republished update-frame clock is published before packet/default dispatch and republished
before animation hooks. Existing cell-less/Frozen queues pause and resume with 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; retail eligibility, while new ordinary F754/F755/default plays on an
attached children advance through their parent. Every sink shares canonical 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 `WorldEntity.Id`, and static allocator overflow fails before aliasing another
landblock. Live SoundTable ownership is installed independently of animation landblock. Live SoundTable ownership is installed independently of animation
eligibility and replaced/cleared on each PhysicsDesc application; DAT statics eligibility and replaced/cleared on each PhysicsDesc application; DAT statics

View file

@ -271,6 +271,14 @@ PlayerTeleported():
SendMovementEvent() 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 The viewer reset copies the player's complete position into both `viewer` and
`viewer_sought_position`. Subsequent ordinary camera updates extend the chase `viewer_sought_position`. Subsequent ordinary camera updates extend the chase
boom outward through the same damped and swept path used after camera 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 `AnimationSequencer.Manager.HandleEnterWorld`, matching retail's PartArray
boundary for player, creature, and object animations without classifying the boundary for player, creature, and object animations without classifying the
preceding action. 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 - 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.

View file

@ -2574,7 +2574,7 @@ public sealed class GameWindow : IDisposable
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController( _liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
_liveEntities, _liveEntities,
_physicsEngine.ShadowObjects, _physicsEngine.ShadowObjects,
entityEffects.PlayTyped, entityEffects.PlayTypedFromHiddenTransition,
_equippedChildRenderer.SetDirectChildrenNoDraw, _equippedChildRenderer.SetDirectChildrenNoDraw,
ClearTargetForHiddenEntity, ClearTargetForHiddenEntity,
() => (_liveCenterX, _liveCenterY), () => (_liveCenterX, _liveCenterY),
@ -3496,6 +3496,9 @@ public sealed class GameWindow : IDisposable
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}" ? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
: "no-pos"; : "no-pos";
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; 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 nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype"; string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
int animPartCount = spawn.AnimPartChanges?.Count ?? 0; int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
@ -3503,7 +3506,8 @@ public sealed class GameWindow : IDisposable
int subPalCount = spawn.SubPalettes?.Count ?? 0; int subPalCount = spawn.SubPalettes?.Count ?? 0;
Console.WriteLine( Console.WriteLine(
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + $"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 // Target the statue specifically for full diagnostic dump: Name match

View file

@ -256,8 +256,36 @@ public sealed class EntityEffectController : IAnimationHookSink
public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity) public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity)
{ {
if (!CanStartOwner(ownerLocalId) // Retail CPhysicsObj::play_script @ 0x00513260 does not enqueue for a
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile) // cell-less object. Network F755/default-hook playback comes through
// this ordinary path.
if (!CanStartOwner(ownerLocalId))
return false;
return ResolveAndQueueTyped(ownerLocalId, rawScriptType, intensity);
}
/// <summary>
/// Queues a typed script from <c>CPhysicsObj::set_hidden</c> without the
/// ordinary <c>play_script</c> cell gate.
/// </summary>
/// <remarks>
/// Retail <c>set_hidden</c> at <c>0x00514C60</c> resolves Hidden/UnHide
/// directly and calls <c>play_script_internal</c>. 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.
/// </remarks>
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) || profile.CurrentPhysicsScriptTableDid is not { } tableDid)
{ {
DiagnosticSink?.Invoke( DiagnosticSink?.Invoke(

View file

@ -139,6 +139,9 @@ public sealed class ChatLog
/// </remarks> /// </remarks>
public void OnWeenieError(uint errorId, string? param) public void OnWeenieError(uint errorId, string? param)
{ {
if (WeenieErrorMessages.IsSilentClientControlStatus(errorId))
return;
// Phase I (post-launch fix): translate the wire code into the // Phase I (post-launch fix): translate the wire code into the
// retail-faithful template via WeenieErrorMessages. Many codes // retail-faithful template via WeenieErrorMessages. Many codes
// are *informational* (e.g. 0x051B "You have entered the X // are *informational* (e.g. 0x051B "You have entered the X

View file

@ -32,6 +32,20 @@ namespace AcDream.Core.Chat;
/// </summary> /// </summary>
public static class WeenieErrorMessages public static class WeenieErrorMessages
{ {
/// <summary>
/// Returns true for internal client-control statuses that retail consumes
/// without adding a line to the chat scroll.
/// </summary>
/// <remarks>
/// <c>ClientCommunicationSystem::HandleFailureEvent</c>
/// (<c>0x00571990</c>) has no display case for ILeftTheWorld
/// (<c>0x003B</c>) or ITeleported (<c>0x003C</c>). ACE sends the latter
/// after a successful portal use; presenting it as a failure is therefore
/// an acdream-only artifact.
/// </remarks>
public static bool IsSilentClientControlStatus(uint errorCode) =>
errorCode is 0x003Bu or 0x003Cu;
/// <summary> /// <summary>
/// Format a WeenieError / WeenieErrorWithString into a human-readable /// Format a WeenieError / WeenieErrorWithString into a human-readable
/// system-message string. /// system-message string.

View file

@ -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<PhysicsScriptTable>(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() private static string? ResolveDatDir()
{ {
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");

View file

@ -222,6 +222,30 @@ public sealed class EntityEffectControllerTests
Assert.Equal([1u], EmitterIds(fixture.Sink)); 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] [Fact]
public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt() public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt()
{ {

View file

@ -518,6 +518,21 @@ public sealed class GameEventWiringTests
Assert.Contains("0x009C", e.Text); 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] [Fact]
public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation() public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation()
{ {

View file

@ -152,6 +152,18 @@ public sealed class ChatLogTests
Assert.Contains("Mana Stone", e.Text); 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] [Fact]
public void OnLocalSpeech_EmptySender_SubstitutesYou() public void OnLocalSpeech_EmptySender_SubstitutesYou()
{ {