823 lines
37 KiB
Markdown
823 lines
37 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 must be validated against its actual inbound state/effect packets
|
|
during Step 8.
|
|
|
|
## 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.
|