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:
parent
bfb4b26dff
commit
4b1bceefbb
12 changed files with 142 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ public sealed class ChatLog
|
|||
/// </remarks>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,20 @@ namespace AcDream.Core.Chat;
|
|||
/// </summary>
|
||||
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>
|
||||
/// Format a WeenieError / WeenieErrorWithString into a human-readable
|
||||
/// system-message string.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue