Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset. Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures. Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green. Co-Authored-By: Codex <noreply@openai.com>
1171 lines
58 KiB
Markdown
1171 lines
58 KiB
Markdown
# 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`.
|
|
|
|
```text
|
|
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`.
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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`.
|
|
|
|
```text
|
|
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):
|
|
|
|
```text
|
|
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`
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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`
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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`
|
|
|
|
```text
|
|
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).
|
|
|
|
```text
|
|
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`.
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
emitterInfoDid, partIndex, offsetFrame(position+orientation), logicalEmitterId
|
|
```
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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`
|
|
|
|
```text
|
|
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`.
|
|
|
|
```text
|
|
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:
|
|
|
|
```powershell
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Entity/Enum/PhysicsDescriptionFlag.cs>
|
|
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Entity/Enum/PhysicsState.cs>
|
|
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/Network/GameMessages/Messages/GameMessageScript.cs>
|
|
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/WorldObjects/SpellProjectile.cs>
|
|
- <https://github.com/merklejerk/holtburger/blob/main/crates/holtburger-protocol/src/messages/object/messages/description.rs>
|
|
- <https://github.com/ACEmulator/ACViewer/blob/master/ACViewer/Entity/AnimationHooks/CreateParticleHook.cs>
|
|
- <https://github.com/ACEmulator/ACViewer/blob/master/ACViewer/Physics/Scripts/PhysicsScriptTableData.cs>
|
|
|
|
## 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,
|
|
tick owner PhysicsScripts, refresh attached emitters/lights, then advance
|
|
particle simulation and draw.
|
|
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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.
|