research(vfx): pin retail projectile and effect oracle
Establish the executable-backed PhysicsDesc, sequence-gate, PhysicsScript, CreateBlocking, particle-anchor, projectile, and Hidden-state behavior before changing runtime code. Correct stale blocking/threshold claims and synchronize the project instructions with the current UI architecture and matching retail binary. Add copyright-safe packet and DAT-container fixtures plus a failing installed-DAT conformance audit for projectile shapes, typed tables, recall motion, default scripts, and raw CreateBlocking inventory. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
bb5acab9e6
commit
d53fe30ffe
14 changed files with 1545 additions and 53 deletions
|
|
@ -69,7 +69,13 @@ Both carry a single `uint EmitterId` (lines 27-30 of respective generated files)
|
|||
|
||||
### `CreateBlockingParticleHook`
|
||||
|
||||
Source: `Generated/Types/CreateBlockingParticleHook.generated.cs:22-37` — **empty body** in the dat. The "blocking" variant is a runtime behavior flag, not a data field.
|
||||
The generated package type has an empty body, but that is a schema defect, not
|
||||
the retail DAT layout. Retail `CreateBlockingParticleHook::Execute`
|
||||
(`0x00526EF0`) reads the complete inherited `CreateParticleHook` payload:
|
||||
emitter DID, part index, full offset frame, and logical emitter ID. At runtime
|
||||
`ParticleManager::CreateBlockingParticleEmitter` (`0x0051B8A0`) suppresses the
|
||||
create only while the same nonzero logical emitter ID is already live. It does
|
||||
not pause animation progression.
|
||||
|
||||
### Companion: `ParticleEmitter` / `ParticleEmitterInfo` (DB_TYPE_PARTICLE_EMITTER, `0x32000000..0x3200FFFF`)
|
||||
|
||||
|
|
@ -105,11 +111,14 @@ Source: `Generated/DBObjs/PhysicsScriptTable.generated.cs:22-59`.
|
|||
```csharp
|
||||
Dictionary<PlayScript, PhysicsScriptTableData> ScriptTable;
|
||||
// PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc. (62 values)
|
||||
// PhysicsScriptTableData = List<ScriptAndModData> Scripts (weighted variants)
|
||||
// PhysicsScriptTableData = List<ScriptAndModData> Scripts (stored-order thresholds)
|
||||
// ScriptAndModData = { float Mod; QualifiedDataId<PhysicsScript> ScriptId; }
|
||||
```
|
||||
|
||||
Used by PhysicsObj (`desc.PhsTableID` → 0x2C-tagged). Enables "when I die, pick a death-sound script with weight = Mod". Not relevant for sky, but relevant for NPC/monster/spell PES.
|
||||
Used by PhysicsObj (`desc.PhsTableID` → 0x2C-tagged). Retail walks entries in
|
||||
stored DAT order and returns the first whose `intensity <= Mod`; `Mod` is an
|
||||
upper threshold, not a random weight. Equality matches, and there is no
|
||||
sorting or fallback-to-first. This is relevant for NPC/monster/spell PES.
|
||||
|
||||
### Retail factory registration (chunk_00410000.c:13439-13451)
|
||||
|
||||
|
|
|
|||
|
|
@ -264,7 +264,9 @@ CreateParticleHook:
|
|||
parent.create_particle_emitter(emitter_info_id, part_index, offset, emitter_id)
|
||||
|
||||
CreateBlockingParticleHook:
|
||||
same particle creation path, plus sequencer blocking semantics
|
||||
decode the same payload as CreateParticleHook
|
||||
if emitter_id is nonzero and still live: suppress this creation
|
||||
otherwise create normally; do not pause animation
|
||||
|
||||
DestroyParticleHook:
|
||||
parent.destroy_particle_emitter(emitter_id)
|
||||
|
|
|
|||
649
docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
Normal file
649
docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
# 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`.
|
||||
|
||||
## 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`
|
||||
|
||||
```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 adds 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.
|
||||
|
||||
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. Per-object frame ordering preserves retail: animation/timed object hooks,
|
||||
movement commit and child updates, particle tick, then PhysicsScript tick.
|
||||
A script-created emitter begins particle simulation on the following tick.
|
||||
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.
|
||||
|
|
@ -516,7 +516,7 @@ per-part transforms. Each frame can carry zero or many hooks. The full
|
|||
| `0x17` | TextureVelocity | Scrolling texture (water) |
|
||||
| `0x18` | TextureVelocityPart | per-part |
|
||||
| `0x19` | SetLight | Dynamic light on/off |
|
||||
| `0x1A` | CreateBlockingParticle | Same as CreateParticle but blocks anim progression until done |
|
||||
| `0x1A` | CreateBlockingParticle | Same payload as CreateParticle; suppresses creation while the same nonzero logical emitter ID remains live. It does not pause animation. |
|
||||
|
||||
### 5.2 Firing semantics
|
||||
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ PhysicsScriptData {
|
|||
| Hook | Fields | Role |
|
||||
|------|--------|------|
|
||||
| `CreateParticleHook` | `EmitterInfoId`, `PartIndex`, `Offset (Frame)`, `EmitterId` | Spawn a named emitter bound to a body part |
|
||||
| `CreateBlockingParticleHook` | — | Identical to CreateParticle but blocks motion-table advance until the emitter retires (used to gate spell cast sequences) |
|
||||
| `CreateBlockingParticleHook` | Same fields as CreateParticle | Suppresses creation while the same nonzero logical emitter ID remains live; it does not pause motion-table advance |
|
||||
| `StopParticleHook` | `EmitterId` | Mark emitter stopped (no new spawns, existing particles finish) |
|
||||
| `DestroyParticleHook` | `EmitterId` | Instantly despawn all remaining particles |
|
||||
| `DefaultScriptHook` | — | Used inside animation tables; implicit |
|
||||
|
|
@ -211,7 +211,7 @@ PhysicsScriptTable {
|
|||
}
|
||||
|
||||
PhysicsScriptTableData {
|
||||
List<ScriptAndModData> Scripts // sorted by Mod descending
|
||||
List<ScriptAndModData> Scripts // preserve stored DAT order
|
||||
}
|
||||
|
||||
ScriptAndModData {
|
||||
|
|
@ -221,12 +221,11 @@ ScriptAndModData {
|
|||
```
|
||||
|
||||
The server sends `GameMessageScript(objectGuid, PlayScript, mod)` to clients.
|
||||
The client picks the `Scripts` entry whose `Mod` is the largest value ≤ the
|
||||
incoming `mod`. That's how a Frost I missile and a Frost VII missile can
|
||||
share a `PlayScript.Launch` entry but show different-intensity effects: the
|
||||
spell sets mod=0.3 vs mod=0.95, and the table picks the matching
|
||||
`PhysicsScript` DID. This is the dispatch lookup done by
|
||||
`ParticleViewer.GetModIdx()` (`ACViewer/ParticleViewer.cs:46-53`).
|
||||
Retail scans the stored `Scripts` entries and returns the first entry for which
|
||||
the incoming intensity is less than or equal to `Mod`. Equality matches; the
|
||||
client neither sorts the entries nor falls back to the first. ACViewer's
|
||||
`ParticleViewer.GetModIdx()` is only an interpretation aid where it differs
|
||||
from `PhysicsScriptTableData::GetScript` (`0x00521A20`).
|
||||
|
||||
### PlayScript enum — the well-known effect namespace
|
||||
|
||||
|
|
@ -460,14 +459,14 @@ mapping is setup-specific; for humanoids the well-known indices are:
|
|||
|
||||
### Offset Frame
|
||||
|
||||
`CreateParticleHook.Offset` is a `Frame` (position + quaternion) applied
|
||||
inside the parent-part's local space. For a torch: `PartIndex=0` (torch
|
||||
mesh), `Offset=(0, 0, 0.45)` spawns the fire emitter at the top of the
|
||||
torch regardless of how the torch is held. The frame is stored in dat
|
||||
coordinates; our port multiplies it into the part-local transform with
|
||||
standard `Matrix4x4.CreateFromQuaternion(Offset.Orientation) *
|
||||
Matrix4x4.CreateTranslation(Offset.Origin)` and composes with the part's
|
||||
world matrix each frame.
|
||||
`CreateParticleHook.Offset` is encoded as a complete `Frame` (position plus
|
||||
quaternion), but retail `Particle::Init` (`0x0051C930`) consumes only its
|
||||
origin for particle emission. For a torch: `PartIndex=0` (torch mesh),
|
||||
`Offset.Origin=(0, 0, 0.45)` spawns the fire emitter at the top of the torch
|
||||
regardless of how the torch is held. Retail transforms that origin and the
|
||||
emitter's own vectors through the current parent-part/root frame; it does not
|
||||
apply `Offset.Orientation`. The quaternion is still decoded and retained for
|
||||
schema fidelity, not multiplied into the particle anchor.
|
||||
|
||||
### Retail `set_parent` call
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue