acdream/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
Erik 75acae02d6 fix(world): complete recall before teleport hide
Match retail's update ordering so object animation, particles, and scripts advance before inbound teleport state is applied. Separate input-originated movement from post-network autonomous position output, and reconcile presentation without a second physics tick so recall cannot resume after arrival.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-16 06:37:29 +02:00

60 KiB

Retail projectile, PhysicsScript, and portal-effect pseudocode

Date: 2026-07-13 Build: Sept 2013 EoR retail client, matching named PDB/decomp Purpose: Oracle for the acdream inbound missile and DAT-driven VFX port.

This note translates the named retail functions into implementation-oriented pseudocode before any C# port. Addresses refer to docs/research/named-retail/acclient_2013_pseudo_c.txt. Retail remains the behavioral oracle; ACE, ACViewer, and Holtburger are interpretation and wire cross-checks only.

1. PhysicsDesc wire order

Oracle: PhysicsDesc::UnPack at 0x0051DDD0.

unpack PhysicsDesc:
    destroy prior owned buffers
    bitfield = read u32
    state    = read u32

    if bitfield has Movement (0x00010000):
        length = read u32
        movementBytes = read length bytes
        if length > 0: autonomousMovement = read u32
    else if bitfield has AnimationFrame (0x00020000):
        animationFrame = read u32

    if Position               (0x00008000): position = unpack Position
    if MTable                 (0x00000002): motionTableId = read DID
    if STable                 (0x00000800): soundTableId = read DID
    if PeTable                (0x00001000): physicsScriptTableId = read DID
    if CSetup                 (0x00000001): setupId = read DID
    if Parent                 (0x00000020): parentGuid, locationId = read u32, u32
    if Children               (0x00000040):
        count = read u32
        repeat count: childGuid, childLocationId = read u32, u32
    if ObjScale               (0x00000080): objectScale = read f32
    if Friction               (0x00000100): friction = read f32
    if Elasticity             (0x00000200): elasticity = read f32
    if Translucency           (0x00040000): translucency = read f32
    if Velocity               (0x00000004): velocity = read vec3 f32
    if Acceleration           (0x00000008): acceleration = read vec3 f32
    if Omega                  (0x00000010): omega = read vec3 f32
    if DefaultScript          (0x00002000): defaultScriptType = read u32
    if DefaultScriptIntensity (0x00004000): defaultIntensity = read f32

    timestamps = read exactly nine u16 values in this order:
        Position, Movement, State, Vector, Teleport,
        ServerControlledMove, ForcePosition, ObjDesc, Instance
    align cursor to four bytes

Absent optional values are semantically different from present zero values and must remain nullable in the parsed representation. ACE's PhysicsDescriptionFlag, and Holtburger's object-description reader, confirm the same order and nine-value timestamp tail.

2. Installing a PhysicsDesc

Oracle: CPhysicsObj::set_description at 0x00514F40 and CPhysicsObj::InitDefaults at 0x005139D0.

InitDefaults(setup):
    if setup.DefaultScript != 0: play that direct PhysicsScript DID
    if setup.DefaultMotionTable != 0: install it
    if setup.DefaultSoundTable != 0: replace sound table
    if setup.DefaultPhysicsScriptTable != 0: replace typed-effect table
    if object is static:
        set HasDefaultAnim / HasDefaultScript as appropriate
        register static animation when either applies

set_description(desc):
    install motion table

    release current sound table
    if desc.SoundTable != 0: load it

    release current PhysicsScriptTable unconditionally
    if desc.PeTable != 0: load it

    install movement buffer or placement frame
    set_state(desc.State, applySideEffects=true)
    install scale, valid friction, elasticity, translucency
    set_velocity(desc.Velocity)
    install desc.Omega
    install typed default-script type and intensity
    copy all nine timestamps into the object's update-time array

The network PeTable therefore overrides the Setup table even when the network value is absent/zero: a live object does not fall back to the Setup table after set_description. The network acceleration vector is unpacked but is not installed here; retail recalculates normal acceleration from physics state.

3. PhysicsState values and state transitions

The values cross-check exactly with ACE's PhysicsState enum:

Static=0x1, Ethereal=0x4, ReportCollisions=0x8,
IgnoreCollisions=0x10, NoDraw=0x20, Missile=0x40,
Pushable=0x80, AlignPath=0x100, PathClipped=0x200,
Gravity=0x400, Lighting=0x800, ParticleEmitter=0x1000,
Hidden=0x4000, ScriptedCollision=0x8000,
HasPhysicsBsp=0x10000, Inelastic=0x20000,
HasDefaultAnim=0x40000, HasDefaultScript=0x80000,
Cloaked=0x100000, ReportAsEnvironment=0x200000,
EdgeSlide=0x400000, Sledding=0x800000, Frozen=0x1000000

Oracle: CPhysicsObj::set_state at 0x00514DD0 and CPhysicsObj::set_hidden at 0x00514C60.

set_state(next):
    changed = current XOR next
    current = next

    if Lighting changed:
        initialize or destroy part-array lights
    if NoDraw changed:
        apply set_nodraw before Hidden handling
    if Hidden changed:
        set_hidden(next has Hidden)

set_hidden(true):
    set Hidden
    resolve PS_Hidden at intensity 1 through current table; enqueue it
    set every attached child NoDraw and update its part array
    if ReportCollisions: end reporting and clear the bit
    set IgnoreCollisions
    hide root object in its cell/render world

set_hidden(false):
    clear Hidden
    resolve PS_UnHide at intensity 1 through current table; enqueue it
    clear every attached child's NoDraw, unconditionally as retail does
    clear IgnoreCollisions
    if ReportCollisions is clear: set it and start reporting
    enter/unhide root part array and cell object

CPhysicsObj::CPhysicsObj at 0x005123F0 initializes state to 0x00400C08 (the assignment is at 0x00512508). That value is EdgeSlide | Lighting | Gravity | ReportCollisions; it does not contain Hidden (0x00004000). Consequently, an ordinary initially-visible CreateObject does not call set_hidden and must not manufacture an UnHide script. UnHide occurs only for a real state-bit transition from Hidden to visible, while an explicit F755 UnHide remains a separate typed effect packet. Hidden is never object destruction: scripts and particles continue to exist while the mesh and interaction surface are suppressed.

SmartBox::DoSetState at 0x004520D0 applies the state only when its u16 timestamp is newer using the retail wrap-safe comparison.

The same comparator is used by State, Vector, Movement, Position, and related timestamp-gated channels (SmartBox::DoVectorUpdate at 0x004521C0 is the second executable cross-check):

is_newer(incoming:u16, stored:u16):
    distance = abs((int)incoming - (int)stored)
    if distance > 0x7FFF:
        return incoming < stored
    return stored < incoming

Equality is stale. Up through a distance of 0x7FFF, the numerically higher stamp wins. At exactly 0x8000 and throughout the wrap half, the numerically lower stamp wins. This exact boundary must be ported; a generic half-range subtraction helper can choose the opposite result at 0x8000.

3.1 Event freshness and object generations

Additional oracles:

  • SmartBox::HandleReceivedPosition at 0x00453FD0
  • SmartBox::HandleObjDescEvent at 0x00453340 and SmartBox::UpdateVisualDesc at 0x00451F40
  • SmartBox::HandleDeleteObject at 0x00451EA0
on CreateObject(desc):
    if no live generation: copy all nine desc timestamps wholesale
    else if desc.Instance is newer: begin a new generation and replace all nine
    else if current Instance is newer: reject the stale CreateObject
    else: keep the object and route each PhysicsDesc branch through its
          ordinary per-channel handler; do not authorize the whole snapshot

on State / Vector / Movement / ObjDesc:
    require the exact live Instance
    require a strictly newer channel timestamp
    apply only after both gates pass

on DeleteObject:
    reject unconditionally when guid is the local player
    require the exact live Instance before either object projection is removed

on ParentEvent(parent, child):
    require the parent's exact live Instance
    strictly advance the child's Position timestamp
    apply parent + placement without creating a second position counter

on PickupEvent(object):
    require the exact live Instance
    strictly advance the shared Position timestamp
    unparent and leave the world, retaining the object and its timestamps

on Position(localPlayer):
    require the exact live Instance
    if ForcePosition is newer:
        store ForcePosition
        if incoming Teleport exactly equals stored Teleport:
            assign Position directly, even when equal or older
            preserve the current heading
            do not advance Teleport
            BlipPlayer via SetPositionSimple (preserve active motion, velocity,
            contact state, and PositionManager stick)
            if Contact AND OnWalkable are set and position is valid:
                send PositionEvent immediately from the post-blip canonical Position
            return

    tentatively advance Position, requiring strictly newer
    if stored Teleport is newer than incoming Teleport:
        restore the old Position timestamp and reject
    if incoming Teleport is newer: advance Teleport
    PositionPack supplies placement 0 and velocity (0,0,0) when their flags are absent
    unset the parent
    if the object has no active animations: apply the decoded placement id
    if object is remote: MoveOrTeleport receives the decoded velocity
    else if Teleport advanced: teleport player and explicitly zero velocity
    else: constrain/interpolate without installing packet velocity
    apply the position

PositionPack::UnPack at 0x00516740 is the source of the absent-as-zero defaults; retaining a prior placement or remote velocity when the wire flag is absent is not retail behavior. The immutable parsed type still preserves presence so diagnostics and conformance tests can distinguish absent from an explicit zero. HandleReceivedPosition consumes those defaults only after the timestamp gate admits the normal path. ForcePosition returns before placement or packet velocity is consumed and therefore preserves the player's live heading and velocity.

SmartBox::HandlePlayerTeleport at 0x00452150 handles standalone F751:

on PlayerTeleport(sequence):
    if sequence is older than current Teleport timestamp: reject
    equality and newer sequences are accepted
    enter the waiting/portal state
    do not advance Teleport here

The accepted destination Position packet advances TELEPORT_TS. Parsing F751 must therefore never publish its sequence directly into outbound AP state.

Retail queues a packet whose Instance belongs to a not-yet-created future generation. ParentAttachmentState now does so for ParentEvent, retaining the parent generation through atomic object replacement. Until LiveEntityRuntime owns the complete mixed pending queue, acdream still drops future Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets without touching the current generation (AD-32); critically, it never adopts the future Instance into the current object.

4. Direct and typed effect packets

Oracles:

  • SmartBox::HandlePlayScriptID at 0x00452020
  • SmartBox::HandlePlayScriptType at 0x00452070
F754 direct packet: targetGuid:u32, physicsScriptDid:u32
F755 typed packet:  targetGuid:u32, scriptType:u32, intensity:f32

on either packet:
    if target object is not materialized:
        QueueBlobForObject(targetGuid, whole packet) and return queued
    if F754: play direct PhysicsScript DID
    if F755: resolve type+intensity through target's current table, then play

The queue preserves packet order. F754 never goes through the table. F755 must preserve unknown raw type values and the incoming float bits until resolution. ACE's GameMessageScript confirms the 16-byte opcode+GUID+type+float F755 wire shape.

Synthetic, non-DAT packet fixtures fixed for the parser port are:

F754: 54 F7 00 00 | 42 00 00 50 | 99 00 00 33
      opcode          GUID 50000042  script DID 33000099

F755: 55 F7 00 00 | 42 00 00 50 | A5 A5 A5 A5 | 34 12 C0 7F
      opcode          GUID 50000042  unknown raw type  NaN payload bits

Step 1 consumes these in parser tests so exact packet lengths and raw float bits are exercised. Step 3 constructs synthetic raw Animation and PhysicsScript fixtures through the compatibility-reader tests; no copyrighted installed-DAT bytes are committed.

5. PhysicsScriptTable selection

Oracles:

  • PhysicsScriptTable::GetScript at 0x00521E40
  • PhysicsScriptTableData::GetScript at 0x00521A20
resolve(type, intensity):
    data = hash lookup by exact type
    if absent: invalid DID
    for entry in stored DAT order:
        if intensity <= entry.Mod:
            return entry.ScriptId
    return invalid DID

Mod is an upper threshold, not a random weight. Do not sort entries and do not fall back to the first. Equality matches. Matching x86 at 0x00521A3B uses test ah, 0x41; jnp return: one tested status bit means intensity is less than the entry (C0) or equal to it (C3), while unordered/NaN sets both tested bits and continues. NaN therefore resolves to no script; an intensity above every stored threshold also resolves to no script. Consequently negative infinity selects the first finite threshold, while positive infinity and NaN select none; the resolver must preserve and compare the incoming float rather than blanket-rejecting all non-finite values.

6. ScriptManager queue

Oracles:

  • ScriptManager::AddScriptInternal at 0x0051B310
  • ScriptManager::NextHook at 0x0051B3F0
  • ScriptManager::UpdateScripts at 0x0051B480

PhysicsScript::UnPack at 0x005218B0 first sorts its decoded hook records by ascending StartTime with PhysicsScriptData::Sort at 0x00521600. This sort applies to PhysicsScript hook time, not to PhysicsScriptTable Mod thresholds (which must remain in stored DAT order).

add(script):
    node = new ScriptData
    node.start = queue empty
        ? Timer.current
        : tail.start + tail.script.length
    append node, including duplicate script/owner pairs
    if it became the head:
        hookIndex = -1
        nextHookTime = node.start + firstHook.start

update:
    while a current node exists and currentTime >= nextHookTime:
        hook = NextHook()
        if hook exists:
            execute hook against owner object
        else:
            pop and release completed head
            initialize next node or mark queue empty

Each owner has one serial FIFO, not a deduplicated set or a concurrent list. Different owners have independent queues. A long frame executes all hooks that became due, in order.

7. CallPES and default-script hooks

Oracle: CPhysicsObj::CallPES at 0x00511AF0.

CallPES(scriptDid, maximumPause):
    if maximumPause < 0.0002 seconds:
        if object is in a cell: enqueue script immediately
    else:
        delay = uniformRandom(0, maximumPause)
        insert a timed CALL_PES object hook at the head of the object-hook list
        when due, enqueue the PhysicsScript on this owner's FIFO

The timed object-hook list is traversed head-to-next. Multiple delayed CallPES hooks are therefore observed newest-first until their individually randomized times become due; this is separate from the PhysicsScript owner's serial FIFO.

DefaultScriptHook resolves the owner's PhysicsDesc default type/intensity. DefaultScriptPartHook locates the child attached at the requested parent part and resolves the child's own table/default, not the parent's.

8. Particle emitter semantics

Oracles:

  • CreateBlockingParticleHook::Execute at 0x00526EF0
  • ParticleManager::CreateBlockingParticleEmitter at 0x0051B8A0

CreateBlockingParticle inherits the complete CreateParticle payload:

emitterInfoDid, partIndex, offsetFrame(position+orientation), logicalEmitterId
normal CreateParticle:
    nonzero logical id replaces the existing live emitter with that id
    zero id creates a new anonymous emitter

blocking CreateParticle:
    if nonzero logical id names a live emitter: do nothing and return 0
    otherwise create exactly as normal

"Blocking" does not pause the animation sequencer. The current Chorizite.DatReaderWriter model omits this inherited payload and can misalign the following hook, so acdream needs one centralized raw-hook compatibility reader for PhysicsScript and Animation files. Ordinary hook types remain delegated to the package decoder.

The complete offset Frame must be decoded and retained for schema fidelity, but retail particle emission consumes only its origin. Particle::Init at 0x0051C930 adds that origin to the particle start point and transforms the translation plus emitter vectors through the current owner part/root frame; it does not read the hook-offset quaternion. ParticleEmitter::UpdateParticles at 0x0051D180 then chooses either the saved start frame or the current live part/root frame according to the DAT parenting mode.

Emitter attachment is therefore evaluated as:

ownerFrame = partIndex == -1 ? rootWorld : animatedPartLocal * rootWorld
anchorPosition = transformPoint(hookOffset.origin, ownerFrame)

The hook-offset quaternion is not applied to particle emission unless a different retail hook consumer proves otherwise. For non-parent-local particles, only future emissions follow a moving anchor; already-born particles remain in world space. Parent-local particles continue to follow their owner.

9. Per-frame physics and projectile flags

Oracles:

  • CPhysicsObj::UpdatePositionInternal at 0x00512C30
  • CPhysicsObj::UpdateObjectInternal at 0x005156B0
  • CPhysicsObj::update_object at 0x00515D10
  • CPhysicsObj::UpdatePhysicsInternal at 0x00510700
  • CPhysicsObj::calc_acceleration at 0x00510950
  • CPhysicsObj::get_object_info at 0x00511CC0
  • Frame::set_vector_heading at 0x00535DB0
update_object:
    if parented, out of world, or Frozen:
        clear transient Active and return without an object tick
    elapsed = Timer.current - update_time
    PhysicsTimer.current = update_time
    if elapsed <= 0.0002 seconds:
        update_time = Timer.current
        return
    if elapsed > 2 seconds:
        update_time = Timer.current
        return
    while elapsed > MAX_QUANTUM (1/5 second):
        PhysicsTimer.current += MAX_QUANTUM
        UpdateObjectInternal(MAX_QUANTUM)
        elapsed -= MAX_QUANTUM
    if elapsed > MIN_QUANTUM (1/30 second):
        PhysicsTimer.current += elapsed
        UpdateObjectInternal(elapsed)
    update_time = PhysicsTimer.current

UpdateObjectInternal(dt):
    UpdatePositionInternal(dt, candidateFrame)
    if Setup has one or more physics spheres:
        align path when applicable
        sweep/transition from committed frame to candidate frame
        commit transition result and update children
    else:
        commit candidate frame directly; do not create a dummy-sphere sweep
    update detection/target/movement/position managers
    ParticleManager.UpdateParticles()
    ScriptManager.UpdateScripts()

UpdatePositionInternal(dt, candidateFrame):
    unless Hidden:
        advance part array / animation and produce its frame offset
    apply PositionManager offset even when Hidden
    combine object root frame with animation offset
    unless Hidden: UpdatePhysicsInternal(dt, candidateFrame)
    process timed object hooks even when Hidden

UpdatePhysicsInternal(dt, frame):
    speedSquared = dot(velocity, velocity)
    if speedSquared > 0:
        if speedSquared > 50^2: normalize velocity and clamp to 50
        calculate friction using dt and the clamped pre-friction speedSquared
        if speedSquared < (0.25^2 + 0.0002): zero velocity
        frame.position += velocity*dt + 0.5*acceleration*dt^2
    velocity += acceleration*dt
    frame.rotation = globalRotate(frame.rotation, omega*dt)

calc_acceleration:
    if transient has Contact AND OnWalkable AND state lacks Sledding:
        acceleration=0 and omega=0
    else if Gravity is clear: acceleration=0 (omega is preserved)
    else acceleration=(0,0,PhysicsGlobals.gravity)

The Sledding constant (0x00800000) in calc_acceleration is confirmed from matching-binary disassembly at 0x00510961; the named lift misidentified it as a string address. The near-stop test is deliberately based on the original or 50-unit-clamped speedSquared, not a speed recomputed after friction.

The same matching executable resolves the update_object globals that the named lift displays as zero: MAX_QUANTUM is 0.2 seconds and MIN_QUANTUM is 1/30 second. Thus a long but valid frame is replayed as 0.2-second catch-up steps, a final remainder at or below 1/30 second stays accumulated for the next tick, and a gap above 2 seconds is discarded instead of simulating a huge projectile jump.

AlignPath uses the full normalized 3D movement vector through Frame::set_vector_heading; it is not yaw-only.

get_object_info adds transition flag 0x8 when PhysicsState.Missile is set. That is PathClipped behavior. It does not add PerfectClip, so ordinary missiles must not implicitly arm PerfectClip.

10. Missile collision exclusion

Oracle: OBJECTINFO::missile_ignore at 0x0050CEB0.

missile_ignore(other):
    if other is a missile: ignore
    if moving object is not a missile: do not ignore
    if other.id == designatedTargetId: do not ignore
    if other is ethereal and has a weenie: ignore
    if designatedTargetId != 0 and other is a creature: ignore
    otherwise collide

The moving object's target ID initializes to zero. The client must not infer it from the currently selected UI target.

Retail builds the transition sweep from Setup physics spheres when present, scaled by object scale. Although the lower-level transition routine has a dummy-sphere path, ordinary UpdateObjectInternal does not call transition when GetNumSphere() == 0; it commits the frame without a collision sweep. Projectile shape selection must therefore come from Setup data, not a mesh-size guess or a synthesized dummy radius.

11. Installed-DAT and fixture audit

The installed DAT files at C:\Turbine\Asheron's Call are private interoperability inputs and are not copied into the repository. The audit is reproducible with:

dotnet run --project tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj -c Release

The command is a conformance gate: missing required assets, changed hashes or sphere shapes, parse failures, a changed recall-motion closure, or a changed raw CreateBlocking inventory produce a nonzero exit code.

The projectile WCID mapping comes from the local ACE weenie corpus at C:\Users\erikn\source\repos\acdream\references\weenies(single file).json by selecting WCIDs 300, 305, 7276-7282, and 23144 and reading DID properties Setup (1) and PhysicsEffectTable (22).

WCID Name Setup PhysicsScriptTable
300 Arrow 0x02000124 0x3400002B
305 Quarrel / crossbow bolt 0x0200012A 0x3400002B
7276 Acid Stream 0x020003F6 0x34000082
7277 Flame Bolt 0x0200040D 0x34000005
7278 Force Bolt 0x0200087D none in template
7279 Frost Bolt 0x020003F4 0x34000080
7280 Lightning Bolt 0x02000880 0x34000081
7281 Shockwave 0x020003FA none in template
7282 Whirling Blade 0x020003FC none in template
23144 Tusker Fist 0x02000EAE none in template

11.1 Collision shapes

Every audited projectile Setup has exactly one ordinary sphere and no cylinder spheres. A sphere has a Setup-local origin and radius, not an orientation.

Setups Sphere origin Radius (world units)
Arrow, quarrel, acid, flame, frost, shockwave, whirling blade, Tusker Fist (0, 0, 0) 0.10
Lightning Bolt 0x02000880 (0, 0, 0) 0.15
Force Bolt 0x0200087D (0, -0.165, 0.1045) 0.102

This pins a one-sphere invariant for the required projectile set, but disproves a centered-sphere shortcut: Force Bolt's sphere is offset. Step 6 must preserve the Setup-local origin and multiply both origin and radius by object scale.

11.2 Projectile table order and chains

The relevant entries, in stored order, are:

  • Arrow/quarrel table 0x3400002B: Create (1 -> 0x33000719), Destroy (1 -> 0x33000233), and DisappearDestroy (1 -> 0x33000302). It has no Launch or Explode entry.
  • Flame table 0x34000005: ProjectileCollision (1 -> 0x33000109); Launch (0 -> 0x3300011B, 0.5 -> 0x3300011C, 1 -> 0x3300011D); Explode (0 -> 0x3300011E, 0.5 -> 0x3300012B, 1 -> 0x33000120).
  • Frost 0x34000080: collision 0x33000109, Launch 0x330008BF, Explode 0x33000119, each at threshold 1.
  • Lightning 0x34000081: collision 0x33000109, Launch 0x330008C0, Explode 0x3300013E, each at threshold 1.
  • Acid 0x34000082: collision 0x33000109, Launch 0x330008C2, Explode 0x33000132, each at threshold 1.
  • Tusker Fist has no network/template table, but its Setup 0x02000EAE carries direct DefaultScript=0x33000D77. Setup initialization therefore runs that direct PES: Scale, CreateParticle emitter 0x3200071B on part 0, and SoundTable, all at time zero.

The Launch scripts contain CreateParticle trails plus sound-table hooks; the collision script contains SetLight; Explode scripts contain particles and sound. This confirms that physical and spell missiles use the same generic PhysicsScript/hook sinks rather than a special render-only trail path.

11.3 Recall and Hidden/UnHide

0x10000153 is the ACE/local-DAT motion command, not an Animation DID. In the human MotionTable 0x09000001, the NonCombat link resolves it to Animation 0x030009BF, frames 0..end at 9.96 frames/second. At frame 16 that Animation directly creates emitter 0x3200078E, attached to root part -1 with logical ID 19 and zero offset, then plays SoundTweaked hooks at frames 16, 46, 76, and 116. There is no CallPES in this human recall chain. Other local creature MotionTables resolve the same command to 0x03000047, 0x0300098F, or a 0x03000C34 -> 0x030009BF -> 0x03000C34 sequence.

Installed tables containing both typed transitions group into two asset pairs:

  • Tables 0x34000004, 0x34000064, 0x34000066, 0x34000076, 0x34000095, 0x340000B2, 0x340000B4, 0x340000C9, 0x340000CA, 0x340000CB, 0x340000CD, and 0x340000D1: UnHide (1 -> 0x3300032F), Hidden (1 -> 0x33000331).
  • Tables 0x34000063, 0x340000A9, 0x340000B9, and 0x340000BB: UnHide (1 -> 0x330002E0), Hidden (1 -> 0x330002F3).

This installed metadata also validates the table comparison recovered from x86: state transitions call intensity 1 and the entries have Mod 1, so equality must match.

11.4 CreateBlocking and fixture provenance

An aligned raw retail-layout scan finds CreateBlocking hooks in PhysicsScripts 0x33000AEA through 0x33000AF8 inclusive and none in Animations. The scan validates the type, a retail direction value, and the 0x32xxxxxx emitter DID prefix directly from raw bytes, so the defective stock decoder cannot skip or misalign a following hook. The repository commits only synthetic, noncopyrighted fixtures:

  • tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs: exact F754/F755, a broad projectile-field CreateObject, isolated Movement and AnimationFrame branches, and absent-versus-present-zero PeTable shapes. Step 1 tests include truncation at each gated cursor.
  • tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs: a synthetic raw PhysicsScript and Animation container, each containing a complete CreateBlocking payload followed by an AnimationDone hook at the exact expected cursor. RetailDatLoaderTests verifies both corrected containers, ordinary-hook parity with the package decoder, malformed-entry rejection, and representative installed blocking scripts.

Representative installed raw-entry identities are pinned as metadata, not copied bytes:

DID Length SHA-256
Arrow Setup 0x02000124 812 630D19A9C79137F7BA90D01448B03E8F44D655FF77A4725792CB5660930A079E
Quarrel Setup 0x0200012A 776 ED529E834CC1799B5655549200F7D1060FB62A707988E7B7D4FBED2D9C4BA0D1
Force Bolt Setup 0x0200087D 344 B30469764673F55BCBB4F123559256299C34D819974EAB42AC476E3FEED6EAE8
Flame table 0x34000005 88 86906B0EDDC9AA2242091EAC7A22C88FB2150F0773B6CCA1FA5E4AAEAFF2A386
Recall Animation 0x030009BF 143560 21EF99FE5A760216AF48946B9C41D0FA7E4B94297B8DEC8B91C41BB8C7A0D02D
Blocking PES 0x33000AEA 64 C0D75973ADB55386ED6AC860C6D08EC43862ABE8CE07AC4E942EBA31DAF61320
Tusker Fist default PES 0x33000D77 108 9F27628BDEA6B05C95CFADD037746AB380542DF8B040265EF881B3366CB8E12E

ACE's current SpellProjectile.Setup cross-check confirms that server-created spell projectiles set ReportCollisions, Missile, AlignPath, and PathClipped; rotating projectile definitions instead set omega and clear AlignPath. On impact ACE broadcasts authoritative SetState, typed effect scripts, velocity zero, and eventual deletion. The client therefore predicts presentation but does not invent impact or destruction.

The recall Animation's direct DAT hooks must be executed rather than replaced with a guessed cloud. Hidden/UnHide state transitions and explicit typed scripts remain distinct packet-driven events. A normal visible spawn alone is not evidence for an UnHide materialization effect; the observer portal-in sequence remains a final two-client packet/visual gate.

11.5 Step 8 implementation boundary

The port keeps raw wire state and side-effect-derived final state separate. LiveEntityPresentationController drains accepted state transitions only after the renderer, effect profile, and PhysicsScript owner are ready; pending mixed F754/F755 packets replay after that construction-state drain. Hidden removes the retained entity from drawing, collision, radar, picking, status, and new target acquisition, but does not unregister its record, cell, script queue, particles, lights, or canonical local ID. Attached children receive the exact direct NoDraw mutation.

The Hidden update is a narrow tick, not a frozen entity. In CPhysicsObj::UpdatePositionInternal (0x00512C30) Hidden skips PartArray root-motion/animation advancement and UpdatePhysicsInternal, but still composes PositionManager::adjust_offset and processes timed object hooks. The surrounding UpdateObjectInternal (0x005156B0) then continues the target, movement, and position-manager tails. A moving Hidden local or remote therefore updates its root/cell and effect-pose snapshot without accepting input, gravity, or ordinary body integration.

Retail CObjCell::hide_object (0x0052BE30) also sends DetectionManager LeftDetection. DetectionManager is not yet ported, so TS-49 records the one explicit adaptation: acdream sends the existing TargetManager non-Ok availability edge to tear down MoveTo/Sticky consumers and clears the hidden object's watched-role subscriptions.

Fresh remote TELEPORT_TS, or first placement while the canonical cell is zero, follows MoveOrTeleport Branch A before its contact test:

CancelMoveTo(0x3c)
UnStick
StopInterpolating
UnConstrain
ClearTarget + NotifyVoyeurOfEvent(Teleported)
report_collision_end
SetPosition the same body at received full cell + frame through CTransition
derive Contact/OnWalkable/contact plane and ground-transition state

This order is required even for an airborne packet; the ordinary airborne no-op is only a Branch B/C decision and cannot undo a genuine teleport. A canonical nonzero cell whose spatial projection is unavailable is classified as cell-less for this decision: it cannot take the ordinary interpolation path without a resident cell.

If the destination landblock is pending, RemoteTeleportController parks the authoritative frame without contact, retains the original contact edge, and waits for projection visibility. The request is scoped to live generation and accepted PositionSequence. Every newer accepted Position packet refreshes that same request; hydration collision-seats only the latest sequence, while delete, reset, or GUID reuse discards the old incarnation's placement.

SetPositionInternal captures the old Contact/OnWalkable flags at entry. It writes destination Contact while retaining the source OnWalkable bit for the first calc_acceleration, then invokes set_on_walkable (therefore HitGround/LeaveGround), and only then calls handle_all_collisions at 0x005154FE. Retaining source walkability through that first calculation matters for a grounded source that hydrates onto a steep contact: retail clears grounded angular drift before installing the destination's non-walkable state. The callback may change velocity, so collision response must consume that post-callback velocity while still receiving the captured source flags. This ordering is shared by loaded teleports and local/remote Hidden PositionManager movement. A Hidden projectile that shares the same RemoteMotion body also uses this narrow manager tick once; projectile integration remains paused.

If projection hydration occurs but placement resolution fails, the visibility edge cannot be treated as a retry clock. The controller restores the captured source body frame, full cell, contact state, and projection, then drops the pending request. A resident source restores its shadow immediately; an unloaded source delegates one generation-scoped restore to LiveEntityPresentationController, the same owner used by Hidden/UnHide, until that projection returns. Before any newer accepted placement rebuckets, it transfers the deferred restore into an explicit active-placement generation, including while Hidden. That ownership remains active for the complete pending lifetime, so any number of intervening Hidden/UnHide or visibility edges cannot restore an unresolved pose. Only the controller's stable completion clears it: the placement either restores after successful collision seating, re-defers its captured source after failure, or hands a stable Hidden result back for UnHide. A same-incarnation MovementManager/runtime rebind cannot replace the canonical physics body or discard the typed placement contract. A wrapper replacement around that same body is adopted by the pending request before hydration, so component rebinding cannot strand active-placement ownership. Operational component clearing removes the wrapper, not the incarnation's body identity or placement requirement; only logical teardown releases those invariants. The production wrapper's body is constructor-owned and immutable, and hydration validates both pending/current wrappers against the record's captured body. A malformed dynamic wrapper is detached and the canonical body is rolled back. Every runtime-binding boundary reads the wrapper's Body exactly once, validates that local snapshot, and installs the same reference into the live record. A cell-less source withdraws the projection and publishes the final invisible presentation edge. Spatial rebucketing removes and places atomically, visibility transitions commit their new set first and drain through a non-reentrant FIFO, and LiveEntityRuntime rejects delayed duplicates against its last logical state. A rollback triggered by a destination-visible callback therefore cannot expose remove/add pulses or be overwritten by the outer notification. A newer loaded destination that fails placement consumes the original pending request and performs the same rollback. Logical teardown is independently failure-isolated across presentation, effects, teleport, movement/target, shadow, and light owners; session reset clears pending teleport state even when another teardown callback throws.

12. Secondary-reference cross-checks

The following secondary implementations were inspected after the named-retail oracle. They confirm wire/schema or server-authority details; none overrides a retail disagreement:

  • ACE Source/ACE.Entity/Enum/PhysicsDescriptionFlag.cs and Source/ACE.Entity/Enum/PhysicsState.cs confirm PhysicsDesc gates and state bits.
  • ACE Source/ACE.Server/Network/GameMessages/Messages/GameMessageScript.cs confirms F755's opcode/GUID/raw type/float layout.
  • ACE Source/ACE.Server/WorldObjects/SpellProjectile.cs confirms the server creates missile bodies and remains authoritative for impact state, effect, vector correction, and deletion.
  • Holtburger crates/holtburger-protocol/src/messages/object/messages/description.rs confirms PhysicsDesc field order and the nine trailing sequences.
  • ACViewer ACViewer/Entity/AnimationHooks/CreateParticleHook.cs and ACViewer/Physics/Scripts/PhysicsScriptTableData.cs were used as schema interpretation aids only; their behavior is not substituted for retail's blocking-emitter or stored-order threshold rules.

Repository links:

13. Port invariants

  1. Server GUID is translated once to a canonical local entity ID before an effect reaches render, audio, light, translucency, or particle sinks.
  2. Logical entity creation/destruction is separate from spatial rebucketing.
  3. Server Position/Vector/State/Delete packets remain authoritative.
  4. No client-side impact, explosion, damage, or deletion is synthesized.
  5. Presentation frame ordering is fixed: advance animation/root motion, publish indexed parts, compose equipped children, drain animation hooks, refresh attached emitters/lights, advance particle simulation, tick owner PhysicsScripts, dispatch inbound network events, run the post-inbound command-interpreter position check, reconcile spatial derivatives without advancing time, then draw. The explicit network boundary ports SmartBox::UseTime @ 0x00455410: CPhysics::UseTime updates every object (including its particle and script managers) before SmartBox drains its inbound queue, after which CommandInterpreter::UseTime @ 0x006B3BF0 evaluates AutonomousPosition. Within each object, CPhysicsObj::UpdateObjectInternal @ 0x005156B0 updates ParticleManager before ScriptManager, so a particle created by a script begins simulation on the following object frame.

Retail stages animation hooks inside CSequence::update, then executes them from CPhysicsObj::process_hooks before the new root is committed. The same update subsequently calls CPhysicsObj::set_frame (0x00514090), which runs CPartArray::SetFrame and UpdateChildrenInternal; that relocates the newly parented emitter/held child to the final pose before ParticleManager advances or anything draws. acdream's explicit publish-then-drain barrier preserves that observable ordering without exposing a half-committed pose to modern sinks. 6. Hidden suppresses mesh/collision/interaction but does not destroy the effect owner. 7. DAT assets determine models, colors, timing, and attachment behavior. 8. Every ported branch receives a conformance test and a named-retail citation.

14. Step 5 implementation map (2026-07-14)

  • EntityEffectPoseRegistry exposes current root and indexed part-local poses through IEntityEffectPoseSource. The indexed channel contains retail's rigid CPhysicsPart frames, not drawable mesh scale: UpdateParts 0x005190F0 multiplies only the animation origin by object scale, while SetScaleInternal 0x00518A00 stores Setup DefaultScale separately on gfxobj_scale. Animated entities publish those rigid frames beside their visually scaled MeshRef composition; equipped children then publish holdingFrame * parentPart * parentRoot plus the child's own indexed part locals. Appearance replacement republishes immediately.
  • AnimationHookFrameQueue captures hooks during sequence advance and drains only after equipped-child composition. AnimationDone and UseTime remain paired with the sequencer even if its render pose vanished during teardown.
  • ParticleHookSink binds each live handle to local owner, exact part index (including root -1), complete offset Frame, logical emitter ID, and render pass. It applies the offset origin through partLocal * rootWorld; the retained offset quaternion remains intentionally unused per Particle::Init 0x0051C930.
  • Normal nonzero logical IDs replace, blocking nonzero IDs suppress while the old emitter remains live, and zero IDs create anonymously. Natural emitter retirement releases the logical ID. StopParticleEmitter retains the ID until that retirement, while destroy removes it immediately. Missing ParticleEmitterInfo DAT records log owner + DID and create nothing; the former synthesized generic cloud is removed.
  • World-released particles retain their emission origin while future emissions follow the refreshed anchor. Parent-local particles consume the refreshed anchor for existing particles, matching Particle::Update 0x0051C290 and ParticleEmitter::UpdateParticles 0x0051D180. Loaded-to-pending spatial transitions skip particle update/draw under retail's cell-less update_object 0x00515D10 gate while retaining the emitter, particles, original absolute creation times, and logical ID. Authoritative pose correction may still refresh the anchor. Re-entry evaluates elapsed particle age/emitter duration once and emits no absent-time backlog; timestamps are not shifted to freeze the effect.
  • Setup lights retain their complete local LIGHTINFO frame. LiveEntityLightController owns live registration/re-entry and every frame LightingHookSink composes it through the current owner root/cell. Held objects use the child root published after CPhysicsObj::UpdateChild 0x00512D50; top-level roots follow their WorldEntity. PhysicsState's Lighting bit and SetLightHook both drive the same create/destroy state, matching CPhysicsObj::set_state 0x00514DD0 and set_lights 0x0050FCF0. This retires AP-67 and TS-10. Light presentation follows LiveEntityRuntime's final projection edge, so a loaded-to-loaded rebucket does not replay registration and a pending owner contributes no stale light. Attached lights wait for the composed-child-pose barrier before their first registration.
  • Setup-part indices are retained separately from drawable MeshRefs; a missing middle GfxObj cannot shift later hook/holding indices. Nested held objects update parent-before-child and compose through the parent's already published child root. The buffers are retained by the attachment owner and reused each frame.
  • Logical teardown removes queues, emitters, lights, and poses exactly once; rebucketing does not. Pose loss withdraws an attached projection and its descendants while retaining accepted relations for valid later recovery. A post-publish parent-first recovery drain realizes the complete descendant chain, preventing an A→B→C graph from stranding C after B returns. The drain is edge-triggered by object/appearance/pose publication, never a per-frame retry loop for a permanently absent Setup or holding part. Session reset clears captured hooks and live poses through normal owner teardown while retaining DAT-static/synthetic poses. TS-11 and TS-12 are retired with the emitter-binding and live-part mechanisms.

15. Step 6 implementation map (2026-07-14)

  • ProjectilePhysicsStepper is a pure Core clock/integration/sweep owner. It accepts an already-live PhysicsBody, full cell, Setup-local sphere, local entity identity, and optional authoritative target identity; it owns no renderer, network state, or logical lifetime. ProjectileController in the next step will select records and apply authoritative corrections around it.
  • The shared integrator now uses retail's 0.2-second maximum catch-up quantum and world-space angular rotation (Frame::grotate, quaternion delta pre-multiplied). Velocity is clamped to 50 world units/second before integration, acceleration is recalculated from the final PhysicsState each quantum, and AlignPath replaces orientation from the complete three-axis displacement through RetailFrameMath.SetVectorHeading.
  • Installed projectile Setup auditing proves the required arrow, bolt, and representative spell set uses exactly one ordinary collision sphere. The port retains the complete Setup-local origin and scales both origin and radius; it therefore preserves Force Bolt's offset (0, -0.165, 0.1045) and never derives collision size from the mesh.
  • ResolveWithTransition accepts the sphere's local origin and begin/end orientations, carries the resulting orientation in ResolveResult, skips the moving entity's own shadow, and automatically adds PathClipped for a Missile body. It deliberately does not add PerfectClip.
  • ObjectInfo.MissileIgnore is the exact 0x0050CEB0 branch tree. Runtime collision metadata now distinguishes “has a CWeenieObject” from “is a creature”; server-created live entities set the former, while DAT landblock geometry does not. The designated target remains zero until an authoritative wire/runtime source supplies one.
  • Transition subdivision provides the continuous sweep. Conformance covers maximum-speed thin sphere and BSP walls, terrain floors, elastic reflection, inelastic stopping, missiles, ethereal weenies, designated and unknown creature targets, self exclusion, and loaded landblock crossing. Retail's transition loop has no arbitrary step-count cap; a 50-unit/second arrow with a 0.1-unit sphere therefore completes all 100 sweep steps in one 0.2-second catch-up quantum. On transition failure, the integrated candidate frame is retained in the current cell and cached velocity is zeroed without running SetPositionInternal or collision response; transition-local contact, stationary, walkable-polygon, and sliding results are discarded. Successful SetPositionInternal writeback keeps Contact distinct from OnWalkable: a steep plane can be contact while only normals with Z at or above FloorZ are walkable. Both facts commit before collision response, so a floor impact settles on the following tick without turning a steep hit into ground.
  • The landblock-crossing test exposed and fixed a shared world-frame rebasing defect: after the containing cell crosses a 192-metre block boundary, the carried block origin must move by the same block delta before the next substep. Retail gets this for free from cell-relative Position.Frame; acdream now performs the equivalent rebase at the ordered containing-cell retarget, preventing one extra landblock jump per remaining sweep substep. Boundary conformance covers positive and negative X and Y crossings.
  • TS-2 is retired. AP-83 and AP-91 remain because ordinary missiles are PathClipped and do not arm their dormant PerfectClip time-of-impact paths.

16. Step 7 live integration map (2026-07-14)

The App integration preserves the same retail object across every spatial and network operation:

after materialized CreateObject:
    if final PhysicsState has never carried Missile: no projectile component
    if Setup does not have the audited one ordinary sphere: diagnose, no guess
    otherwise:
        construct one PhysicsBody, or adopt the same body already owned by the
            object's MovementManager
        validate the inbound frame/vectors, canonicalize the outdoor cell,
            retain valid friction in [0,1], and clamp elasticity to [0,0.1]
        apply velocity through set_velocity (immediate 50-unit/second clamp)
        bind it to LiveEntityRecord.ProjectileRuntime

when MovementManager and projectile classification arrive in either order:
    bind MovementManager cell reads/writes to this exact live-record incarnation
    share its PhysicsBody with ProjectileRuntime
    install CreateObject velocity/omega when the ordinary body is constructed
    if projectile classification arrives second, preserve that body's CURRENT
        vectors/Active state, validate that current body rather than stale
        CreateObject vectors, normalize its current cell frame, and translate
        its clock to the App game clock without replaying retained vectors;
        a safely ignored malformed CreateObject vector cannot veto a later
        finite body, while a non-finite current body cannot enter simulation

each frame:
    for each canonical live record with a projectile component:
        skip/clear Active through update_object when parented, cell-less,
            Frozen, Static, or out of world
        if the object has a PartArray, clear Active beyond the retail
            96-world-unit player-distance boundary; reactivation stamps now
        run ProjectilePhysicsStepper with local WorldEntity.Id for self-shadow
            exclusion and target id zero
        if this exact record generation still owns this exact component:
            WorldEntity.SetPosition(resolved body frame)
            update orientation and full parent cell
            RebucketLiveEntity only when the full cell changed
            move the existing collision shadow to the resolved frame
            publish the new root to the effect-pose owner
        if Missile is now clear and MovementManager exists: that ordinary owner
            advances the shared body instead
        if Missile is clear with no MovementManager: keep advancing the active
            retained CPhysicsObj; collision reads final state, so missile_ignore
            and Missile-derived PathClipped are disabled automatically

fresh State/Vector/Position/Movement packet:
    reject structurally invalid Position/Vector values before advancing the
        snapshot or timestamp, even if Missile classification has not arrived
    first pass the existing per-channel retail timestamp gate
    State: update the canonical body first, even when missile acquisition is
        impossible because Setup or sphere data is unavailable; the accepted
        SetState collision flags must never remain only on the live record.
        A non-finite local receipt clock does not discard first classification:
        bind against the controller's last finite game clock (the clock epoch
        is zero before the first frame), while keeping the wire state authoritative.
        Update this body without newly activating it; removing Missile
        disables missile classification but retains the component/body on this
        object. MovementManager becomes the update owner when present;
        otherwise the retained active CPhysicsObj continues ordinary physics.
        A later Missile state reuses that same current body and cell;
        align the projectile clock at that ownership transfer, preserve the
        existing Active bit, and never activate a body that was already inactive
    Vector: set_velocity + omega on this body; clamp immediately and stamp the
        receipt clock only when the packet reactivates an inactive object; if
        Missile is currently clear and MovementManager exists, its ordinary
        Airborne/LeaveGround funnel owns those writes on the shared body
    Position: hard-correct world frame plus canonical wire cell-local frame,
        shadow, spatial bucket, and effect pose; do not overwrite velocity
    Movement: unpack through the normal MovementManager/animation funnel while
        sharing this exact PhysicsBody and the live record's canonical cell;
        projectile stepping suppresses the generic remote translation tick,
        so there is still only one integrator and one spatial authority

leave_world (pickup/parent):
    clear InWorld and Active, withdraw collision shadows but retain their exact
        registration payload and the component with the incarnation
    fresh Position re-entry restores the same shadows, stamps the current
        physics clock, and never consumes the time spent outside the world

loaded/pending transition:
    the rebucket result is checked in the same physics quantum
    pending clears InWorld/Active and suspends shadows without teardown
    hydration restores body/shadow membership even if Missile was cleared while
        pending; projectile integration itself remains disabled
    landblock rehydration keys materialized movers by LiveEntityRecord.FullCellId,
        never by the retained create-time Snapshot.Position

DeleteObject / generation replacement / session reset:
    normal LiveEntityRuntime teardown destroys the owning record; the
    projectile controller has no second GUID dictionary to clear

ProjectileController runs before animation advancement in the render/update phase and publishes its current root directly; an animated projectile then publishes final part poses later in the same frame. PhysicsScript/particle/light ticks therefore observe the projectile's current predicted root. The controller never creates impact effects, collision messages, damage, or deletion; ACE's later packets remain authoritative for every gameplay outcome.

Step 9 — deterministic lifecycle-hardening map (2026-07-14)

Step 9 adds no new AC algorithm and therefore no replacement retail formula. It verifies that the mechanisms above compose without retaining a displaced object incarnation. The acceptance oracle is ownership symmetry:

create 96 live missile/effect owners at a fixed clock and RNG seed
    queue F754 before CreateObject
    register one canonical live record/local ID
    materialize through the ordinary live-entity resource seam
    bind the projectile to that record's canonical PhysicsBody
    register its collision shadow, effect profile, pose, emitter, and light

advance all owners together
    execute the initial PES hook and retain a future hook in each serial FIFO
    tick the ordinary ProjectileController path
    never create a projectile-specific renderer registration or draw pass

churn spatial residence
    rebucket half loaded -> pending -> loaded
    unload/reload that destination landblock
    retain the exact local ID, body, renderer owner, effect owner, and FIFO
    pause/resume particle presentation and dynamic lights at projection edges

churn logical lifetime
    rapidly delete 32 owners
    recreate the same server GUIDs with a newer INSTANCE_TS
    prove the replacement owns new local IDs and no prior-generation resource
    mix individual deletes with session reset and never-created pending F754

terminal assertions
    zero live/materialized records and null body/projectile/effect/world refs
    zero loaded/pending/rescued live projection references
    zero render owners and balanced EntitySpawnAdapter mesh refcounts
    zero active, retained, and suspended collision-shadow registrations
    zero ready effect profiles and pending F754/F755 packets
    zero PhysicsScript owner queues, anchors, and delayed CallPES hooks
    zero particle emitters/particles/bindings/logical IDs/owner/render-pass maps
    zero effect poses and light controller/sink/manager owner maps

repeat the recall/portal lifecycle
    resolve exact action motion 0x10000153 through an in-memory MotionTable
    drain its animation CallPES through the shared router and serial scheduler
    apply Hidden, park authoritative placement in an unloaded destination
    hydrate and collision-seat that same body, then apply UnHide
    repeat twelve times while retaining one local ID and one logical owner
    finish with zero placement, presentation, script, particle, pose, and shadow owners

The stress design exposed five ownership defects rather than masking them:

  1. GpuWorldState::RemoveLandblock can rescue a persistent player projection before the next frame drains it. Rebucket/delete now scrubs an undrained rescue reference, while the session-scoped persistent GUID classification deliberately survives same-GUID generation replacement and clears only at accepted delete/session teardown.
  2. Spatial visibility callbacks are serialized and can be re-entrant. An old queued GUID edge is now ignored unless it still equals current spatial truth, so deleting/recreating a GUID inside an observer cannot hide or re-register the replacement incarnation. Each rebucket/withdrawal also captures the record reference plus a projection mutation token; an observer that replaces the GUID or reprojects the same record supersedes the outer transaction.
  3. Logical delete and generation replacement previously tore down spatial and runtime indices by GUID after arbitrary callbacks. Teardown now captures and removes the accepted record before callbacks, removes its exact WorldEntity reference, and clears generation/local-ID owners only when they still belong to that record. A per-GUID mutation epoch prevents an outer CreateObject from resurrecting after a nested delete/reset, while unrelated GUID activity does not cancel it. Its tombstone remains until session clear so nested delete-then-create cannot repeat the outer operation's epoch. A teardown failure is thrown at the runtime boundary even when a nested newer generation supersedes the outer create. Materialization is an atomic boundary: logical mutation from a resource-registration callback is rejected and rolled back, and a replacement registered during old teardown cannot materialize until that teardown ends.
  4. Session Clear previously snapshotted records while allowing callbacks to register owners outside the snapshot. Registration is now rejected during terminal clear, preventing untracked renderer/script resources; snapshot entries already deleted by an earlier callback are skipped rather than torn down twice.
  5. Visibility dispatch previously stopped at the first throwing observer. Both spatial and live-presentation fan-outs now drain every subscriber and every queued owner edge before aggregating failures, so one faulty sink cannot strand lights/particles or leak a stale edge into GUID reuse. Rebucket and withdrawal finish their canonical cell, projection, and presentation-index commits before rethrowing those observer aggregates.

The deterministic gate now validates the exact recall action ID and its animation-hook/PES route plus Hidden/remote-placement/UnHide ownership. Installed DAT colors, trail/cloud appearance, remote portal observation, and impact-origin presentation remain visual acceptance items; an in-memory conformance fixture cannot claim pixel appearance.

Corrective visual gate — authored particle geometry (2026-07-14)

Oracle: CPhysicsPart::Draw at 0x0050D7A0, CPhysicsPart::Always2D at 0x0050D8A0, and ParticleEmitter::SetInfo at 0x0051CE90.

particle presentation part = construct a complete CPhysicsPart from emitter GfxObj

Always2D(part):
    if part has no degrade table or the table has no entries:
        return false
    return firstDegrade.DegradeMode != 1

draw particle:
    if Always2D(part):
        draw the camera-facing degrade presentation
    else:
        draw every authored mesh polygon and surface through CPhysicsPart::DrawMesh

The installed Armor Self chain proves why this distinction is visible: PS_ShieldUpGrey resolves through table 0x34000004 to PES 0x3300068F, whose first four CreateParticle hooks use emitters 0x32000350, 351, 355, and 356. Their hardware GfxObjs 0x01001666..0x01001669 are mode-1 meshes, each with five vertices and multiple triangular polygons. Each piece contributes an apex face meeting near local Z = 2.32 m; together they close the crystal top. Emitter 0x32000379/GfxObj 0x01001098 is the separate mode-2 additive billboard. Flattening every GfxObj to one bounding-box quad discards the four apex pieces and produces the observed open shield.

The App renderer now follows Always2D: full-mesh particles reuse the mandatory modern GlobalMeshBuffer batches, bindless surface handles, cull modes, and authored indices; only the 2D case enters the six-index quad path. Mesh ownership is keyed by the stable particle-emitter handle and releases its WB reference on the exact EmitterDied lifecycle edge (or renderer teardown before WB teardown).