Compare commits

...

28 commits

Author SHA1 Message Date
Erik
559b79dc98 fix(render): keep animated entities visible when their landblock is frustum-culled
User report: other characters disappear when the camera rotates,
even though they're standing within visible distance.

Root cause: InstancedMeshRenderer's landblock-level frustum cull
(InstancedMeshRenderer.cs:352-355) skipped the entire landblock's
entity list when the landblock AABB was outside the frustum.
Static scenery culling that way is fine, but ANIMATED entities
(remote players, NPCs, monsters) got culled with the landblock --
they vanished as soon as the camera turned away from their block.

Fix: pass an animatedEntityIds set to Draw. Inside CollectGroups
the landblock-cull decision is now per-landblock boolean (not a
continue), and the per-entity loop bypasses the cull when the
entity id is in animatedEntityIds. Static entities still respect
the landblock cull. GameWindow rebuilds the set per frame from
_animatedEntities (typically <100 entities, cheap).

Fast path preserved: when animatedEntityIds is null/empty AND
the landblock is culled, skip the entity list entirely -- same
O(visible-landblocks) cost as before.

Tests stay 1439 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:10:04 +02:00
Erik
b93dfe95d8 Merge feature/animation-system-complete — Phase L.1c animation MVP
21 commits porting retail's MoveToManager-equivalent client-side
behavior for server-controlled creature locomotion and combat
engagement. Shipped as MVP after live visual verification across
multiple iteration rounds with the user.

Highlights:
- 186a584 — initial Phase L.1c port: extracts Origin / target guid /
  MovementParameters block from MoveTo packets (movementType 6/7),
  adds RemoteMoveToDriver per-tick body-orientation steering with
  ±20° aux-turn-equivalent snap tolerance.
- d247aef — corrected arrival predicate semantics + 1.5 s
  stale-destination timeout for entities leaving the streaming view.
- f794832 — root-caused "creature won't stop to attack" via two
  research subagents converging on retail
  CMotionInterp::move_to_interpreted_state's unconditional
  forward_command bulk-copy. Lifted ServerMoveToActive flag clearing
  + InterpretedState bulk-copy out of substate-only branch so
  Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear
  stale MoveTo state and zero forward velocity.
- ff6d3d0 — RemoteMoveToDriver.ClampApproachVelocity caps horizontal
  velocity at the final-approach tick so body lands EXACTLY at
  DistanceToObject instead of overshooting through the player.
- 37de771 — bulk-copy ForwardCommand for MoveTo packets too (closed
  the regression where MoveTo creatures stayed at default
  ForwardCommand=Ready in InterpretedState and only translated via
  UpdatePosition snaps).
- 34d7f4d + e71ed73 — AnimationSequencer.HasCycle query +
  fallback chain (requested → WalkForward → Ready → no-op) at BOTH
  the OnLiveMotionUpdated path AND the spawn handler. Prevents
  ClearCyclicTail from wiping the body's cyclic tail when ACE
  CreateObject carries CurrentMotionState.ForwardCommand pointing
  to an Action-class motion (e.g. AttackHigh1 from a mid-swing
  creature) which has no cyclic-table entry — was the "torso on
  the ground" symptom for monsters seen in combat by a fresh
  observer.

Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt
(MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80,
CMotionInterp::move_to_interpreted_state 0x00528xxx,
MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/
ACE.Server/Physics/Animation/MoveToManager.cs (port aid),
references/holtburger/ (cross-check on snapshot-only client
behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md
(the Phase L.1c pseudocode doc).

Tests: 1404 → 1422 (parser type-7 path retention, type-6 target
guid retention, driver arrival semantics, retail-faithful
chase/flee branches, approach-velocity clamp scenarios,
HasCycle present/missing, AttackHigh1 wire layout).

Pending follow-ups (filed for future): target-guid live resolution
for type 6 packets (residual chase lag), StickToObject sticky-target
guid trailing field, full MoveToManager state machine port
(CheckProgressMade stall detector, Sticky/StickTo, use_final_heading,
pending_actions queue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:50:59 +02:00
Erik
e71ed73aa9 fix(anim): Phase L.1c spawn-time cycle fallback + diagnostics
User reports same "torso on the ground" symptom after 34d7f4d. Likely
cause: my fallback only covered the OnLiveMotionUpdated path, not the
spawn handler at the CreateObject boundary. If the spawn-time
SetCycle requests a (style, motion) pair the MotionTable lacks,
ClearCyclicTail wipes the cyclic tail at line 396 of
AnimationSequencer.cs and every body part snaps to its setup-default
offset until the first OnLiveMotionUpdated UM applies the path's
fallback there.

Apply the same fallback chain (requested → WalkForward → Ready →
no-op-don't-clear) at the spawn handler. Also add a one-line
diagnostic dump (under ACDREAM_DUMP_MOTION=1) on both code paths so
the next launch confirms whether the fallback is actually firing and
what (mtable, style, motion) tuples are missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:39:43 +02:00
Erik
34d7f4def2 fix(anim): Phase L.1c sequencer cycle fallback for missing MoveTo motion
User-observed regression on commit 37de771: monsters in combat with
another client appear as "just a torso on the ground" until they
move. User correctly identified this as a regression I introduced.

Cause traced to the SEQUENCER side, not the InterpretedState side.
AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396)
unconditionally calls ClearCyclicTail() BEFORE looking up the
requested cycle in the MotionTable. If the cycle is missing
(_mtable.Cycles.TryGetValue returns false), the body is left without
ANY cyclic tail at all — and every part snaps to its setup-default
offset on the next Advance(). Most creatures' setup-defaults put
all limbs at the torso origin, so the visual collapses to "just a
torso on the ground" until a different (working) cycle arrives.

This is specifically a regression from commit 186a584 (Phase L.1c
port). Pre-fix, MoveTo packets fell through to fullMotion=Ready
(every MotionTable contains a Ready cycle). Post-fix, MoveTo packets
seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance
creatures (e.g. monsters in HandCombat 0x003C) have no
(combat, RunForward) cycle in their MotionTable — they're meant to
walk in combat, with retail's apply_run_to_command upgrading
WalkForward → RunForward at the velocity layer rather than the
animation-cycle layer.

Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate
the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it.
Fall back chain: requested motion → WalkForward → Ready →
no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy
(commit 37de771) is unchanged — body still gets RunForward velocity
even when the visible animation falls back to WalkForward or Ready.

Tests: 1420 → 1422.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:33:48 +02:00
Erik
37de771778 fix(anim): Phase L.1c bulk-copy ForwardCommand for MoveTo packets too
User-observed regression on commit ff6d3d0: at login, monsters appear
as "just a torso on the ground" until they start moving.

Cause: f794832 lifted the InterpretedState bulk-copy to apply to BOTH
overlay and substate packets, but gated it on
`!IsServerControlledMoveTo`. The original substate-only
DoInterpretedMotion call I removed had previously updated
InterpretedState for MoveTo packets too (fullMotion=RunForward seed
from PlanMoveToStart routed through ApplyMotionToInterpretedState's
RunForward case). My replacement only fired for non-MoveTo packets,
silently regressing MoveTo creatures to a default
ForwardCommand=Ready InterpretedState.

Consequence: chasing creatures had ForwardCommand=Ready in their
InterpretedState even though the cycle on the sequencer was
RunForward. apply_current_movement (gate: RunForward||WalkForward)
returned zero velocity — body never advanced via the steering branch's
velocity integration. The body ONLY translated when an UpdatePosition
hard-snap arrived (every ~200ms server tick), producing the
"torso on the ground at spawn" pose before the first UP snap landed
and "running on the spot" between snaps.

Fix: drop the IsServerControlledMoveTo gate. Bulk-copy
InterpretedState.ForwardCommand=fullMotion and ForwardSpeed=speedMod
UNCONDITIONALLY for any packet that reaches OnLiveMotionUpdated.
Matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) which doesn't filter by
movement type — for MoveTo, RunForward/speed*runRate; for substate,
the wire's command/speed; for overlay, Attack/animSpeed (and
get_state_velocity gates correctly to zero, the desired stop).

Tests still 1420 green — the existing parser/driver tests cover the
data; this is a code-path completeness fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:25:37 +02:00
Erik
ff6d3d0c94 fix(anim): Phase L.1c clamp approach velocity to prevent overshoot
User-observed residual after f794832: creature stops to attack but
still runs slightly through the player before stopping.

Cause: at 4 m/s body velocity (RunAnimSpeed × ~1.0 speedMod) and a
60 fps tick (~16 ms), the body advances ~6.4 cm per tick. When dist
falls just below the 0.6 m DistanceToObject arrival threshold, the
arrival predicate fires and zeroes velocity — but the body has
already advanced one full tick INTO the threshold zone. That last
tick is the "running through" the user sees, especially when
combined with a player visual radius of ~0.5 m.

Fix: cap horizontal velocity in the steering branch so the body lands
EXACTLY at the arrival threshold instead of overshooting it. Pure
function in RemoteMoveToDriver (ClampApproachVelocity) so it's
testable; called from GameWindow.cs after apply_current_movement
sets RunForward velocity from the active cycle.

The clamp is a strict scale-down of the X/Y components; Z is left
to gravity / terrain handling. No-op for the flee branch — fleeing
has no overshoot risk by definition.

Tests: 1416 → 1420. Four new clamp scenarios: exact-landing (FP
tolerance), would-overshoot scale-down, already-at-threshold zeroing,
flee no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:14:35 +02:00
Erik
f794832ebc fix(anim): Phase L.1c clear MoveTo state + bulk-copy ForwardCommand on overlay UMs
User-observed regression on commit d247aef: creature reaches melee
range and "just runs" instead of stopping to attack. Two independent
research subagents converged on the same root cause.

When ACE broadcasts a melee swing, it sends an mt=0 UpdateMotion with
ForwardCommand=AttackHigh1 (Action class, 0x10000062), motion_flags
=StickToObject, and a trailing 4-byte sticky-target guid — there is
NO preceding cmd=Ready. The swing UM IS the stop signal.

Retail's CMotionInterp::move_to_interpreted_state
(acclient_2013_pseudo_c.txt:305936-305992) bulk-copies forward_command
from the wire into InterpretedState UNCONDITIONALLY, regardless of
motion class. With forward_command=AttackHigh1, get_state_velocity
(:305172-305180) returns velocity.Y=0 because its gate is
RunForward||WalkForward — body stops moving forward. The animation
overlay (the swing) is appended on top of whatever cyclic tail is
active.

Acdream's overlay branch in GameWindow.OnLiveMotionUpdated routed
Action-class commands through PlayAction (animation overlay only) and
SKIPPED:
  - ServerMoveToActive flag update — stale RunForward MoveTo state
    persisted, the per-tick driver kept steering toward the prior
    Origin and calling apply_current_movement.
  - InterpretedState.ForwardCommand bulk-copy — even if the flag had
    been cleared, the body's InterpretedState.ForwardCommand stayed
    at RunForward from the prior MoveTo cycle, so
    apply_current_movement kept producing forward velocity.
  - MoveToPath capture — staleness-timeout band-aid masked this.

Fix: lift the _remoteDeadReckon state-update block out of the
substate-only `else` branch so it runs for both overlay and substate
paths. For non-MoveTo packets, write fullMotion + speedMod directly to
InterpretedState.ForwardCommand/ForwardSpeed (bypassing
ApplyMotionToInterpretedState, which is a heuristic helper that
silently no-ops for Action class — see MotionInterpreter.cs:941-970).
This matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) bulk-copy semantics.

Also corrected RemoteMoveToDriver arrival predicate to retail-faithful:
chase = dist <= DistanceToObject; flee = dist >= MinDistance. The
prior max(MinDistance, DistanceToObject) defensive port happened to
compute the right value for ACE's wire defaults but had wrong
semantics (would have failed for any retail config with MinDistance >
DistanceToObject).

Tests: 1414 → 1416. New parser test for the AttackHigh1 wire layout;
new driver tests for retail-faithful chase/flee arrival.

Defers: target-guid live resolution for type 6 packets (chase-lag
mitigation, symptom #3), StickToObject sticky-target guid trailing
field, full MoveToManager port (CheckProgressMade, pending_actions
queue, Sticky/StickTo, use_final_heading).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:02:53 +02:00
Erik
d247aef2e4 fix(anim): Phase L.1c chase arrival + stale destination
User-observed regressions on commit 186a584:

1. "Monster keeps running in different directions when it should be
   attacking" — chase oscillates around the player at melee range
   instead of stopping. Root cause: arrival check used MinDistance
   only (retail's algorithm), but ACE puts the melee threshold in
   DistanceToObject (default 0.6) and leaves MinDistance at 0. So
   our check was never satisfied; body kept re-targeting around the
   player as each MoveTo refresh moved the destination.

   Fix: arrival = dist <= max(MinDistance, DistanceToObject) + epsilon.
   Honors retail when retail sets MinDistance > 0; falls through to
   ACE's DistanceToObject when MinDistance is 0. Confirmed by
   independent research (named retail decomp, ACE wire writers,
   holtburger client) that DistanceToObject is the documented chase
   threshold in ACE; retail's MinDistance is only meaningful when
   server config overrides the default 0.

2. "Monster disappears, then runs in place" — entity left our
   streaming view, server stopped emitting MoveTo, last destination
   stayed cached. When entity re-entered view, body still steered
   toward the stale point, eventually arrived (V=0), animation kept
   playing → "running on the spot."

   Fix: 1.5 s stale-destination timeout. ACE re-emits MoveTo at
   ~1 Hz during active chase; if no fresh packet for 1.5 s, the
   entity has either left view, transitioned off MoveTo without us
   seeing the cancel UM, or had its move cancelled server-side.
   Clear destination + zero velocity so the next interpreted-motion
   UM (or fresh MoveTo) drives the body cleanly.

Also confirmed (via dispatched research subagent against ACE writer
side, named retail MovementManager::PerformMovement, and holtburger):
the wire's "Origin" field IS the destination, not the start position.
My driver's interpretation was correct; the symptoms were arrival
threshold + staleness, not a misread of the wire.

Tests: 1412 → 1414 (ACE-melee arrival, retail-MinDistance arrival).

Origin-stale lag during active chase remains — server's Origin is
the target's position at packet-emit time, ~1 s behind the player.
For type 6 MoveToObject, the retail-faithful fix is target-guid
live resolution per HandleUpdateTarget @ 0x0052a7d0; deferred per
the pseudocode doc's "out of scope" list. For type 7 there's no
fix without target-velocity prediction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:32:54 +02:00
Erik
9a2839dfe8 Merge branch 'feature/phase-c1-particles' — Phase C.1 PES particles + sky-pass refinements
Phase C.1 ships the retail-faithful PES particle pipeline plus a set of
sky-pass refinements that landed alongside it (Translucent+ClipMap blend,
raw-Additive fog-skip, sampler-object wrap selection). Three feature
commits:

  ec1bbb4 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
  3d21c13 refactor(sky): replace per-frame wrap-mode mutation with persistent samplers
  6d159d9 docs(roadmap): mark Phase C.1 shipped

What landed
===========

Particle data layer (retail ports cited in commits):

  - ParticleEmitterInfo unpack with all 13 ParticleType motion
    integrators (Particle::Init 0x0051c930, Particle::Update 0x0051c290).
  - PhysicsScriptRunner with hook scheduling + CallPES self-loop
    semantics (FUN_0051bed0..bfb0 family).
  - ParticleHookSink translates CreateParticle / DestroyParticle /
    StopParticle / CallPES hooks into emitter spawn/stop calls.
  - EmitterDescRegistry resolves dat ParticleEmitter records to
    runtime descriptors. DAT emitters do NOT default additive — blend
    state is derived from the particle GfxObj surface flags.
  - AttachLocal (is_parent_local=1) follows the live parent each frame
    via ParticleSystem.UpdateEmitterAnchor / ParticleHookSink
    .UpdateEntityAnchor — matches retail
    ParticleEmitter::UpdateParticles 0x0051d2d4.
  - ParticleSystem.EmitterDied lets the sink prune dead per-entity
    handle tracking so naturally-expired emitters don't leak.

Particle GL renderer:

  - Instanced billboard quads with material-derived blend per particle.
  - Global back-to-front sort (across textures + blend modes).
  - Bounding-box → axis/size dispatch picking the largest two
    dimensions for non-billboard particles.
  - Point-sprite degrade detection via DegradeMode == 2.
  - C-vector orientation for ParabolicLVGAGR / LVLALR / GVGAGR.

Sky-pass refinements (most landed earlier on feature/sky-fixes; the
C.1 worktree adds the last few):

  - Translucent + ClipMap forces alpha-blend for cloud sheet
    0x08000023 (matches D3DPolyRender::SetSurface 0x0059c4d0 branch
    at decomp line 425246).
  - Raw-Additive fog-skip via uApplyFog uniform (matches 0x0059c882).
  - Per-keyframe SkyObjectReplace Translucency / Luminosity /
    MaxBright divided by 100 (raw dat is percent, shader expects
    fraction).
  - Bit 0x01 pre/post-scene split (matches GameSky
    ::CreateDeletePhysicsObjects 0x005073c0 routing).
  - Setup-backed (0x020xxxxx) sky objects via SetupMesh.Flatten —
    earlier code dropped these silently.
  - Persistent GL sampler objects (Wrap + ClampToEdge) replace
    per-frame TexParameter mutation. Ported from WorldBuilder
    (Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132).
  - Post-scene Z-offset (-120m) gated on (Properties & 4) != 0 &&
    (Properties & 8) == 0 per GameSky::UpdatePosition 0x00506dd0
    instead of firing on every post-scene SkyObject.

Sky-PES playback intentionally disabled
=======================================

A 2026-04-28 named-retail recheck disproved the original C.1
sky-PES premise. SkyDesc::GetSky (0x00501ec0) copies
SkyObject.default_pes_object into CelestialPosition.pes_id, but
GameSky::CreateDeletePhysicsObjects, MakeObject, and UseTime never
read the field. The experimental sky-PES path remains gated behind
ACDREAM_ENABLE_SKY_PES=1 for dat archaeology only — do not
reintroduce per-SkyObject PES playback in the normal render path
without new decompile evidence.

Tests
=====

dotnet build green, dotnet test green: 695 + 393 + 243 = 1331 passed
(up from 1325). New tests:

  - UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
  - UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
  - EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
  - Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed
  - UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
  - EmitterDied_PrunesPerEntityHandleTracking

Visual verification
===================

Sky / cloud / weather: confirmed by the user during phase development
(pink clouds restored, post-scene rain cylinder Z gated, no aurora
blobs on the skybox). Sampler refactor visually verified as a no-op.

Deferred to Phase C.1.5
=======================

Wiring entity-attached emitters to retail effect IDs:

  - Portal swirls (currently rotating-black-disk placeholder).
  - Chimney smoke / fireplace flames.
  - Spell-cast effect emitter spawns from animation hooks.

The ParticleHookSink wiring is ready; only the entity-side
identification of which retail effect ID belongs to each weenie
class is deferred. File a follow-up issue if needed.
2026-04-29 08:15:14 +02:00
Erik
6d159d9416 docs(roadmap): mark Phase C.1 shipped
Adds the Phase C.1 row to the "Phases already shipped" table and
flags the C.1 bullet in the "Phases ahead — Phase C — Polish / visuals"
section as ✓ SHIPPED. Retains C.1 entity-emitter wiring (portal swirls,
chimney smoke, fireplace flames) as a Phase C.1.5 follow-up — the data
layer is ready, only the wiring of entity-attached emitters to retail
effect IDs is deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:14:21 +02:00
Erik
3d21c1352a refactor(sky): replace per-frame wrap-mode mutation with persistent samplers
Ports WorldBuilder's GL sampler-object pattern
(references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132,
SkyboxRenderManager.cs:312). Two persistent samplers (Repeat +
ClampToEdge) are created once at GL init; the sky pass binds the
appropriate one to texture unit 0 per submesh instead of mutating
per-texture GL_TEXTURE_WRAP_S/T state.

Why this is better than the prior M1 track-and-restore hack:

  1. Sampler state is decoupled from texture state. Two renderers can
     share the same texture handle but sample it with different wrap
     modes simultaneously and safely — sampler state at the bind point
     overrides the texture's own wrap parameters.

  2. No bookkeeping. Drops the HashSet<uint> clamped-textures tracking
     and the end-of-pass restore loop. The only restore needed is
     BindSampler(0, 0) to release unit 0 back to per-texture state.

  3. Constant cost. Sampler objects are created once per GL context,
     not per draw. Filter modes match TextureCache's upload defaults
     (Linear/Linear, no mipmaps) so the binding is purely a wrap-mode
     selection.

Field count: SkyRenderer.cs -28 lines, +14 lines. GameWindow.cs gets
the SamplerCache field + ctor + Dispose. SkyRenderer disposed before
SamplerCache so the sky teardown path doesn't reference a freed
sampler handle.

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:08:26 +02:00
Erik
ec1bbb4f43 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).

Post-review fixes folded into this commit:

H1: AttachLocal (is_parent_local=1) follows live parent each frame.
    ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
    let the owning subsystem refresh AnchorPos every tick — matches
    ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
    parent frame when is_parent_local != 0. Drops the renderer-side
    cameraOffset hack that only worked when the parent was the camera.

H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
    retail-faithful (1 - translucency) opacity formula. The code was
    right; the comment was a leftover from an earlier hypothesis and
    would have invited a wrong "fix".

M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
    and restores them to Repeat at end-of-pass, so non-sky renderers
    that share the GL handle can't silently inherit clamped wrap state.

M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
    weather-flagged AND bit 0x08 is clear, matching retail
    GameSky::UpdatePosition 0x00506dd0. The old code applied it to
    every post-scene object — a no-op today (every Dereth post-scene
    entry happens to be weather-flagged) but a future post-scene-only
    sun rim would have been pushed below the camera.

M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
    handles from the per-entity tracking dictionaries, fixing a slow
    leak where naturally-expired emitters' handles stayed in the
    ConcurrentBag forever during long sessions.

M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
    can't ever overlap the object-index range. Synthetic IDs stay in
    the reserved 0xFxxxxxxx space.

New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
  single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:47:11 +02:00
Erik
186a584404 feat(anim): Phase L.1c port MoveTo path data + per-tick steer
Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.

The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.

Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.

Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.

Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
  + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
  (0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
  — port aid; flagged divergences (WalkRunThreshold default, set_heading
  snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
  ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
  workflow (decompile → cross-reference → pseudocode → port).

Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).

Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:49:22 +02:00
Erik
882a07cfde fix(anim): Phase L.1c anchor monster MoveTo prediction
Keep the retail MoveTo speed/runRate parsing from 9812965 for animation playback, but do not use the partial MoveTo state as a body-position solver. Until the full retail MoveToManager target path is ported, retain UpdatePosition-derived velocity for server-controlled creature position and prevent that velocity from clobbering the packet-derived animation cycle speed.
2026-04-28 21:12:03 +02:00
Erik
9812965183 fix(anim): Phase L.1c match MoveTo run speed
Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
2026-04-28 20:58:22 +02:00
Erik
4dd8d4b46e fix(anim): Phase L.1c seed move-to locomotion
Retail MoveToManager::BeginMoveForward calls MovementParameters::get_command (0x0052AA00) and then _DoMotion/adjust_motion, so a server-controlled MoveTo begins visible forward locomotion before the next UpdatePosition echo. Seed RunForward for MoveTo packets that omit ForwardCommand, while preserving active locomotion and letting position velocity refine walk/run/stop.
2026-04-28 19:48:12 +02:00
Erik
7656fe0970 fix(anim): Phase L.1c animate server-controlled chase 2026-04-28 19:38:52 +02:00
Erik
b96b680a20 fix(anim): Phase L.1c route creature actions and despawns
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.

Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.

Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
2026-04-28 19:21:02 +02:00
Erik
4874d8595a feat(combat): Phase L.1c wire live attack input 2026-04-28 11:58:57 +02:00
Erik
d1fb68f419 test(world): serialize DerethDateTime offset tests 2026-04-28 11:58:50 +02:00
Erik
646246ba84 feat(anim): Phase L.1c select combat maneuvers 2026-04-28 11:44:17 +02:00
Erik
831392a7b2 feat(anim): Phase L.1c classify combat animation commands 2026-04-28 11:37:49 +02:00
Erik
268af82e28 fix(combat): Phase L.1c align attack type flags 2026-04-28 10:59:29 +02:00
Erik
25b9616703 feat(combat): Phase L.1c add outbound combat actions 2026-04-28 10:57:12 +02:00
Erik
29afc94b94 fix(net): Phase L.1c conform combat wire events 2026-04-28 10:54:50 +02:00
Erik
460f95cb42 fix(anim): Phase L.1b route motion commands 2026-04-28 10:46:22 +02:00
Erik
1c69670392 docs(anim): Phase L.1a animation system audit 2026-04-28 10:38:58 +02:00
Erik
1f82b7604e docs(plans): Phase C.1 PES particle rendering — handoff spec
Self-contained spec for the next session: PES (Particle Effect
Schedule) renderer that produces retail's "aurora light play",
portal swirls, chimney smoke, fireplace flames in one
implementation. Rolls up ISSUES.md #28 (root-caused this session
to PES on CelestialPosition.pes_id) and likely #29 (residual
cloud density gap).

Picks up after sky/weather session (merged at f7c9e88). Phase
E.3 already shipped the data layer (ParticleSystem,
EmitterDescLoader, ParticleHookSink, PhysicsScriptRunner,
VfxModel in src/AcDream.Core/Vfx/). C.1 is the visual half:
SkyDescLoader PesObjectId capture, SkyRenderer emitter spawn,
billboarded-quad GL renderer following WorldBuilder's
ParticleBatcher pattern.

Spec includes Step 0 grep targets, references in priority order
(decomp first, ACME/WorldBuilder second), the Dereth Rainy
DayGroup PES enumeration from tools/StarsProbe (notably
0x3300042C active 0.27-0.91 = "render this and confirm" target),
implementation outline (C.1.0 through C.1.6), pitfalls from
prior sessions, and the worktree setup commands.

To kick off the next session, point it at this file.
2026-04-28 10:11:44 +02:00
72 changed files with 7499 additions and 1092 deletions

View file

@ -178,24 +178,25 @@ missing is the plugin-API surface.
---
## #2 — Lightning visual not wired (dat-baked PES triggers)
## #2 — Lightning visual mismatch (sky PES path disproved)
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-04-25
**Component:** weather / sky / vfx
**Description:** Retail's Rainy DayGroup in the Dereth Region dat contains 12+ `SkyObject` entries with non-zero `PesObjectId` and narrow visibility windows (570 ms at keyframe-boundary moments) that drive PhysicsScript-authored flash + thunder effects. We render the sky meshes but ignore the PES path, so no lightning flashes appear during storms. The fragment-shader flash bump on `uFogParams.z` is already wired in `sky.frag` — only the CPU-side PES→runner wire is missing.
**Description:** Lightning/storm sky visuals still do not match retail. A 2026-04-28 named-retail recheck disproved the prior assumption that `SkyObject.PesObjectId` drives sky-render flash particles: `SkyDesc::GetSky` copies the field into `CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects`, `GameSky::MakeObject`, and `GameSky::UseTime` never read it.
**Root cause / status:** Research complete. Implementation is: in `SkyRenderer.Render`, detect visibility-window entry on any SkyObject with `obj.PesObjectId != 0`, call `PhysicsScriptRunner.Play(pesObjectId, ownerId: sky-owner, anchorPos: camera)`, and route any `SetFlash` / `Sound` hooks from the script into `uFogParams.z` + audio.
**Root cause / status:** Open again. The sky-PES path is non-retail and must stay disabled for normal rendering. The remaining mismatch likely lives in the sky/weather mesh material path, the lightning/fog flash path, or another weather subsystem outside `GameSky`; do not reintroduce per-SkyObject PES playback without new decompile evidence.
**Files:**
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — add per-SkyObject PES dispatch inside the visibility loop
- `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` — already shipped (Phase 6a); exposes `Play(scriptId, entityId, anchorWorldPos)`
- `src/AcDream.Core/Lighting/SceneLightingUbo.cs``FogParams.Z` is the flash slot; needs a sink that bumps it and decays
- `src/AcDream.App/Rendering/Shaders/sky.frag` — flash bump already wired (`rgb += flash * vec3(1.5, 1.5, 1.8)`)
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky/weather mesh draw, material state, pre/post split
- `src/AcDream.App/Rendering/Shaders/sky.frag` — flash/fog/lightning coloration path
- `src/AcDream.Core/World/SkyDescLoader.cs` — keep `PesObjectId` parsed for diagnostics, not render playback
**Research:**
- `docs/research/2026-04-28-pes-pseudocode.md` — C.1 correction: `CelestialPosition.pes_id` copied but ignored by GameSky
- `docs/research/2026-04-23-sky-pes-wiring.md` — earlier decompile trace reached the same no-sky-PES conclusion
- `docs/research/2026-04-23-lightning-real.md` (decompile trace + dat discovery)
- `docs/research/2026-04-23-physicsscript.md` (runtime semantics)
- `docs/research/2026-04-23-lightning-crossfade.md` (crossfade mechanism)
@ -281,7 +282,9 @@ missing is the plugin-API surface.
**Description:** Retail renders a dynamic colored "light play" effect in the sky during certain Rainy/Cloudy DayGroup time windows. The user describes it as aurora-borealis-style. acdream renders no comparable effect.
**Root cause:** PES (Particle Effect Schedule) particles attached to SkyObjects via the `CelestialPosition.pes_id` field. Retail header at `acclient.h` line 35451 (verbatim):
**Root cause / status:** Open again. The prior root cause was wrong: `CelestialPosition.pes_id` exists in the retail header and is populated by `SkyDesc::GetSky`, but named retail `GameSky` code does not read it during sky object creation, update, or draw. A 2026-04-28 C.1 experiment that played those PES ids produced colored blobs/wash that did not match retail's broad aurora-like rays, and the path is now debug-only behind `ACDREAM_ENABLE_SKY_PES=1`.
Retail header at `acclient.h` line 35451 still documents the copied field:
```c
struct CelestialPosition {
@ -302,21 +305,24 @@ struct CelestialPosition {
| 7 | 0x02000BA6 | 0x33000453 | 0.030.19 | early morning |
| 17 | 0x02000589 | **0x3300042C** | **0.270.91** | **active during user's screenshot** |
acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetupUploaded` walks `Setup.Parts` for `0x020xxx` IDs). The dynamic visual half — emitting and animating the PES particles — is unimplemented and provides the actual aurora look. Phase E.3 already has data-only PES support per memory crib `project_session_2026_04_18.md`; this issue requires the runtime + visual half.
acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetupUploaded` walks `Setup.Parts` for `0x020xxx` IDs). The remaining dynamic visual half is not `SkyObject.PesObjectId`; likely suspects are sky/weather mesh material state, texture transform/blending, or a separate weather/lightning subsystem outside `GameSky`.
**Implementation outline:**
1. PES dat decode (already partially in `AcDream.Core.World.PesData` per Phase E.3).
2. PES emitter runtime — schedule, spawn, advect, color-cycle, expire each particle.
3. `SkyRenderer` integration — when `MakeObject` sees `pes_id != 0`, spawn the PES at the SkyObject's celestial position.
4. PES vertex-sprite renderer — billboarded textured quads with additive blending and color cycling. Probably reuses the future general-purpose particle renderer (issue #L? — TBD).
1. Keep `SkyObject.PesObjectId` parsed for diagnostics only.
2. Compare retail/acdream material state for the active sky/weather GfxObj/Setup ids (`0x02000588`, `0x02000589`, `0x02000714`, `0x02000BA6`).
3. Trace the named retail sky/weather draw path for texture transforms, translucency, diffusion, luminosity, and any non-GameSky weather effect dispatch.
4. Only add a new runtime visual path once the decompile has an actual caller.
**Decomp pointers:**
- `CPhysicsObj::InitPartArrayObject` decomp ~280484 — dispatches type 7 to Setup loader.
- `CPartArray::CreateSetup` decomp ~287490 — Setup → Parts → optional PES wiring.
- `SkyDesc::GetSky` named retail `0x00501ec0` — copies `SkyObject.default_pes_object` into `CelestialPosition.pes_id`.
- `GameSky::CreateDeletePhysicsObjects` named retail `0x005073c0` — creates/updates sky objects from `gfx_id`, does not read `pes_id`.
- `GameSky::MakeObject` named retail `0x00506ee0` — calls `CPhysicsObj::makeObject(gfx_id, 0, 0)`, no PES.
- `GameSky::UseTime` named retail `0x005075b0` — updates frame/luminosity/diffusion/translucency, no PES.
**Files:**
- `src/AcDream.Core/World/SkyDescLoader.cs``SkyObjectData` needs to carry `PesObjectId` (currently dropped on the floor).
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — needs a particle-emission step alongside the per-SkyObject mesh draw.
- `src/AcDream.Core/World/SkyDescLoader.cs` — carries `PesObjectId` for diagnostics.
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — likely material/texture-transform parity work.
- `src/AcDream.App/Rendering/GameWindow.cs` — sky-PES playback remains debug-only, disabled by default.
**Acceptance:** When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time.

View file

@ -56,6 +56,7 @@
| I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `AttackDone` / `KillLanded`; templates ported verbatim from holtburger `panels/chat.rs:221-308`. | Tests ✓ |
| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
| C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@ -111,7 +112,7 @@ Plus polish that doesn't get its own phase number:
**Goal:** close the visible gaps that make the world read as "old / broken" compared to retail.
**Sub-pieces:**
- **C.1 — VFX / particle system.** `PhysicsScript` parser, per-entity `ParticleEmitter` state, billboarded-quad particle renderer that lives in the Phase 9.1/9.2 translucent pass. Delivers **portal swirls, chimney smoke, and fireplace flames** in one implementation.
- **✓ SHIPPED — C.1 — VFX / particle system + sky-pass refinements.** Retail-faithful `ParticleEmitterInfo` runtime + 13-type motion integrator port + `PhysicsScript` runner + instanced billboard renderer with material-derived blend + global back-to-front sort + AttachLocal live-parent follow. Sky-pass refinements: Translucent+ClipMap alpha-blend, raw-Additive fog-skip, per-keyframe SkyObjectReplace divide-by-100, sampler-object wrap selection (ported from WorldBuilder), gated post-scene Z-offset. Sky-PES disabled by default — named-retail decomp proves `GameSky` drops `pes_id`. **Portal swirls, chimney smoke, fireplace flames** still need a Phase C.1.5 follow-up to wire entity-attached emitters to retail effect IDs (the data layer is ready; only the wiring is deferred). Lands as merge `feat(vfx): Phase C.1 — PES particle renderer + post-review fixes` (`ec1bbb4`) + `refactor(sky): replace per-frame wrap-mode mutation with persistent samplers` (`3d21c13`).
- **C.2 — Dynamic point lights.** Fireplaces and lamps need local lighting; small upgrade to the mesh shader to accumulate N (e.g., 4) nearest point lights per draw. Uniform-buffer or UBO-friendly layout.
- **C.3 — Palette range tuning.** Small per-range offset/length tweaks to match retail skin/hair/eye colors. Mostly diff and verify work, no architecture change.
- **C.4 — Double-sided translucent polys.** Edge case left by Phase 9.2: neg-side translucent polys are culled because cull is always BACK. Fix by tracking per-sub-mesh `CullMode` and flipping GL state per draw (or drawing twice with opposite cull). Minor.
@ -305,6 +306,49 @@ with retail's MMB-hold mouse-look.
---
### Phase L.1 — Animation system completion
**Status:** IN PROGRESS on `feature/animation-system-complete`.
**Goal:** complete the retail-faithful animation surface beyond the
locomotion/jump K-fix series: combat swings, spell casting, emotes, death,
item-use, NPC/monster special actions, remote observer parity, and the
remaining floating-point polish around style transitions, modifiers, action
queues, speed scaling, and PosFrame root motion.
**Plan of record:** `docs/plans/animation-system-audit.md`.
**Sub-pieces:**
- **L.1a — Audit & inventory.** Map retail named-decomp evidence, ACE
cross-references, existing acdream hook points, and current gaps for each
animation category. Output: `docs/plans/animation-system-audit.md`.
- **L.1b — Command router + motion-state cleanup.** Extract tested
`SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants,
and split death `Sanctuary` action from persistent `Dead` substate.
- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
style transitions, attack swings by stance/power/height, hit reactions,
evades/blocks/parries, and death handoff.
- **L.1d — Spell casting wiring.** Cast command classification, windup,
release, fizzle/interruption, recoil, and school/effect distinctions.
- **L.1e — Emotes + postures.** Outbound slash emotes, inbound
command-list emotes, and persistent sit/lie/kneel/sleep states.
- **L.1f — NPC/monster + item-use coverage.** Scripted gestures, monster
special actions, potion/food/scroll/recall cycles, and remote parity.
- **L.1g — Polish + conformance.** Style-transition chain, durable
modifiers/action queues, root-motion handling, speed scaling, and broad
synthetic MotionTable tests.
**Acceptance:**
- `dotnet build` and `dotnet test` green at each commit.
- Test count grows by at least 30 with one representative cycle/action test
per major animation category.
- Every AC-specific behavior cites named retail decomp or ACE/holtburger
cross-reference evidence in code comments, tests, or commit notes.
- User visual sign-off for local and remote attack, spell, emote, death, and
item-use animation parity before marking shipped.
---
### Phase J — Long-tail (deferred / low-priority)
Not detailed here; each gets its own brainstorm when it becomes relevant.

View file

@ -0,0 +1,376 @@
# Phase C.1 — PES particle rendering (sky aurora + portals + smoke)
**Status:** specced, not started.
**Filed:** 2026-04-27 (handoff from sky/weather session, branch merged at f7c9e88).
**Worktree:** to be created at `.worktrees/phase-c1-particles` on branch `feature/phase-c1-particles`.
**2026-04-28 correction:** named-retail decompile disproves the sky-PES
premise in this spec. `SkyDesc::GetSky` copies `default_pes_object` into
`CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects`
(`0x005073c0`), `GameSky::MakeObject` (`0x00506ee0`), and
`GameSky::UseTime` (`0x005075b0`) never read it. C.1 remains valid as the
generic PhysicsScript/particle renderer for real hooks, portals, smoke, etc.,
but per-SkyObject PES playback is debug-only and disabled by default.
---
## What you're building
A retail-faithful **PES (Particle Effect Schedule)** rendering system. PES is
retail's name for "scripted vertex-sprite emitter". The dat carries a PES file
per effect ID (e.g. `0x33000453`) describing how particles spawn, advect,
color-cycle, and expire. Retail attaches a PES to sky objects, motion-hook
events, and portal swirls. Without it, several visible-in-retail effects look
flat or absent in acdream:
- The dynamic "aurora-like light play" the user sees in retail's Rainy/Cloudy
sky is **not** a separate aurora system. It's PES particles attached to
SkyObjects via the `pes_id` field of `CelestialPosition` (verbatim retail
header at `acclient.h:35451`). See ISSUES.md #28 + #29.
- Portal swirls (rotating black disks today — see roadmap "Open visual
defects" → "Portals render as a rotating black disk").
- Chimney smoke + fireplace flames (`References/ACViewer/Physics/Particles/`
for the visual model).
- Spell cast effects (some are PES, some are setup-mesh-based).
Phase **E.3** already shipped the **data layer**: `ParticleSystem`,
`EmitterDescLoader`, `ParticleHookSink`, `PhysicsScriptRunner`, `VfxModel`
in `src/AcDream.Core/Vfx/`. **C.1** is the visual half — the GL renderer plus
SkyRenderer integration.
---
## Phase 0 — worktree setup
```bash
git -C C:/Users/erikn/source/repos/acdream worktree add .worktrees/phase-c1-particles -b feature/phase-c1-particles main
cd C:/Users/erikn/source/repos/acdream/.worktrees/phase-c1-particles
dotnet build && dotnet test
```
Expected: green baseline before touching code. If red, stop and investigate
(do not assume merge artifact).
---
## Step 0 — GREP NAMED FIRST
Per `CLAUDE.md`'s "Development workflow" rule, before any AC-specific
implementation step, grep `docs/research/named-retail/acclient_2013_pseudo_c.txt`
by `class::method` name. For PES specifically, search:
```bash
grep -nE "PhysicsScript::|PhysicsObj::PlayScript|PartArray::CreateSetup" \
docs/research/named-retail/acclient_2013_pseudo_c.txt | head -40
grep -nE "ParticleEmitter::|EmitterInfo::|EmitterDesc::|PESystem" \
docs/research/named-retail/acclient_2013_pseudo_c.txt | head -40
grep -nE "CreateParticleEmitter|DestroyParticleEmitter|RemoveParticleEmitter" \
docs/research/named-retail/acclient_2013_pseudo_c.txt | head -20
```
Then check `docs/research/named-retail/acclient.h` for verbatim retail structs:
- `EmitterInfo` (per-emitter description)
- `EmitterDesc` (per-particle description: lifetime, color curve, motion)
- `ParticleEmitter` (runtime instance)
- `PhysicsScript` (parsed `0x34xxxxxx` PES file: list of "spawn this emitter at this time" entries)
Once you have the named functions/structs, write a brief
`docs/research/2026-04-XX-pes-pseudocode.md` translating the C decomp into
clean pseudocode before porting (per CLAUDE.md "WRITE PSEUDOCODE" step). The
sky/weather session repeatedly proved this catches misinterpretations before
they become bugs.
---
## What we already have (Phase E.3 data-layer)
Read these files first — they encode the dat schema and runtime model:
- `src/AcDream.Core/Vfx/EmitterDescLoader.cs` — loads `EmitterInfo` /
`EmitterDesc` from the dat. Confirm field set matches retail's
`acclient.h` struct definitions.
- `src/AcDream.Core/Vfx/ParticleSystem.cs` — runtime stepper. **Verify**
the 13 motion-type integrators match retail's
`PhysicsScript::MotionType` enum exactly. The roadmap claims they do.
- `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` — schedules emitters per
the parsed PES. This is the timeline driver.
- `src/AcDream.Core/Vfx/ParticleHookSink.cs` — receives `CreateParticle`
hook calls from MotionInterpreter / AnimationSequencer (Phase E.1).
- `src/AcDream.Core/Vfx/VfxModel.cs` — the per-entity particle state.
Tests for these are in `tests/AcDream.Core.Tests/Vfx/`. Run them first to
confirm the data-layer baseline still passes after any refactor.
**What is NOT yet there** (and is C.1's job):
- A GL renderer that consumes `ParticleSystem.LiveParticles` and draws
billboarded quads.
- SkyRenderer integration so `CelestialPosition.pes_id` actually spawns a
PES — currently `SkyDescLoader` drops the field on the floor (see
`SkyObjectData` at `src/AcDream.Core/World/SkyDescLoader.cs:28-54`).
- Per-entity emitter spawn for non-sky cases (chimneys, portals, spell
effects). Probably a wiring exercise once the renderer exists.
---
## References (priority order)
Per `CLAUDE.md`'s "Reference hierarchy by domain" — particle systems are
client-side visual, so the priority is:
1. **`docs/research/named-retail/acclient_2013_pseudo_c.txt`** — the actual
retail client, fully named. Beats every other reference.
2. **`docs/research/named-retail/acclient.h`** — verbatim retail struct
definitions for `EmitterInfo`, `EmitterDesc`, `PhysicsScript`,
`ParticleEmitter`, `CelestialPosition`.
3. **`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`**
— exact Silk.NET stack match, has the GL-side billboarded-quad batcher
we want to port. Check `ParticleEmitter` and rendering glue around it.
4. **`references/ACViewer/ACViewer/Physics/Particles/`** — MonoGame, has
the visual algorithms. Use as cross-check on color cycle + motion math.
5. **`references/ACE/Source/`** — server-side, mostly irrelevant for client
particles, but ACE's `EmitterDesc` structs can confirm field types.
6. **`references/AC2D/`** — older C++ AC client. Smaller scope, useful
cross-check.
7. **`references/Chorizite.ACProtocol/`** — clean-room protocol library;
PES is dat-side not protocol-side, so probably not relevant.
When two references disagree: decomp wins. Always.
---
## What we already learned (data points the next session shouldn't re-derive)
### CelestialPosition struct (verbatim retail header `acclient.h:35451`)
```c
struct CelestialPosition {
IDClass<_tagDataID,32,0> gfx_id;
IDClass<_tagDataID,32,0> pes_id; // ← particle scheduler ID
float heading;
float rotation;
AC1Legacy::Vector3 tex_velocity;
float transparent;
float luminosity;
float max_bright;
unsigned int properties;
};
```
### Dispatch path (named retail decomp)
- `GameSky::CreateDeletePhysicsObjects` at offset `0x005073c0` (decomp
~269036) iterates `sky_obj_pos` and calls `MakeObject(gfx_id, ...)`. The
current acdream port mirrors this for `gfx_id`. **The `pes_id` field is
silently dropped.** Wire it.
- `CPhysicsObj::InitPartArrayObject` at decomp ~280484 dispatches by type
prefix — type 6 → direct GfxObj, type 7 → `CPartArray::CreateSetup`.
This is the existing path C.1 needs to extend with PES-emitter spawn.
- `CPartArray::CreateSetup` at decomp ~287490 → `SetSetupID`
`InitDefaults` (loads animations + scripts + physics-script-table).
Note: the **physics-script-table** mentioned here is the PES dispatcher.
### Properties bit semantics (decomp 268704+ = `GameSky::Draw`)
- bit `0x01` — post-scene placement (`after_sky_cell`). Acdream now
honours this (commit `034a684`). Foreground rain & particle emitters
with bit 0x01 set should render in the post-scene weather pass.
- bit `0x02` — hidden when fog override is active.
- bit `0x04` — only render if `LScape::weather_enabled`. Most PES-bearing
rows have this set.
- bit `0x08` — purpose unknown; seen on every PES-bearing entry. Worth
decoding in C.1 for completeness. Likely "this object owns a PES" or
"this is a transient effect".
### Specific PES IDs in Dereth (probe-confirmed)
`tools/StarsProbe/Program.cs` already enumerates these. For Rainy DG3:
| OI | Active window | Gfx | **PES** | Notes |
|----|--------------|-----|---------|-------|
| 5 | always | 0x02000714 | **0x330007DB** | low-rate background |
| 7 | 0.030.19 | 0x02000BA6 | **0x33000453** | early morning rain |
| 8 | 0.910.98 | 0x02000BA6 | **0x33000453** | late evening rain |
| 11 | 0.0250.030 | 0x02000588 | **0x33000428** | dawn flash |
| 12 | 0.1900.200 | 0x02000588 | **0x33000428** | morning end |
| 13 | 0.0300.190 | 0x02000589 | **0x3300042C** | morning |
| 14 | 0.9050.910 | 0x02000588 | **0x33000428** | dusk start |
| 15 | 0.9800.990 | 0x02000588 | **0x33000428** | dusk end |
| 16 | 0.9100.980 | 0x02000589 | **0x3300042C** | evening |
| **17** | **0.2700.910** | 0x02000589 | **0x3300042C** | **most of daytime — pick this for first visual test** |
| 18 | 0.4000.500 | 0x02000BA6 | 0x33000453 | midday burst |
Use **`0x3300042C`** as the canonical "render this and confirm it shows up"
target. It's active during normal daytime in any Rainy DayGroup; the user
can compare side-by-side with retail at the same in-game time.
### Already-working sky pass
The sky/weather session (commits `97fc1b5`..`e4bc6de`, all merged at
`f7c9e88`) shipped:
- retail-faithful `SunColor` / `AmbientColor` magnitude (`|sunVec|` formula)
- bit-0x01 post-scene partition
- Translucent-flag override on `FromSurfaceType` (cloud blend mode)
- Setup-backed (`0x020xxxxx`) sky object loading via `SetupMesh.Flatten`
- Sky fog + additive-fog-skip
So when you launch with `ACDREAM_DAT_DIR=...`, the static sky meshes already
render correctly. PES is the missing dynamic layer — adding it should be
strictly additive on top of the existing visuals.
---
## Implementation outline
Skeleton; the next session should fill in details after Step 0 grep + reading
the existing E.3 code.
### C.1.0 — Decomp pseudocode + verify E.3 matches
- Grep + read `PhysicsScript`, `EmitterDesc`, `EmitterInfo`,
`ParticleEmitter` in the named decomp.
- Translate to pseudocode in a `docs/research/2026-04-XX-pes-pseudocode.md`
file. Cite line numbers.
- Diff E.3's `EmitterDescLoader` field-by-field against the decomp. If
E.3 is wrong, add a regression test before fixing.
### C.1.1 — `PesObjectId` capture in `SkyDescLoader`
- `src/AcDream.Core/World/SkyDescLoader.cs:28-54` — extend `SkyObjectData`
with `uint PesObjectId` (currently dropped). Set it from
`CelestialPosition.pes_id` in the dat.
- Tests: golden-value test that `0x3300042C` flows from a fixture region
through to `SkyObjectData.PesObjectId`.
### C.1.2 — PES file decode (if E.3 doesn't already do it)
- DatReaderWriter probably has a generated `PhysicsScript` reader. Confirm.
If so, no work here. If not, implement decode + tests.
### C.1.3 — Emitter spawn in SkyRenderer
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — when a SkyObject has
`PesObjectId != 0`, request the `ParticleSystem` to spawn that PES
attached to the SkyObject's celestial position.
- Schedule per the SkyObject's `BeginTime` / `EndTime` window — only emit
while the day fraction is inside the window. Mirrors retail's
`GameSky::CreateDeletePhysicsObjects` activation logic.
### C.1.4 — Particle GL renderer
This is the bulk of C.1. Follow `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`
as the structural template — same Silk.NET stack, mature pattern. Key
elements:
- **Vertex sprite (billboard)**: 4 verts per particle, oriented to face
the camera. Quad UVs from the particle's texture id + animation phase.
- **Batched draw**: dynamic VBO; rebuild each frame from
`ParticleSystem.LiveParticles`. Sort by Z when blend mode is alpha-blend.
- **Color cycle**: per particle, interpolate the `EmitterDesc.ColorCurve`
(RGBA over normalized lifetime) and modulate. Verify the curve evaluator
matches retail's `EmitterDesc::GetColor(t)` — likely linear-interp between
curve nodes.
- **Blend modes**: PES particles use the same `TranslucencyKind` resolution
as static meshes. `Additive` is by far the most common for sky particles.
- **Depth handling**: depth test on, depth write **off** (translucent sprites
shouldn't occlude each other or solid geometry).
- **Per-pass split**: PES on sky objects with bit 0x01 set render in the
post-scene pass; bit 0x01 clear render in the pre-scene pass with the dome.
Reference shader pair: probably a single VS+FS for billboarded textured
quads. Look at WorldBuilder's `Shaders/particle.vert/.frag` for prior art.
### C.1.5 — Wire other PES sources
After sky particles work, do a sweep:
- Portals — `WorldEntity` weenies of class `WeenieClassId.Portal` should
spawn a portal-swirl PES. Replace the placeholder rotating-black-disk
rendering.
- Chimneys / fireplaces — `EnvCell.StaticObjects` for inn/cottage cells
reference PES IDs. Confirm via probe.
- Animation-hooked particles — `MotionInterpreter` already invokes
`CreateParticle` hooks via `ParticleHookSink` (Phase E.1). Verify the
hook reaches the new renderer.
### C.1.6 — Visual verification
Per `CLAUDE.md`'s "Visual verification workflow":
1. Build green, tests green.
2. Launch live client (`ACDREAM_DAT_DIR=...`, `ACDREAM_LIVE=1`, etc.).
3. Stand at Holtburg outdoors during a Rainy DayGroup at midday-ish (DayGroup
selection is LCG-deterministic from year+day_of_year — control by
forcing a specific date if needed).
4. User confirms aurora-like light play matches retail in dual-client
side-by-side comparison.
Acceptance: ISSUES.md #28 closes; #29 likely closes too (the residual cloud
density gap is hypothesised to roll into #28).
---
## Pitfalls to avoid (lessons from prior sessions)
1. **Do NOT guess the PES file format.** It has a header, a list of frames,
each with an emitter-desc-id and start time. The exact byte layout is in
the decomp — read it before writing the decode.
2. **Do NOT integrate via subagent without context.** The animation sequencer
integration cost a 4-fix marathon when a subagent rewrote the transform
pipeline. PES → renderer is similarly central; the subagent must read
the existing Vfx/ + SkyRenderer/ before editing.
3. **Do NOT re-derive things probes already answered.** `tools/StarsProbe`
has the full SkyObject + PES enumeration; just read its log.
4. **Do NOT skip the pseudocode step.** Write a pseudocode doc per C.1.0
before porting.
5. **Decomp wins all ties.** WorldBuilder's `ParticleBatcher` is a fine
template for Silk.NET idioms but its PES interpretation is a port —
if it disagrees with the decomp, the decomp is right.
6. **Translucent ≠ Additive ≠ AlphaBlend at the surface level.** The
sky/weather session learned this the hard way. PES particles will hit
the same `TranslucencyKindExtensions.FromSurfaceType` resolution; the
fixes already merged (`375065b`) handle the `Translucent + ClipMap`
override. Don't undo that.
7. **Bit `0x08` in SkyObject.Properties** is undecoded as of the merge.
Worth grepping for `& 0x08` or `& 8` in the named decomp during C.1.0
to determine if it gates PES specifically. If yes, only spawn a PES
when the bit is set.
---
## Critical files
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — primary oracle.
- `docs/research/named-retail/acclient.h` — verbatim retail structs.
- `docs/research/named-retail/symbols.json` — symbol → address lookup.
- `docs/ISSUES.md` — issues #28 (aurora root cause) and #29 (residual gap).
- `docs/plans/2026-04-11-roadmap.md` — phase identifier (C.1).
- `src/AcDream.Core/Vfx/` — Phase E.3 data-layer scaffolding.
- `src/AcDream.Core/World/SkyDescLoader.cs:28-54``SkyObjectData`,
needs `PesObjectId` capture.
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky pass; needs
particle emit + draw call.
- `tools/StarsProbe/Program.cs` — probe with PES IDs already enumerated.
- `tools/RainMeshProbe/Program.cs` — sky surface flag dump.
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`
— Silk.NET particle batcher template.
- `references/ACViewer/ACViewer/Physics/Particles/` — visual algorithm
cross-check.
---
## Rules of engagement (per CLAUDE.md)
- **Step 0 grep named first** before any AC-specific implementation.
- **One hypothesis at a time.** No bundled fixes. If a fix fails, return to
Phase 1 of `superpowers:systematic-debugging`, not Fix #2.
- **Three failed fixes ⇒ stop and question the architecture**, not Fix #4.
- **Visual verification is the only acceptance test that requires user
input.** Everything else proceeds without confirmation.
- **Drive autonomously** through full phases and across commit boundaries.
Don't stop mid-phase for routine progress check-ins.
- **Subagent policy**: default Sonnet for implementation chunks; Opus only
for load-bearing quality review at phase boundaries. Provide each subagent
with the full file paths it needs to read and the acceptance criteria.
- **Commits go to the feature branch** until the phase ships, then merge to
main with a `--no-ff` merge commit summarising the full phase.

View file

@ -0,0 +1,557 @@
# Animation System Audit
Phase A audit for `feature/animation-system-complete`.
Date: 2026-04-28.
## Summary
The animation core is much stronger than the feature surface around it.
`AnimationSequencer` already handles cyclic state changes, transition links,
negative-speed retail remaps, mid-cycle speed changes, frame hooks, and
PosFrame root-motion accumulation. `GameWindow.OnLiveMotionUpdated` already
routes `InterpretedMotionState.Commands[]` through `PlayAction`, which means
server-broadcast NPC/monster/emote/action overlays are likely to animate when
the server emits motion commands.
The remaining gap is mostly orchestration:
- local combat/spell/item-use commands build wire packets but do not yet drive
the local visible action immediately;
- several combat/spell/emote packet surfaces need conformance fixes before
animation triggers can be trusted: combat mode enum values, split
melee/missile attack builders, `CombatCommenceAttack`, `AttackDone`,
damage/death notification parsers, `MagicSchool` enum order, and outbound
emote/soul-emote builders;
- combat/spell/item-use game events populate state/chat but do not yet map to
animation overlays for attacker/defender/caster;
- style changes are handled as simple `SetCycle(style, motion)` swaps, not the
full ACE `MotionTable.GetObjectSequence` multi-link style transition chain;
- held posture/emote commands need a small command resolver and tests around
one-shot-vs-persistent routing;
- death needs explicit `Sanctuary` action -> `Dead/Fallen` persistence rather
than relying on chat/health side effects.
## Evidence Sources
Named retail decomp:
- `CMotionTable::is_allowed` at `0x005226C0`
- `CMotionTable::get_link` at `0x00522710`
- `CSequence::update_internal` at `0x005255D0`
- `CMotionInterp::adjust_motion` at `0x00528010`
- `CMotionInterp::charge_jump` at `0x005281C0`
- `CMotionInterp::get_jump_v_z` at `0x00527AA0`
- `CMotionInterp::jump` at `0x00528780`
- `CMotionInterp::apply_current_movement` at `0x00528870`
- `CMotionInterp::HitGround` at `0x00528AC0`
- `CMotionInterp::LeaveGround` at `0x00528B00`
- `CMotionInterp::DoMotion` at `0x00528D20`
- `CMotionInterp::DoInterpretedMotion` at `0x00528360`
- `ClientCombatSystem::HandleCommenceAttackEvent` at `0x0056AD20`
- `ClientCombatSystem::SetCombatMode` at `0x0056BE30`
- `ClientCombatSystem::StartAttackRequest` at `0x0056C040`
- `ClientCombatSystem::EndAttackRequest` at `0x0056C0E0`
- `ClientCombatSystem::StartPowerBarBuild` at `0x0056ADB0`
- `ClientCombatSystem::GetPowerBarLevel` at `0x0056ADE0`
- `ClientCombatSystem::ExecuteAttack` at `0x0056BB70`
- `ClientCombatSystem::HandleDefenderNotificationEvent` at `0x0056C920`
- `ClientCombatSystem::HandleEvasionDefenderNotificationEvent` at `0x0056C620`
- `ClientCombatSystem::HandlePlayerDeathEvent` at `0x0056C320`
- `ClientCombatSystem::HandleAttackerNotificationEvent` at `0x0056B420`
- `ClientCombatSystem::HandleAttackDoneEvent` at `0x0056C500`
- `CM_Combat::Event_ChangeCombatMode` at `0x006A9A70`
- `CM_Combat::Event_TargetedMeleeAttack` at `0x006A9C10`
- `CM_Combat::Event_TargetedMissileAttack` at `0x006A9D60`
- `AttackHook::Execute` at `0x00526B70`
- `gmSpellcastingUI::Cast` at `0x004C6050`
- `ClientMagicSystem::CastSpell` at `0x00568040`
- `ClientMagicSystem::FreeHandsAndCastSpell` at `0x00566EF0`
- `ClientMagicSystem::GetAppropriateSpellFormula` at `0x00567D50`
- `CM_Magic::Event_CastUntargetedSpell` at `0x006A3150`
- `CM_Magic::Event_CastTargetedSpell` at `0x006A3040`
- `ItemHolder::UseObject` at `0x00588A80`
- `CM_Inventory::Event_UseEvent` at `0x006AC3B0`
- `CM_Inventory::Event_UseWithTargetEvent` at `0x006AC480`
- `CM_Item::DispatchUI_UseDone` at `0x006A8510`
- `CommandInterpreter::PlayerIsDead` at `0x006B3D70`
- `SmartBox::HandlePlayScriptID` at `0x00452020`
- `CM_Physics::DispatchSB_PlayScriptID` at `0x006ACC40`
- `CM_Physics::DispatchSB_PlayScriptType` at `0x006AC6E0`
- `ClientCommunicationSystem::DoEmote` at `0x00578AD0`
- `ClientCommunicationSystem::Pose` at `0x00580480`
- `ClientCommunicationSystem::Handle_Communication__HearEmote` at
`0x0057CBE0`
- `ClientCommunicationSystem::Handle_Communication__HearSoulEmote` at
`0x0057D020`
- `ChatPoseTable::InqChatPoseCommand` at `0x00570AD0`
- `ChatEmoteData::Pack` at `0x004FCE80`
Cross-reference material:
- `docs/research/deepdives/r03-motion-animation.md`
- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Server\Physics\Animation\MotionTable.cs`
- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Server\Physics\Animation\MotionInterp.cs`
- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Entity\Enum\MotionCommand.cs`
- `C:\Users\erikn\source\repos\acdream\references\holtburger\apps\holtburger-cli\src\pages\game\combat.rs`
- `C:\Users\erikn\source\repos\acdream\references\holtburger\crates\holtburger-core\src\client\messages.rs`
- `C:\Users\erikn\source\repos\acdream\references\holtburger\crates\holtburger-protocol\src\messages\movement\types.rs`
The clean worktree intentionally does not contain `references/`; it was read
read-only from the original checkout path above.
## Current Code Surface
Core animation:
- `src/AcDream.Core/Physics/AnimationSequencer.cs`
- `SetCycle(style, motion, speedMod, skipTransitionLink)` handles cyclic
state changes and transition links.
- `PlayAction(motionCommand, speedMod)` handles Action, Modifier, and
ChatEmote one-shots through Links/Modifiers lookup.
- `Advance(dt)` emits pending hooks and accumulates PosFrame deltas.
- Missing: full style-transition chain, durable modifier list, action queue
accounting, and a public command-resolution facade that callers can test
without `GameWindow`.
- `src/AcDream.Core/Physics/MotionInterpreter.cs`
- Handles locomotion, jump, leave-ground/hit-ground, and basic contact
guards.
- Missing: full retail `MotionState`, action list, modifier list, hold-key
run application, combat-state guards, and `move_to_interpreted_state`.
- `src/AcDream.Core/Physics/MotionCommandResolver.cs`
- Reconstructs full 32-bit commands from 16-bit wire values.
App integration:
- `src/AcDream.App/Input/PlayerMovementController.cs`
- Local walk/run/strafe/turn/jump driver. It does not own combat/spell/item
action animation.
- `src/AcDream.App/Rendering/GameWindow.cs`
- `OnLiveMotionUpdated` is the main inbound motion/action router.
- `OnLiveVectorUpdated` seeds airborne jump arcs and Falling cycles.
- `OnLivePositionUpdated` snaps positions and lands airborne remotes.
- `TickAnimations` advances sequencers and drains hooks.
- `UpdatePlayerAnimation` drives the local movement cycle.
- Missing: typed animation coordinator for combat/spell/use/death/emote
events; too much command mapping still lives inline.
Wire/state:
- `src/AcDream.Core.Net/Messages/AttackTargetRequest.cs`: outbound attack
request exists, but currently combines melee and missile into one layout;
retail/ACE/holtburger use distinct `0x0008` melee and `0x000A` missile
payloads.
- `src/AcDream.Core.Net/Messages/CastSpellRequest.cs`: outbound spell request
exists.
- `src/AcDream.Core.Net/Messages/CharacterActions.cs`: combat mode request
exists, but the combat-mode enum must be corrected to retail values
`NonCombat=1`, `Melee=2`, `Missile=4`, `Magic=8`.
- `src/AcDream.Core.Net/Messages/InteractRequests.cs`: use/use-with-target
request exists.
- `src/AcDream.Core.Net/GameEventWiring.cs`: combat, spell, item, chat events
route into state classes.
- Missing: public `WorldSession.SendAttack/SendCast/SendUse/ChangeCombatMode`
wrappers and animation-side subscriptions.
## Retail Command Catalogue To Use
From ACE `MotionCommand.cs` + `r03-motion-animation.md`:
- Locomotion substates: Ready `0x41000003`, WalkForward `0x45000005`,
WalkBackward `0x45000006`, RunForward `0x44000007`, TurnRight
`0x6500000D`, TurnLeft `0x6500000E`, SideStepRight `0x6500000F`,
SideStepLeft `0x65000010`, Falling `0x40000015`.
- Held/posture substates: Crouch `0x41000012`, Sitting `0x41000013`,
Sleeping `0x41000014`, Dead `0x40000011`, Fallen `0x40000008`.
- Item/use substates: Reload `0x40000016`, Unload `0x40000017`, Pickup
`0x40000018`, StoreInBackpack `0x40000019`, Eat `0x4000001A`, Drink
`0x4000001B`, Reading `0x4000001C`.
- Spell substates/actions: CastSpell `0x400000D3`, MagicBlast
`0x4000002B`, MagicSelfHead `0x4000002C`, MagicSelfHeart `0x4000002D`,
MagicBonus..MagicPenalty `0x4000002E..0x40000034`, MagicTransfer
`0x40000035`, MagicEnchantItem `0x40000037`, MagicPortal `0x40000038`,
MagicPray `0x40000039`, MagicPowerUp01..10 `0x1000006F..0x10000078`,
MagicPowerUp01Purple..10Purple `0x1000012B..0x10000134`.
- Combat actions: Sanctuary `0x10000057`, ThrustMed/Low/High
`0x10000058..0x1000005A`, SlashHigh/Med/Low `0x1000005B..0x1000005D`,
BackhandHigh/Med/Low `0x1000005E..0x10000060`, Shoot `0x10000061`,
AttackHigh/Med/Low1..6 `0x10000062..0x1000006A` and
`0x10000186..0x1000018E`, MissileAttack1..3 `0x100000D0..0x100000D2`,
SpecialAttack1..3 `0x100000CD..0x100000CF`, dual-wield/offhand ranges
`0x10000173..0x1000019A`.
- ChatEmote actions: Wave `0x13000087`, BowDeep `0x1300007D`, Laugh
`0x13000080`, Point `0x13000084`, Salute `0x1300008A`, Kneel
`0x13000092`, HaveASeat `0x13000152`, DrudgeDance `0x13000151`, plus
the full `0x1200/0x1300` ranges in `r03`.
- Persistent emote states: `0x430000EA..0x430000FD`, SnowAngelState
`0x43000118`, CurtseyState `0x4300011A`, AFKState `0x4300011B`,
MeditateState `0x4300011C`, SitState `0x4300013A`,
SitCrossleggedState `0x4300013B`, SitBackState `0x4300013C`,
PossumState `0x43000142`, HaveASeatState `0x43000145`. ACE's enum is a
useful alias catalog but has a shifted range for some late chat-emote states;
named-retail values win when hard-coding constants.
## Category Audit
### 1. Own Player Movement
Status: mostly working.
Evidence: retail `CMotionInterp` jump/grounding symbols listed above; ACE
`MotionInterp.cs` for `adjust_motion`, `apply_current_movement`, `HitGround`,
and `LeaveGround`.
acdream locations: `PlayerMovementController`, `MotionInterpreter`,
`UpdatePlayerAnimation`, `OnLiveVectorUpdated`, `OnLivePositionUpdated`.
Gaps:
- held postures exist as retail commands but are not driven by a general
posture/action API;
- `MotionInterpreter` does not yet own full `MotionState`, so non-locomotion
commands cannot be uniformly tested there;
- mounted/swimming need dat/retail verification before any implementation.
Tests to add:
- posture state `SetCycle` tests for Crouch/Sitting/Sleeping;
- `motion_allows_jump` conformance for item/spell/aim/posture ranges;
- local action does not stomp Falling while airborne.
### 2. Other Players' Movement
Status: partially working after the K-fix series.
Evidence: `UpdateMotion` handling in `OnLiveMotionUpdated`; retail
`CMotionInterp::DoInterpretedMotion` and `apply_current_movement`; ACE
`MotionInterp.apply_current_movement`.
acdream locations: `RemoteMotion`, `OnLiveMotionUpdated`,
`OnLiveVectorUpdated`, `OnLivePositionUpdated`, `TickAnimations`.
Gaps:
- remote action overlays only happen when the server includes
`InterpretedMotionState.Commands[]`; combat/spell game events do not yet
synthesize overlays when the wire omits motion commands;
- no test fixture exercises `OnLiveMotionUpdated` command-list routing outside
`GameWindow`;
- root-motion deltas are accumulated but not applied to remote body transforms.
Tests to add:
- command-list `Wave` -> `PlayAction` routing through a new coordinator;
- airborne remote ignores mid-arc locomotion cycle swaps but still updates
interpreted movement;
- landing swaps Falling back to current interpreted command.
### 3. NPC Movement
Status: likely works for UpdateMotion-driven locomotion and simple gestures;
not verified.
Evidence: retail MotionTable/InterpretedMotionState path; ACE
`MotionTable.GetObjectSequence` and `MotionInterp.move_to_interpreted_state`.
acdream locations: `CreateObject.ParseServerMotionState`,
`OnLiveMotionUpdated`, `TickAnimations`.
Gaps:
- no NPC-specific live test checklist;
- no retained action/modifier list, so repeated scripted gestures are
fire-and-forget overlays only;
- no head-look/threat-pose state beyond whatever arrives as motion commands.
Tests to add:
- synthetic NPC `UpdateMotion` with `Commands=[Wave, Ready]` plays one-shot
then returns to Ready;
- style-default fallback for creature motion tables.
### 4. Monster Movement
Status: locomotion probably works when `MotionTableId` and UpdateMotion are
present; special attacks are unknown.
Evidence: ACE MotionTable supports monster actions such as HeadThrow,
FistSlam, BreatheFlame, SpinAttack, Bite, SpecialAttack1..3.
acdream locations: same as NPC movement; `AnimationHookRouter` for VFX/audio
side effects.
Gaps:
- attack action overlays for monsters depend on server motion command lists;
- no mapping from combat events to visible monster attack/hit reactions;
- no exotic creature spot-checks.
Tests to add:
- `PlayAction(BreatheFlame)` resolves from Links/Modifiers when synthetic data
provides it;
- Attack hooks fire exactly once for a synthetic monster action.
### 5. Combat Actions
Status: wire codecs and combat state exist; visual action orchestration is
missing for local and event-driven paths.
Evidence:
- retail `ClientCombatSystem::StartPowerBarBuild`,
`ClientCombatSystem::GetPowerBarLevel`, `ClientCombatSystem::ExecuteAttack`,
`HandleCommenceAttackEvent`, `HandleAttackerNotificationEvent`,
`HandleAttackDoneEvent`;
- ACE `MotionTable.GetAttackFrames` scans Attack hooks and is the canonical
hit-frame source;
- holtburger combat UI tracks `AttackCommenced`, `AttackDone`, victim,
attacker, defender, evasion, and killed feedback as runtime state.
acdream locations:
- `AttackTargetRequest` exists but no `WorldSession.SendAttack` wrapper was
found;
- `CombatState` emits `DamageTaken`, `DamageDealtAccepted`, evasion,
`AttackDone`, and `KillLanded`;
- `GameEventWiring` registers combat event parsers;
- `AnimationSequencer.PlayAction` can play the swing once the command is known.
Gaps:
- combat-mode enum values are currently non-retail for missile/magic;
- melee/missile attack request builders need to be split to retail layouts:
`0x0008 targetGuid, attackHeight, power` and
`0x000A targetGuid, attackHeight, accuracy`;
- `CombatCommenceAttack (0x01B8)` is enumerated but not parsed/wired;
- `AttackDone (0x01A7)` and attacker/defender/death notification parsers need
ACE/holtburger fixtures before downstream animation can trust them;
- `CombatState` has no `CurrentMode`, no attack sequence active flag, no
selected target, and no power-bar state;
- no local predictive swing on attack request;
- hit reactions (Twitch/Stagger/Tipped/FallDown) are not mapped from defender
notifications;
- style changes for draw/sheath do not run the full style-transition chain.
Tests to add:
- parse/wire `CombatCommenceAttack`;
- `CombatAnimationCoordinator` maps height/power/style to attack command;
- defender hit quadrant maps to a stable flinch command;
- `AttackHook` dispatch is one-shot.
### 6. Spell Casting
Status: outbound cast packets and spellbook/enchantment state exist; visible
cast-stage animation is missing.
Evidence:
- retail `ClientMagicSystem::CastSpell` and `FreeHandsAndCastSpell`;
- `gmSpellcastingUI::Cast` calls `ClientMagicSystem::CastSpell`;
- outbound cast actions are `0x0048` untargeted (`spellId`) and `0x004A`
targeted (`targetGuid`, `spellId`);
- retail/ACE school order is `War=1`, `Life=2`, `Item=3`, `Creature=4`,
`Void=5`;
- MotionCommand spell catalogue above;
- `GameEventWiring` wires spellbook/enchantment updates but not casting
animation.
acdream locations:
- `CastSpellRequest` targeted/untargeted builders;
- `Spellbook`, `SpellTable`, `GameEventWiring` spell handlers;
- `AnimationHookRouter` already routes hooks to audio/VFX sinks.
Gaps:
- no cast coordinator reading server `UpdateMotion`, spellcasting chat,
`PlayScript.Fizzle`, `UseDone`, and errors into one local cast timeline;
- no fizzle/interruption animation mapping from `PlayScript.Fizzle = 0x51`
(ACE sends speed `0.5`) and `WeenieError`;
- no recoil/release state;
- no local immediate cast animation on request.
- `MagicSchool` enum currently needs conformance against the retail/ACE order.
Tests to add:
- spell school/effect classifier maps to MagicBlast/MagicSelf/MagicPortal;
- fizzle error maps to a one-shot action or recovery state once retail
command is confirmed;
- cast request triggers local action overlay without waiting for enchantment.
### 7. Emotes
Status: inbound text parsers and chat display exist; motion command-list
emotes likely animate if server emits them. Slash-command-to-emote wire and
text-event-to-animation are missing.
Evidence:
- retail `ClientCommunicationSystem::DoEmote`, `HelpEmote`,
`DoEmoteList`, `InitializeEmoteInputActionHash`;
- retail `ClientCommunicationSystem::Pose` looks up a token in
`ChatPoseTable`, issues the motion command locally, then sends SoulEmote;
- `ChatEmoteData::Pack`;
- ACE MotionCommand ChatEmote range.
acdream locations:
- `EmoteText` and `SoulEmote` top-level parsers;
- `ChatLog.OnEmote` / `OnSoulEmote`;
- `GameWindow.OnLiveMotionUpdated` command-list `PlayAction` route.
Gaps:
- no outbound `Communication_Emote (0x01DF)` or
`Communication_SoulEmote (0x01E1)` GameAction builder found;
- `MoveToState` currently writes zero command-list entries, so the client
cannot yet send pose/emote commands in the retail motion-state path;
- `ChatInputParser` has no `/em`, `/emote`, `/me`, `/sit`, `/kneel`,
`/sleep`, or `/lie` parsing;
- `EmoteText`/`SoulEmote` text events do not carry an emote id, so they
should not be used as the primary animation source unless retail proves a
deterministic text -> command mapping;
- held postures need `SetCycle`, not `PlayAction`.
Tests to add:
- `MotionCommandResolver` reconstructs representative ChatEmotes;
- command-list Wave routes to `PlayAction`;
- persistent Sit/Meditate routes to `SetCycle`.
### 8. Death Animations
Status: death chat and killer notifications exist; pose transition is missing.
Evidence:
- retail `CommandInterpreter::PlayerIsDead` checks forward command
`0x40000011`;
- MotionCommand `Sanctuary = 0x10000057` is an action and must not be used as
the persistent death state;
- MotionCommand `Dead = 0x40000011` and `Fallen = 0x40000008` are persistent
states;
- `PlayerKilled` top-level message and `KillerNotification (0x01AD)` are
parsed/wired.
acdream locations:
- `PlayerKilled`, `ChatLog.OnPlayerKilled`;
- `CombatState.OnKillerNotification`;
- `MotionCommand.Dead` currently incorrectly comments `0x10000057` in
`MotionInterpreter`; this should be split into `Sanctuary` action and
`Dead` substate before death work.
Gaps:
- no explicit death animation coordinator;
- no hit-direction-aware fall;
- no dead-pose persistence or respawn reset.
Tests to add:
- death event plays Sanctuary then persists Dead/Fallen;
- movement is blocked while Dead/Fallen;
- respawn/reset returns to Ready.
### 9. Item-Use Animations
Status: outbound use builders exist; local visible use animations are missing.
Evidence:
- retail `ItemHolder::UseObject`;
- MotionCommand item/use states: Pickup, StoreInBackpack, Eat, Drink,
Reading, HouseRecall, LifestoneRecall.
acdream locations:
- `InteractRequests.BuildUse` / `BuildUseWithTarget`;
- `ItemRepository`, appraise/use-done event enum.
Gaps:
- no `WorldSession.SendUse` wrapper found;
- `UseDone (0x01C7)` is enumerated but not parsed/wired;
- `0xF754 PlayScriptId` is wired, but target anchoring and speed handling need
audit; `0xF755 PlayScriptType` is not wired;
- no item-class-to-motion mapping.
Tests to add:
- potion use maps to Drink;
- food maps to Eat;
- scroll/book maps to Reading;
- recall spell/item maps to recall command once retail source is confirmed.
### 10. Mounting / Dismounting
Status: not implemented; likely not relevant to retail AC character movement.
Evidence: `r03` lists `Graze` as a monster-only/mount-like stance, but no
player mount feature has been verified in retail references in this audit.
Action: defer until a server/content feature requires it. Do not invent
mounting behavior.
### 11. Floating-Point / Polish
Status: partially implemented.
Evidence:
- `AnimationSequencer.MultiplyCyclicFramerate` exists and is tested;
- `LocalAnimationSpeed` exists in `MovementResult`;
- PosFrame deltas are accumulated in `AnimationSequencer`.
Gaps:
- root-motion deltas are not composed into entity/body transforms;
- remote animation speed scaling is tied to ForwardSpeed/SidestepSpeed/TurnSpeed
only when UpdateMotion carries them;
- style-transition and modifier physics combination are incomplete.
Tests to add:
- same-motion/different-speed rescale remains green;
- root-motion delta is consumed by an integration coordinator;
- modifiers combine velocity/omega instead of replacing base cycle physics.
## Implementation Order
1. Extract an `AnimationCommandRouter` in Core/App-adjacent code that owns
`SetCycle` vs `PlayAction` routing for full 32-bit commands. Move the
command-list logic out of `GameWindow.OnLiveMotionUpdated` into tests.
2. Add missing MotionCommand constants and fix the `Dead`/`Sanctuary`
distinction.
3. Fix combat wire conformance first: combat-mode enum, split attack builders,
`CombatCommenceAttack`, `AttackDone`, damage/evasion/death notification
parsers, and fixtures from ACE/holtburger.
4. Wire `CombatState.CurrentMode` and `WorldSession.SendAttack/ChangeCombatMode`;
trigger local and remote swing overlays through the router.
5. Add spell-cast event/state wiring: `WorldSession.SendCast`, school enum
conformance, `UpdateMotion` cast actions, spellcasting chat,
`PlayScript.Fizzle`, `UseDone`, and errors.
6. Add outbound emote/soul-emote builders, MoveToState command-list emission,
chat parser aliases, and posture routing.
7. Add item-use wrappers, `UseDone`, script target anchoring, and
item-class-to-motion mapping.
8. Add death coordinator and respawn reset.
9. Port full ACE style-transition/modifier/action queue semantics into a
`MotionTableWalker` or equivalent, replacing `SetCycle` special cases only
after the category tests cover current behavior.
10. Apply/consume root motion where retail expects it; leave purely decorative
PosFrames un-applied when decomp/ACE proves they should not move the body.
## Visual Sign-Off Points
The agent can build, test, and live-launch autonomously, but these require
user visual confirmation before claiming complete:
- local attack swing + defender flinch;
- local spell windup -> release/fizzle;
- local `/wave` and persistent sit/lie/kneel/sleep;
- local death pose and respawn recovery;
- potion drink/eat/read animations;
- remote observer view for all of the above.

View file

@ -0,0 +1,113 @@
# Combat Animation Planner Pseudocode
## Sources
- Retail `ClientCombatSystem::ExecuteAttack` (`0x0056BB70`): sends
targeted melee or missile attack intent and records pending response state.
It does not choose a local swing animation.
- Retail `ClientCombatSystem::HandleCommenceAttackEvent` (`0x0056AD20`):
starts/updates power-bar and busy UI state. The event carries no
`MotionCommand`.
- Retail command-name table around `0x00803F34`: combat commands include
`Twitch1..4`, `StaggerBackward`, `StaggerForward`, `ThrustMed`,
`SlashHigh`, `Shoot`, `AttackHigh1`, and later offhand/multistrike
commands.
- ACE `Player_Melee.DoSwingMotion` and `GetSwingAnimation`: server chooses
a swing from `CombatManeuverTable.GetMotion(...)` and broadcasts the
selected `MotionCommand` with `UpdateMotion`.
- ACE `CombatManeuverTable.GetMotion`: indexes `(stance, attack height,
attack type)` to one or more motion commands; power level chooses between
multiple entries.
## Retail Rule
Combat GameEvents are state/UI notifications. Motion state is the animation
authority.
## Pseudocode
```text
PlanForEvent(event):
return None
PlanFromWireCommand(wireCommand, speed):
fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand)
return PlanFromFullCommand(fullCommand, speed)
PlanFromFullCommand(fullCommand, speed):
kind = ClassifyMotionCommand(fullCommand)
if kind is None:
return None
routeKind = AnimationCommandRouter.Classify(fullCommand)
return Plan(kind, routeKind, fullCommand, speed)
ClassifyMotionCommand(fullCommand):
if command is a combat stance:
return CombatStance
if command is a thrust/slash/backhand/offhand/multistrike motion:
return MeleeSwing
if command is Shoot, MissileAttack*, or Reload:
return MissileAttack
if command is AttackHigh/Med/Low 1..6:
return CreatureAttack
if command is CastSpell, UseMagicStaff, or UseMagicWand:
return SpellCast
if command is Twitch*, Stagger*, FallDown, or Sanctuary:
return HitReaction
if command is Dead:
return Death
return None
```
## Maneuver Selection Pseudocode
```text
SelectMotion(table, stance, attackHeight, attackType, powerLevel,
isThrustSlashWeapon):
candidates = []
for maneuver in table.CombatManeuvers:
if maneuver.Style == stance
and maneuver.AttackHeight == attackHeight
and maneuver.AttackType == attackType:
candidates.append(maneuver.Motion)
if candidates is empty:
return None
subdivision = isThrustSlashWeapon ? 0.66 : 0.33
if candidates.Count > 1 and powerLevel < subdivision:
motion = candidates[1]
else:
motion = candidates[0]
return motion
```
This matches ACE `CombatManeuverTable.GetMotion` plus
`Player_Melee.GetSwingAnimation`. The `prevMotion` parameter is present in
ACE's table API but the current ACE implementation does not use it; the
power threshold chooses between multiple entries.
## Named Retail Motion IDs
`DatReaderWriter.Enums.MotionCommand` is shifted by three entries starting
at `AllegianceHometownRecall`. Named retail command tables are:
- `command_ids` table lines 1017626-1017658:
`0x016E..0x0197 -> 0x1000016E..0x10000197`.
- command-name table lines 1068272-1068313:
`OffhandSlashHigh = 0x10000170`, `AttackLow6 = 0x1000018B`,
`PunchFastLow = 0x1000018E`, etc.
`MotionCommandResolver` therefore overrides that range after building the
DRW reflection table, otherwise offhand and late unarmed attack actions
resolve as UI/mappable commands and never reach `PlayAction`.
## Implementation Note
The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable`
and `DatReaderWriter.Types.CombatManeuver` directly. acdream already
references `Chorizite.DatReaderWriter`; the missing live-state piece is a
named `CombatTable` data-id on player/creature state.

View file

@ -0,0 +1,345 @@
# Phase C.1 PES particle pseudocode
Retail sources:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
- `ParticleEmitterInfo::{GetRandom*,InitEnd,ShouldEmitParticle,UnPack}`
at `0x005170d0..0x005179f0`
- `ParticleManager::{CreateParticleEmitter,DestroyParticleEmitter,StopParticleEmitter}`
at `0x0051b6c0..0x0051b7a0`
- `Particle::{Update,Init}` and `ParticleEmitter::{EmitParticle,UpdateParticles}`
at `0x0051b863..0x0051d400`
- `PhysicsScript::{UnPack}` at `0x005218b0`
- `CallPESHook::Execute`, `CreateParticleHook::Execute`,
`DestroyParticleHook::Execute`, `StopParticleHook::Execute` at
`0x00529eb0..0x0052a070`
- `GameSky::{Draw,CreateDeletePhysicsObjects}` at
`0x00506ff0..0x005075d0`
- `docs/research/named-retail/acclient.h`
- `EmitterType`, `ParticleType`
- `ParticleEmitterInfo`, `Particle`, `ParticleEmitter`
- `CreateParticleHook`, `CreateBlockingParticleHook`,
`DestroyParticleHook`, `StopParticleHook`, `CallPESHook`
- `CelestialPosition` with `pes_id`
- Cross-checks:
- `references/ACViewer/ACViewer/Physics/Particles/*`
- `references/ACE/Source/ACE.DatLoader/Entity/ParticleEmitterInfo.cs`
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`
## ParticleEmitterInfo
```text
UnPack(reader):
read id/header
read unknown
read emitter_type
read particle_type
read gfxobj_id
read hw_gfxobj_id
read birthrate
read max_particles
read initial_particles
read total_particles
read total_seconds
read lifespan
read lifespan_rand
read offset_dir, min_offset, max_offset
read A, min_a, max_a
read B, min_b, max_b
read C, min_c, max_c
read start_scale, final_scale, scale_rand
read start_trans, final_trans, trans_rand
read is_parent_local
InitEnd():
sorting_sphere.center = (0, 0, 0)
sorting_sphere.radius = max(max_offset, max_a * lifespan)
RandomScale(base):
value = base + RollDice(-1, 1) * scale_rand
return clamp(value, 0.1, 10.0)
RandomTrans(base):
value = base + RollDice(-1, 1) * trans_rand
return clamp(value, 0.0, 1.0)
RandomLifespan():
value = lifespan + RollDice(-1, 1) * lifespan_rand
return max(value, 0.0)
RandomVector(dir, min, max):
return dir * Random(min, max)
RandomOffset():
v = random vector in [-1, 1]^3
v = v - project(v, offset_dir)
if length(v) is near zero:
v = perpendicular fallback
v = normalize(v)
return v * Random(min_offset, max_offset)
ShouldEmitParticle(emitter):
if total_particles != 0 and emitter.total_emitted >= total_particles:
return false
if emitter.num_particles >= max_particles:
return false
if emitter_type == BirthratePerSec:
return Timer.cur_time - emitter.last_emit_time > birthrate
if emitter_type == BirthratePerMeter:
delta = emitter.last_emit_offset - emitter.current_parent_offset
return dot(delta, delta) > birthrate * birthrate
return false
```
Notes:
- Retail stores `birthrate` as seconds between emissions for
`BirthratePerSec`, not particles per second.
- Retail clamps start/final scale to `[0.1, 10]` and translucency to
`[0, 1]`.
- The named decomp shows final scale/trans add their own base values.
ACE/ACViewer have a few copy-paste mistakes in these helpers; the decomp
wins.
## ParticleManager and emitter lifetime
```text
CreateParticleEmitter(parent, emitter_info_id, part_index, offset, requested_id):
if requested_id != 0:
remove existing emitter with requested_id
info = Dat.Get(ParticleEmitterInfo, emitter_info_id)
emitter = makeParticleEmitter()
emitter.SetInfo(info)
emitter.SetParenting(parent, part_index, offset)
emitter.InitEnd()
emitter.id = requested_id if requested_id != 0 else next_emitter_id++
particle_table.add(emitter.id, emitter)
return emitter.id
DestroyParticleEmitter(id):
remove emitter id from particle_table
StopParticleEmitter(id):
emitter.stopped = true
UpdateParticles():
for each emitter:
keep = emitter.UpdateParticles()
if !keep:
remove emitter
```
`ParticleEmitter::EmitParticle` finds a free/recyclable slot, samples all
random fields from the `ParticleEmitterInfo`, initializes a `Particle`, adds
the particle part, and records `total_emitted`, `last_emit_time`, and
`last_emit_offset`.
`ParticleEmitter::UpdateParticles`:
```text
if drawable/parent is valid:
for each live particle:
parent_frame = parent-local ? current parent frame : particle.start_frame
particle.Update(parent_frame, now, persistent)
if particle.lifetime >= particle.lifespan:
kill particle
while !stopped and info.ShouldEmitParticle(this):
EmitParticle()
if total_seconds != 0 and now - creation_time > total_seconds:
stopped = true
if total_particles != 0 and total_emitted >= total_particles:
stopped = true
return num_particles != 0 || !stopped
```
## Particle integrators
Every particle computes position from age/lifetime, not by accumulating
Euler steps. `parent.origin` below is the parent frame origin chosen by
`is_parent_local`.
```text
age = now - birthtime
Still:
pos = parent.origin + offset
LocalVelocity, GlobalVelocity:
pos = parent.origin + offset + age * A
ParabolicLVGA, ParabolicLVLA, ParabolicGVGA:
pos = parent.origin + offset + age * A + 0.5 * age^2 * B
ParabolicLVGAGR, ParabolicLVLALR, ParabolicGVGAGR:
frame = parent
frame.origin += offset + age * A + 0.5 * age^2 * B
frame.rotate_by(age * C)
pos = frame.origin
Swarm:
pos = parent.origin + offset + age * A
pos.x += cos(age * B.x) * C.x
pos.y += sin(age * B.y) * C.y
pos.z += cos(age * B.z) * C.z
Explode:
pos.x = parent.origin.x + offset.x + (age * B.x + C.x * A.x) * age
pos.y = parent.origin.y + offset.y + (age * B.y + C.y * A.x) * age
pos.z = parent.origin.z + offset.z + (age * B.z + C.z * A.x + A.z) * age
Implode:
pos = parent.origin + offset + cos(A.x * age) * C + age^2 * B
```
`Particle::Init` resolves vector spaces once at spawn:
```text
offset = transform_local_vector(random_offset, start_frame)
LocalVelocity, ParabolicLVGA:
A = local_to_global(A)
ParabolicLVLA:
A = local_to_global(A)
B = local_to_global(B)
ParabolicLVGAGR:
A = local_to_global(A)
C = C
Swarm:
A = local_to_global(A)
Explode:
A = A
B = B
C = normalized random direction scaled by the local C axes
Implode:
A = A
B = B
offset *= C component-wise
C = offset
ParabolicLVLALR:
A = local_to_global(A)
B = local_to_global(B)
C = local_to_global(C)
ParabolicGVGA, GlobalVelocity:
A/B/C remain global as applicable
ParabolicGVGAGR:
A and B remain global
C = C
```
After motion:
```text
t = clamp(age / lifespan, 0, 1)
scale = lerp(start_scale, final_scale, t)
trans = lerp(start_trans, final_trans, t)
opacity = 1 - trans
```
`StartTrans` / `FinalTrans` are transparency values, not source alpha.
Retail sends the interpolated value to `PhysicsPart::SetTranslucency`; the
render path uses its complement as opacity. WorldBuilder's particle renderer
cross-check does the same (`opacity = 1 - currentTrans`).
## PhysicsScript and hooks
`PhysicsScript::UnPack` reads ordered `(start_time, hook)` entries and sorts
them by start time. The runner keeps active script instances keyed by
`(script_id, entity_id)` and fires all hooks whose `start_time <= elapsed`.
Hook execution:
```text
CreateParticleHook:
parent.create_particle_emitter(emitter_info_id, part_index, offset, emitter_id)
CreateBlockingParticleHook:
same particle creation path, plus sequencer blocking semantics
DestroyParticleHook:
parent.destroy_particle_emitter(emitter_id)
StopParticleHook:
parent.stop_particle_emitter(emitter_id)
CallPESHook:
parent.CallPES(pes_id, pause)
```
The C.1 implementation keeps hook dispatch in Core and renders the resulting
particles in App. Nested `CallPESHook` stays in `PhysicsScriptRunner`, while
`ParticleHookSink` converts create/destroy/stop hooks into runtime emitter
handles.
## Sky integration
`CelestialPosition` has both `gfx_id` and `pes_id`. Retail sky object
creation copies `properties` and draws two sky cells. A named-retail recheck
on 2026-04-28 corrected the original C.1 assumption:
```text
SkyDesc::GetSky (0x00501ec0):
copy SkyObject.gfx_id into CelestialPosition.gfx_id
copy SkyObject.default_pes_object into CelestialPosition.pes_id
copy properties / rotate / arc angle / tex velocity
GameSky.CreateDeletePhysicsObjects (0x005073c0):
for each visible CelestialPosition:
post_scene = (properties & 0x01) != 0
make/update sky gfx object from gfx_id in before/after cell
do not read pes_id
GameSky.MakeObject (0x00506ee0):
CPhysicsObj::makeObject(gfx_id, 0, 0)
set texture velocity
GameSky.UseTime (0x005075b0):
CreateDeletePhysicsObjects()
CalcFrame()
set_frame / luminosity / diffusion / translucency
do not read pes_id
GameSky.Draw(post_scene):
if post_scene == false:
draw before_sky_cell
else:
draw after_sky_cell
```
The sky renderer must preserve the existing `0x01` pre/post split for sky
meshes. `SkyObject.default_pes_object` is parsed and retained for diagnostics,
but it is not a retail render-path particle source. In acdream the experimental
sky-PES path is therefore gated behind `ACDREAM_ENABLE_SKY_PES=1` and disabled
for normal visual comparison.
## GL rendering
WorldBuilder's `ParticleBatcher` confirms the GL-side policy:
```text
collect live billboard instances
sort back-to-front by camera distance for alpha blending
depth test enabled
depth writes disabled
cull disabled
blend SrcAlpha/OneMinusSrcAlpha for alpha
blend SrcAlpha/One for additive
stream dynamic instance VBO
draw instanced unit quads
```
C.1 keeps that policy and splits draw calls by particle render pass:
- pre-scene sky particles after the pre-scene sky meshes
- scene particles after opaque world/static objects
- post-scene sky particles after post-scene sky/weather meshes

View file

@ -0,0 +1,285 @@
# Phase L.1c — Remote MoveTo body-driver pseudocode
**Date**: 2026-04-28
**Goal**: Port the minimum viable subset of retail `MoveToManager` so the body
position of server-controlled chasing creatures (movementType 6/7) tracks the
server-supplied destination smoothly, instead of freezing at zero velocity
between sparse `UpdatePosition` snaps.
## Problem (root cause from systematic-debugging Phase 1)
The 882a07c stabilizer holds `rm.Body.Velocity = 0` while `ServerMoveToActive`
is true, on the principle "do not let `apply_current_movement` free-run with
incomplete MoveTo state." The state IS incomplete: our parser at
[`UpdateMotion.cs:280-290`](../../src/AcDream.Core.Net/Messages/UpdateMotion.cs)
keeps only `speed`/`runRate`/flags from the 7-DWORD `MovementParameters`
block and the `runRate` trailer, **discarding** `Origin (destination)`,
`targetGuid` (type 6 only), `distance_to_object`, `min_distance`,
`fail_distance`, `walk_run_threshhold`, and `desired_heading`.
Symptoms (live log + user observation 2026-04-28):
- **Disappearing**: body frozen at `Velocity=0` while RunForward animation
plays; next UpdatePosition teleports body to actual server pose. If the
teleport target is outside the visible window, observer sees disappear/reappear.
- **Jitter**: when a stale UP-derived velocity exists, body extrapolates along
the OLD heading; meanwhile the server is steering the creature on a curve.
Each new UP snap-corrects → visible stutter.
The fresh MoveTo packet stream (~1 Hz, seq 0x01FE→0x0204 in the live log) IS
sending fresh target positions and headings each tick — we're throwing them
away.
## Retail behavior (named decomp + ACE port)
Sources:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — citations below
- `docs/research/named-retail/acclient.h` — struct definitions
- `references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs`
- `references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`
### Wire layout (`MovementParameters::UnPackNet` @ `0x0052ac50`, type 6/7)
```
[uint targetGuid] // type 6 only (MoveToObject)
Origin: uint cellId // then 3 floats local x/y/z
float x, y, z // destination position
MovementParameters (28 bytes, exact retail order):
uint flags // bitfield (see below)
float distance_to_object // arrival far-bound (ACE default 0.6)
float min_distance // arrival near-bound
float fail_distance // abort when starting→current >= this
float speed // base speed multiplier
float walk_run_threshhold // (sic, two h's) — wire default 15.0
float desired_heading // final orientation (radians or degrees)
float runRate // CMotionInterp::my_run_rate copy
```
### MovementParameters bit-flags (declaration order, acclient.h:31423-31443)
| Bit | Mask | Name | Meaning |
|----:|---------|------|---------|
| 0 | 0x00001 | can_walk | gait permission |
| 1 | 0x00002 | can_run | gait permission (we already use this for `MoveToCanRun`) |
| 2 | 0x00004 | can_sidestep | enables strafe path |
| 3 | 0x00008 | can_walk_backwards | gait permission |
| 4 | 0x00010 | can_charge | force HoldKey_Run |
| 5 | 0x00020 | fail_walk | fail if only walk possible |
| 6 | 0x00040 | use_final_heading | append final TurnToHeading after arrival |
| 7 | 0x00080 | sticky | MoveToObject only — StickTo on completion |
| 8 | 0x00100 | move_away | flee target |
| 9 | 0x00200 | move_towards | chase target (chase creatures set this) |
| 10 | 0x00400 | use_spheres | use cylinder distance vs straight-line |
| 11 | 0x00800 | set_hold_key | apply HoldKeyToApply |
| ... | ... | ... | (autonomous, modify_*_state, cancel_moveto, stop_completely, disable_jump) |
### MoveToManager::HandleMoveToPosition (per-tick, `0x00529d80` lines 307187-307440)
```
if physics.motions_pending:
cancel any aux turn cmd (let the queued motion complete)
else:
targetWorld = currentTargetPosition // last server-supplied destination
desiredHeading = atan2(targetWorld - body.position) + get_desired_heading(currentCmd)
headingDelta = normalize(desiredHeading - body.heading)
if |headingDelta| <= 20°: // retail tolerance
// ACE adds set_heading(target, true) here (server-tic-rate fudge)
cancel any aux turn cmd
else:
edi = (headingDelta < 180°) ? TurnLeft : TurnRight
if edi != auxCommand:
_DoMotion(edi) // -> CMotionInterp
auxCommand = edi
dist = GetCurrentDistance()
if CheckProgressMade(dist):
if !movingAway and dist <= min_distance: // arrived
popHeadNode(); _StopMotion(currentCmd); _StopMotion(auxCommand); BeginNextNode()
if movingAway and dist >= distance_to_object:
popHeadNode(); ...
if !movingAway and Position.distance(starting, current) >= fail_distance:
CancelMoveTo(0x3d) // YouChargedTooFar
```
### Key insight: MoveToManager does NOT touch the body directly
Every motion start/stop is dispatched through `CMotionInterp::DoInterpretedMotion`
(via `_DoMotion`/`_StopMotion`). The body's actual position evolves via the
ordinary physics tick (`PhysicsBody::UpdatePhysicsInternal`). MoveToManager is
purely a *planner* sitting above CMotionInterp, deciding *which command* (and
which auxiliary turn) the body should be running at any given tick.
## Acdream port — minimum viable subset
The server re-emits MoveTo packets ~1 Hz with fresh destinations, so we can
skip:
- `MoveToObject_Internal` target-tracking (`HandleUpdateTarget`) — server does it
- Sticky / `PositionManager::StickTo`
- `CheckProgressMade` stall detection — server cancels the move
- `fail_distance` / `WeenieError.YouChargedTooFar` — server-side concern
- `WeenieObj::OnMoveComplete` callback
- Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as
a fresh single-step plan)
We DO need:
1. **Parser**: extract the discarded fields into `ServerMotionState`.
2. **Per-tick steer**: compute heading-to-destination, turn body orientation
toward it (snap when within ±20° per ACE's tic-rate fudge), then *allow*
`apply_current_movement` to run — which sets `Body.Velocity` from the
active RunForward cycle, oriented along the now-correct heading.
3. **Arrival**: when `dist <= distance_to_object`, switch animation to Ready
and clear `ServerMoveToActive`. Server's next MoveTo packet will resume.
## Pseudocode — acdream port
### Parser change (`UpdateMotion.TryParseMoveToPayload`)
```
TryParseMoveToPayload(body, pos, mt, out parsed):
if mt == 6:
if rem < 4: return false
parsed.TargetGuid = ReadU32; pos += 4
if rem < 16: return false
parsed.OriginCellId = ReadU32; pos += 4
parsed.OriginX = ReadF32; pos += 4
parsed.OriginY = ReadF32; pos += 4
parsed.OriginZ = ReadF32; pos += 4
if rem < 28: return false
parsed.Flags = ReadU32; pos += 4
parsed.DistanceToObject = ReadF32; pos += 4
parsed.MinDistance = ReadF32; pos += 4
parsed.FailDistance = ReadF32; pos += 4
parsed.Speed = ReadF32; pos += 4
parsed.WalkRunThreshold = ReadF32; pos += 4
parsed.DesiredHeading = ReadF32; pos += 4
if rem < 4: return false
parsed.RunRate = ReadF32
return true
```
### Per-tick driver (new `RemoteMoveToDriver` in `AcDream.Core.Physics`)
```
DriveOneTick(rm, dt):
if not rm.HasMoveToDestination: return ApplyDefault
targetWorld = rm.MoveToDestinationWorld // pre-converted at packet time
bodyPos = rm.Body.Position
// Distance check first — arrival short-circuits before any heading work
dist = horizontalDistance(targetWorld, bodyPos)
if dist <= rm.MoveToMinDistance + 0.05 (epsilon for float wobble):
rm.HasMoveToDestination = false
// animation cycle moves to Ready via the existing
// ApplyServerControlledVelocityCycle path on next zero-velocity sample
rm.Body.Velocity = Vector3.Zero
return Arrived
// Heading compute (XY plane; Z untouched — server owns Z)
deltaXY = (targetWorld.XY - bodyPos.XY).Normalized
desiredHeading = atan2(deltaXY) // radians
currentHeading = QuaternionToYaw(rm.Body.Orientation)
headingDelta = wrapPi(desiredHeading - currentHeading)
// Snap orientation toward target — match ACE's set_heading(target, true)
// when within tolerance, otherwise rotate at retail-faithful turn rate.
const float tolerance = 20° (in radians)
if |headingDelta| <= tolerance:
rm.Body.Orientation = QuaternionFromYaw(desiredHeading)
else:
// retail TurnSpeed default ≈ π/2 rad/s for monsters; clamp by dt
float maxStep = TurnRateRadPerSec * dt
float step = clamp(headingDelta, -maxStep, +maxStep)
rm.Body.Orientation = QuaternionFromYaw(currentHeading + step)
// Allow apply_current_movement to set Velocity from RunForward cycle.
// The cycle was already seeded by PlanMoveToStart at packet receipt
// and is being played by the AnimationSequencer. CMotionInterp's
// apply_current_movement reads InterpretedState.ForwardCommand and
// sets Body.Velocity = (forward axis of orientation) * RunAnimSpeed * speedMod.
return DriveActive // caller now invokes apply_current_movement
```
### Integration in `GameWindow.OnUpdateMotion` (movementType 6/7 branch)
```
on receipt of MoveTo packet:
// existing code already seeds the animation cycle via PlanMoveToStart
// NEW: store world-converted destination + thresholds on rmState
lbX = (originCellId >> 24) & 0xFF
lbY = (originCellId >> 16) & 0xFF
origin = ((lbX - liveCenterX) * 192, (lbY - liveCenterY) * 192, 0)
rmState.MoveToDestinationWorld = (originX, originY, originZ) + origin
rmState.MoveToMinDistance = parsed.MinDistance
rmState.MoveToDistanceToObject = parsed.DistanceToObject
rmState.HasMoveToDestination = true
// ServerMoveToActive remains set; existing
```
### Integration in per-tick remote update (`GameWindow.cs` ~line 5045)
```
// Replace the current Velocity = Zero hold with:
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination)
{
var driveResult = RemoteMoveToDriver.DriveOneTick(rm, dt);
if driveResult == Arrived:
// signal cycle update to Ready via existing path
ApplyServerControlledVelocityCycle(serverGuid, ae, rm, Vector3.Zero);
else:
rm.Body.TransientState |= Contact | OnWalkable | Active
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
{
// No destination yet (very early frame, packet hasn't fully landed)
rm.Body.Velocity = Vector3.Zero;
}
else
{
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
```
## Conformance test cases
1. **Parser round-trip — type 7 (MoveToPosition)**
- Synthesize a 68-byte body with known origin + 7 params + runRate.
- Assert all 9 new fields decode correctly.
2. **Parser round-trip — type 6 (MoveToObject)**
- Synthesize a 72-byte body with target guid + origin + params + runRate.
- Assert TargetGuid populated and shifts subsequent fields by 4 bytes.
3. **DriveOneTick — heading snap within tolerance**
- body at (0,0,0) facing east, destination (10,0,0).
- DesiredHeading=0; current=0; |delta|=0 ≤ 20° → snap.
- assert orientation unchanged (already correct).
4. **DriveOneTick — heading turn beyond tolerance**
- body at (0,0,0) facing east, destination (0,10,0).
- desiredHeading=π/2; current=0; |delta|=π/2 > 20°.
- dt=0.1s, TurnRate=π/2 → step = π/4 toward target.
- assert orientation rotated by π/4 (not full snap).
5. **DriveOneTick — arrival**
- body at (0,0,0), destination (0.4,0,0), MinDistance=0.6.
- assert HasMoveToDestination cleared and Velocity zeroed.
6. **Bit-flag mapping** (already partially tested via `MoveToCanRun`)
- assert flag 0x00200 (move_towards) is detected as `MoveTowards=true`.
## Out of scope (future Phase L.1d if needed)
- Sticky / StickTo for MoveToObject completion
- `use_final_heading` (post-arrival turn-to-heading)
- `fail_distance` early-cancel (server already does this; we just don't flag it)
- `CheckProgressMade` stall detector
- Strafe / move_away / move_towards-and-away combo (`towards_and_away` helper)
- Sphere-cylinder distance (`use_spheres` bit)
- `MoveToObject` target-guid resolution — currently we only honor the Origin,
which works because the server re-emits with refreshed Origin each tick.
If the target is moving fast and the server's emit cadence falls behind,
we'd see lag; a future enhancement is to look up the target entity by
guid and use its current world position when fresher than Origin.

View file

@ -0,0 +1,97 @@
# 2026-04-28 Sky Cloud Material Trace
Context: Phase C.1 originally treated the Rainy/Cloudy sky visual as a
SkyObject PES problem. Retail named-decomp and dat inspection disprove that
for the broad cloud/ray layer.
## Retail Trace
- `LScape::draw` (`0x00506330`) calls `GameSky::Draw(0)` before terrain and
`GameSky::Draw(1)` after terrain.
- `SkyDesc::GetSky` copies `pes_id`, but `GameSky::CreateDeletePhysicsObjects`
compares/replaces only `gfx_id` and calls `GameSky::MakeObject(gfx_id, ...)`.
The sky object PES id is not part of retail `GameSky` rendering.
- `GameSky::UseTime` applies keyframe replace fields to instantiated sky
objects:
- `0x005076e1`: `CPhysicsObj::SetLuminosity(luminosity * 0.01)`
- `0x00507715`: `CPhysicsObj::SetDiffusion(max_bright * 0.01)`
- `0x00507747`: `CPhysicsObj::SetTranslucency(transparent * 0.01)`
- `CMaterial::SetTranslucencySimple` (`0x005396f0`) writes material alpha as
`1 - translucency`.
- `CMaterial::SetDiffuseSimple` (`0x00539750`) writes material diffuse RGB.
Therefore `SkyObjectReplace.MaxBright` is diffuse, not an emissive cap.
- `D3DPolyRender::SetSurface` (`0x0059c4d0`) disables fixed-function fog alpha
whenever the raw `SurfaceType.Additive` bit is set (`0x0059c882`), even when
the earlier `Translucent + ClipMap` branch forces normal alpha blending.
## Dat Trace
The broad Rainy/Cloudy layer is `GfxObj 0x01004C35`, not one of the tiny
`0x020xxxxx` setup anchors:
- `0x01004C35`: huge sky mesh, bbox roughly `20175 x 20175 x 1180`, UVs tile
across the sheet.
- Surface `0x08000023`: `Base1ClipMap | Translucent | Alpha | Additive`
(`0x00010114`), `Translucency=0.25`, `Luminosity=0`, `Diffuse=1`.
- Texture `0x060037AF`: 256x256 A8R8G8B8 cloud/ray texture.
The setup ids observed in Rainy groups (`0x02000588`, `0x02000589`,
`0x02000BA6`, `0x02000714`) are one-part dummy anchors with tiny `0x010001EC`
geometry and default scripts/PES for sounds/flashes. They are not the broad
cloud layer.
## Port Consequences
- Keep per-SkyObject PES rendering debug-only until another retail path proves
it is used.
- Render `0x08000023` as final alpha blend because retail's translucent/clipmap
branch overrides the raw additive blend.
- Still disable sky fog for that surface because retail keys fog-alpha disable
off the raw `Additive` bit.
- Route `MaxBright` to diffuse (`uDiffuseFactor`) and `Luminosity` to emissive.
- Use a final opacity multiplier for material/surface transparency before the
fragment alpha write; dynamic keyframe transparency remains `1 - value`.
## WorldBuilder Cross-Check
Cloned upstream `https://github.com/Chorizite/WorldBuilder.git` at commit
`167788be6fce65f5ebe79eef07a0b7d28bd7aa81`. Its
`Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` renders sky objects
camera-centered with depth off, but it is not a faithful retail oracle for sky
tint: `GameScene.cs` has the skybox render call commented out, the manager
always selects `DayGroups[0]`, and it uploads `SunlightColor = Vector3.Zero`
/ `AmbientColor = Vector3.One` for sky. `RegionInfo.cs` interpolates
DayGroup[0] lighting for terrain/world objects, not the active retail
DayGroup/weather sky.
That explains why WorldBuilder cannot answer the missing green/purple Rainy
sky tint directly. The actionable lesson is narrower: do not fog-paint the
raw-additive cloud sheet itself. In acdream, non-additive sky layers now receive
the keyframe fog tint so the broad background wash appears behind clouds, while
surfaces with the raw Additive bit (notably `0x08000023`) keep fixed-function
fog disabled and preserve the pink cloud/ray detail.
WorldBuilder's regular object path does collect `Setup.DefaultScript`
particle hooks (`ObjectMeshManager.CollectEmittersFromScript`) and instantiates
them via `ObjectRenderManagerBase`, but its skybox manager does not use that
setup/particle path for SkyObjects. Dat inspection also showed the canonical
Rainy default script target `0x3300042C` is a sound-loop chain (`SoundTweaked`
+ `CallPES`), not the broad green tint or cloud ray layer.
Additional renderer lessons from upstream WorldBuilder:
- Particle blend is material-derived. `ParticleEmitterInfo` does not carry an
additive flag; WorldBuilder reads `ObjectRenderData.Batches[0].IsAdditive`
from the particle GfxObj surface. acdream now leaves DAT emitters non-additive
by default and resolves particle blend from the selected particle surface.
- Particles must be globally sorted back-to-front before drawing. Sorting only
inside per-texture dictionaries can reorder translucent particles whenever
multiple textures/blend states are active.
- Particle quads come from the authored particle GfxObj bounds. Degenerate
extents fall back to `1.0`, and point-sprite degrade mode applies a `0.9`
base scale.
- Texture decoding must try highres `RenderSurface` records after portal lookup
and must zero alpha for black pixels on compressed clipmap textures.
- WorldBuilder tracks UV wrap and cull mode per object batch. acdream's sky path
already uses authored UV wrap, but shared object rendering still needs the
same metadata carried through a later C.4 pass.

File diff suppressed because it is too large Load diff

View file

@ -152,7 +152,16 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null)
HashSet<uint>? visibleCellIds = null,
// L-fix1 (2026-04-28): set of entity ids that should bypass the
// landblock-level frustum cull. Animated entities (other
// players, NPCs, monsters) are always rendered if their
// landblock is loaded — without this they vanish whenever the
// camera rotates away from their landblock, even though
// they're within visible distance of the player. Pass null /
// empty to keep the previous "cull everything by landblock"
// behavior.
HashSet<uint>? animatedEntityIds = null)
{
_shader.Use();
@ -165,7 +174,7 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
// directly — no per-draw uniform uploads needed.
// ── Collect and group instances ───────────────────────────────────────
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds);
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds);
// ── Build and upload the instance buffer ──────────────────────────────
// Count total instances.
@ -342,16 +351,27 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds)
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds)
{
foreach (var grp in _groups.Values)
grp.Entries.Clear();
foreach (var entry in landblockEntries)
{
if (frustum is not null &&
entry.LandblockId != neverCullLandblockId &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
// L-fix1 (2026-04-28): the landblock cull decision is now
// PER-LANDBLOCK boolean, not a continue. We still need to
// walk the entity list because animated entities (in
// animatedEntityIds) bypass the cull and render anyway.
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
// Fast path: no animated entities globally → if landblock is
// culled, skip the whole entity list (preserves the original
// O(visible-landblocks) cost when the caller doesn't care
// about animated bypass).
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities)
@ -359,6 +379,14 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
if (entity.MeshRefs.Count == 0)
continue;
// L-fix1: when the landblock is frustum-culled, only
// render entities flagged as animated. This keeps
// remote players / NPCs / monsters visible even when
// their landblock rotates out of the view frustum.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!landblockVisible && !isAnimated)
continue;
// Step 4: portal visibility filter. If we have a visible cell set,
// skip interior entities whose parent cell isn't visible.
// visibleCellIds == null means camera is outdoors → show all interiors.

View file

@ -2,64 +2,69 @@ using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Vfx;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Simple billboard-quad particle renderer. One draw call per emitter:
/// the CPU streams (position, size, rotation, packed color) into a
/// per-instance VBO; a unit quad VBO gets instanced and the vertex
/// shader rotates the quad around the camera forward vector so it
/// always faces the viewer.
///
/// <para>
/// Not a retail-perfect port of the D3D7 fixed-function particle pipe;
/// good enough for rain, snow, and the basic spell auras we need for
/// Phase G.1's weather + E.3's playback. Trails + spot-light
/// interactions deferred.
/// </para>
///
/// <para>
/// Emitters tagged with <see cref="EmitterFlags.AttachLocal"/> get
/// re-anchored to the current camera position each frame so the rain
/// volume follows the player (r12 §7). This is the cheap version of
/// retail's "IsParentLocal" flag on held emitters.
/// </para>
/// Instanced renderer for retail particle emitters.
/// </summary>
public sealed unsafe class ParticleRenderer : IDisposable
{
private readonly record struct BatchKey(uint TextureHandle, bool UseTexture, bool Additive);
private readonly record struct ParticleDraw(BatchKey Key, ParticleInstance Instance);
private readonly struct ParticleInstance
{
public readonly Vector3 Position;
public readonly Vector3 AxisX;
public readonly Vector3 AxisY;
public readonly uint ColorArgb;
public readonly float DistanceSq;
public ParticleInstance(Vector3 position, Vector3 axisX, Vector3 axisY, uint colorArgb, float distanceSq)
{
Position = position;
AxisX = axisX;
AxisY = axisY;
ColorArgb = colorArgb;
DistanceSq = distanceSq;
}
}
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache? _textures;
private readonly DatCollection? _dats;
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
// Unit-quad vertex buffer (-0.5..+0.5 in XY). 4 verts, 6 indices.
private readonly uint _quadVao;
private readonly uint _quadVbo;
private readonly uint _quadEbo;
// Instance buffer — 8 floats per particle: posX,Y,Z, size, colorR,G,B,A.
private readonly uint _instanceVbo;
private float[] _instanceScratch = new float[256 * 8];
public ParticleRenderer(GL gl, string shadersDir)
private float[] _instanceScratch = new float[256 * 16];
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_textures = textures;
_dats = dats;
_shader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle.vert"),
System.IO.Path.Combine(shadersDir, "particle.frag"));
// Unit quad around origin (XY plane, Z = 0). The vertex shader
// reads this, then offsets into world space using the
// per-instance (pos, size) values.
float[] quadVerts = new float[]
float[] quadVerts =
{
// pos x,y uv
-0.5f, -0.5f, 0f, 0f,
0.5f, -0.5f, 1f, 0f,
0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, 0f, 1f,
};
uint[] quadIdx = new uint[] { 0, 1, 2, 0, 2, 3 };
uint[] quadIdx = { 0, 1, 2, 0, 2, 3 };
_quadVao = _gl.GenVertexArray();
_gl.BindVertexArray(_quadVao);
@ -67,8 +72,14 @@ public sealed unsafe class ParticleRenderer : IDisposable
_quadVbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo);
fixed (void* p = quadVerts)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(quadVerts.Length * sizeof(float)), p, BufferUsageARB.StaticDraw);
{
_gl.BufferData(
BufferTargetARB.ArrayBuffer,
(nuint)(quadVerts.Length * sizeof(float)),
p,
BufferUsageARB.StaticDraw);
}
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0);
_gl.EnableVertexAttribArray(1);
@ -77,135 +88,347 @@ public sealed unsafe class ParticleRenderer : IDisposable
_quadEbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo);
fixed (void* p = quadIdx)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(quadIdx.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
{
_gl.BufferData(
BufferTargetARB.ElementArrayBuffer,
(nuint)(quadIdx.Length * sizeof(uint)),
p,
BufferUsageARB.StaticDraw);
}
_instanceVbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 8 * sizeof(float)),
(void*)0, BufferUsageARB.DynamicDraw);
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 16 * sizeof(float)), (void*)0, BufferUsageARB.DynamicDraw);
// Per-instance attributes: pos+size at loc 2, color at loc 3.
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)0);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)0);
_gl.VertexAttribDivisor(2, 1);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)(4 * sizeof(float)));
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(4 * sizeof(float)));
_gl.VertexAttribDivisor(3, 1);
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(8 * sizeof(float)));
_gl.VertexAttribDivisor(4, 1);
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(12 * sizeof(float)));
_gl.VertexAttribDivisor(5, 1);
_gl.BindVertexArray(0);
}
/// <summary>
/// Draw every live particle. Splits emitters by blend mode (additive
/// vs alpha-blend) but doesn't sort by depth — particles don't
/// self-occlude enough for sorting to matter for rain/snow.
/// </summary>
public void Draw(ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos)
public void Draw(
ParticleSystem particles,
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
{
if (particles is null || camera is null) return;
if (particles is null || camera is null)
return;
Matrix4x4.Invert(camera.View, out var invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp);
if (draws.Count == 0)
return;
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
_shader.SetVec3("uCameraRight", GetCameraRight(camera));
_shader.SetVec3("uCameraUp", GetCameraUp(camera));
_shader.SetInt("uParticleTexture", 0);
_gl.Enable(EnableCap.DepthTest);
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
_gl.Disable(EnableCap.CullFace);
_gl.ActiveTexture(TextureUnit.Texture0);
// Group emitters by additive vs alpha-blend so we flip blend state
// once per group rather than per-emitter. Simple two-pass split.
var alphaGroup = new List<ParticleEmitter>(32);
var addGroup = new List<ParticleEmitter>(32);
foreach (var (em, _) in particles.EnumerateLive())
var run = new List<ParticleInstance>(64);
for (int i = 0; i < draws.Count;)
{
var list = (em.Desc.Flags & EmitterFlags.Additive) != 0 ? addGroup : alphaGroup;
if (list.Count == 0 || !ReferenceEquals(list[^1], em))
list.Add(em);
var key = draws[i].Key;
run.Clear();
do
{
run.Add(draws[i].Instance);
i++;
}
while (i < draws.Count && draws[i].Key == key);
_gl.BlendFunc(
BlendingFactor.SrcAlpha,
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
_shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0);
_gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0);
DrawInstances(run);
}
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
foreach (var em in alphaGroup)
DrawEmitter(em, cameraWorldPos);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
foreach (var em in addGroup)
DrawEmitter(em, cameraWorldPos);
_gl.BindTexture(TextureTarget.Texture2D, 0);
_gl.BindVertexArray(0);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.BindVertexArray(0);
}
private void DrawEmitter(ParticleEmitter em, Vector3 cameraWorldPos)
private List<ParticleDraw> BuildDrawList(
ParticleSystem particles,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
Vector3 cameraRight,
Vector3 cameraUp)
{
int liveCount = 0;
for (int i = 0; i < em.Particles.Length; i++)
if (em.Particles[i].Alive) liveCount++;
if (liveCount == 0) return;
// Ensure instance buffer is big enough.
int needed = liveCount * 8;
if (_instanceScratch.Length < needed)
_instanceScratch = new float[needed + 256 * 8];
// Anchor adjustment for AttachLocal emitters — re-center the
// emission volume on the camera each frame so the rain/snow
// follows the viewer. The emitter's AnchorPos stays at the
// spawn point, but when writing out world-space particles we
// add (camera - emitterAnchor) so they track the camera.
bool attachLocal = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0;
Vector3 cameraOffset = attachLocal ? (cameraWorldPos - em.AnchorPos) : Vector3.Zero;
int idx = 0;
for (int i = 0; i < em.Particles.Length; i++)
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
foreach (var (em, idx) in particles.EnumerateLive())
{
ref var p = ref em.Particles[i];
if (!p.Alive) continue;
if (em.RenderPass != renderPass)
continue;
Vector3 pos = p.Position + cameraOffset;
_instanceScratch[idx * 8 + 0] = pos.X;
_instanceScratch[idx * 8 + 1] = pos.Y;
_instanceScratch[idx * 8 + 2] = pos.Z;
_instanceScratch[idx * 8 + 3] = p.Size;
ref var p = ref em.Particles[idx];
// `p.Position` is already in world coordinates: AttachLocal
// emitters get their AnchorPos refreshed each frame by the
// owning subsystem (sky-PES driver, animation tick, etc.) which
// mirrors retail's live-parent-frame read at
// ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1.
Vector3 pos = p.Position;
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
var gfxInfo = ResolveParticleGfxInfo(em.Desc);
uint texture = gfxInfo.TextureHandle;
bool useTexture = texture != 0;
bool additive = gfxInfo.HasMaterial
? gfxInfo.Additive
: (em.Desc.Flags & EmitterFlags.Additive) != 0;
var key = new BatchKey(texture, useTexture, additive);
Vector3 axisX;
Vector3 axisY;
if (gfxInfo.IsBillboard)
{
pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size);
axisX = cameraRight * (gfxInfo.Size.X * p.Size);
axisY = cameraUp * (gfxInfo.Size.Y * p.Size);
}
else
{
Quaternion orientation = ParticleOrientation(em, p);
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
}
// ARGB → RGBA floats.
float a = ((p.ColorArgb >> 24) & 0xFF) / 255f;
float r = ((p.ColorArgb >> 16) & 0xFF) / 255f;
float g = ((p.ColorArgb >> 8) & 0xFF) / 255f;
float b = ( p.ColorArgb & 0xFF) / 255f;
_instanceScratch[idx * 8 + 4] = r;
_instanceScratch[idx * 8 + 5] = g;
_instanceScratch[idx * 8 + 6] = b;
_instanceScratch[idx * 8 + 7] = a;
draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq)));
}
idx++;
return draws;
}
private void DrawInstances(List<ParticleInstance> instances)
{
if (instances.Count == 0)
return;
int needed = instances.Count * 16;
if (_instanceScratch.Length < needed)
_instanceScratch = new float[needed + 256 * 16];
for (int i = 0; i < instances.Count; i++)
{
var p = instances[i];
int o = i * 16;
_instanceScratch[o + 0] = p.Position.X;
_instanceScratch[o + 1] = p.Position.Y;
_instanceScratch[o + 2] = p.Position.Z;
_instanceScratch[o + 3] = 0f;
_instanceScratch[o + 4] = p.AxisX.X;
_instanceScratch[o + 5] = p.AxisX.Y;
_instanceScratch[o + 6] = p.AxisX.Z;
_instanceScratch[o + 7] = 0f;
_instanceScratch[o + 8] = p.AxisY.X;
_instanceScratch[o + 9] = p.AxisY.Y;
_instanceScratch[o + 10] = p.AxisY.Z;
_instanceScratch[o + 11] = 0f;
_instanceScratch[o + 12] = ((p.ColorArgb >> 16) & 0xFF) / 255f;
_instanceScratch[o + 13] = ((p.ColorArgb >> 8) & 0xFF) / 255f;
_instanceScratch[o + 14] = (p.ColorArgb & 0xFF) / 255f;
_instanceScratch[o + 15] = ((p.ColorArgb >> 24) & 0xFF) / 255f;
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
fixed (void* bp = _instanceScratch)
{
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(liveCount * 8 * sizeof(float)),
bp, BufferUsageARB.DynamicDraw);
_gl.BufferData(
BufferTargetARB.ArrayBuffer,
(nuint)(instances.Count * 16 * sizeof(float)),
bp,
BufferUsageARB.DynamicDraw);
}
_gl.BindVertexArray(_quadVao);
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6,
DrawElementsType.UnsignedInt, (void*)0, (uint)liveCount);
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count);
}
private static Vector3 GetCameraRight(ICamera camera)
private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc)
{
Matrix4x4.Invert(camera.View, out var inv);
return Vector3.Normalize(new Vector3(inv.M11, inv.M12, inv.M13));
if (_textures is null)
return ParticleGfxInfo.Default;
if (desc.TextureSurfaceId != 0)
return ParticleGfxInfo.Billboard(
_textures.GetOrUpload(desc.TextureSurfaceId),
Vector2.One,
Vector3.Zero,
additive: (desc.Flags & EmitterFlags.Additive) != 0,
hasMaterial: false);
uint gfxObjId = desc.HwGfxObjId != 0 ? desc.HwGfxObjId : desc.GfxObjId;
if (gfxObjId == 0 || _dats is null)
return ParticleGfxInfo.Default;
if (!_particleGfxInfoByGfxObj.TryGetValue(gfxObjId, out var info))
{
info = ReadParticleGfxInfo(gfxObjId);
_particleGfxInfoByGfxObj[gfxObjId] = info;
}
return info.TextureHandle != 0 ? info : ParticleGfxInfo.Default;
}
private static Vector3 GetCameraUp(ICamera camera)
private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId)
{
Matrix4x4.Invert(camera.View, out var inv);
return Vector3.Normalize(new Vector3(inv.M21, inv.M22, inv.M23));
try
{
var gfx = _dats?.Get<GfxObj>(gfxObjId);
if (gfx is null)
return ParticleGfxInfo.Default;
uint surfaceId = gfx.Surfaces.Count > 0 ? gfx.Surfaces[0].DataId : 0u;
uint texture = surfaceId != 0 && _textures is not null ? _textures.GetOrUpload(surfaceId) : 0u;
bool additive = false;
if (surfaceId != 0)
{
var surface = _dats?.Get<Surface>(surfaceId);
additive = surface is not null && surface.Type.HasFlag(SurfaceType.Additive);
}
return AuthoredParticleGfxInfo(gfx, texture, additive, surfaceId != 0);
}
catch
{
return ParticleGfxInfo.Default;
}
}
private ParticleGfxInfo AuthoredParticleGfxInfo(GfxObj gfx, uint texture, bool additive, bool hasMaterial)
{
if (gfx.VertexArray.Vertices.Count == 0)
return ParticleGfxInfo.Billboard(texture, Vector2.One, Vector3.Zero, additive, hasMaterial);
var min = new Vector3(float.PositiveInfinity);
var max = new Vector3(float.NegativeInfinity);
foreach (var (_, v) in gfx.VertexArray.Vertices)
{
min = Vector3.Min(min, v.Origin);
max = Vector3.Max(max, v.Origin);
}
var size = max - min;
var center = (min + max) * 0.5f;
if (IsPointSprite(gfx))
{
float sx = FallbackParticleExtent(size.X) * 0.9f;
float sy = FallbackParticleExtent(size.Z) * 0.9f;
return ParticleGfxInfo.Billboard(texture, new Vector2(sx, sy), center, additive, hasMaterial);
}
Vector3 axisX;
Vector3 axisY;
Vector2 planeSize;
if (size.Y > size.X && size.Y > size.Z)
{
if (size.X > size.Z)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitY;
planeSize = new Vector2(size.X, size.Y);
}
else
{
axisX = Vector3.UnitY;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.Y, size.Z);
}
}
else if (size.X > size.Y && size.X > size.Z)
{
if (size.Z > size.Y)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.X, size.Z);
}
else
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitY;
planeSize = new Vector2(size.X, size.Y);
}
}
else
{
if (size.X > size.Y)
{
axisX = Vector3.UnitX;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.X, size.Z);
}
else
{
axisX = Vector3.UnitY;
axisY = Vector3.UnitZ;
planeSize = new Vector2(size.Y, size.Z);
}
}
planeSize.X = FallbackParticleExtent(planeSize.X);
planeSize.Y = FallbackParticleExtent(planeSize.Y);
return new ParticleGfxInfo(texture, planeSize, axisX, axisY, center, false, additive, hasMaterial);
}
private bool IsPointSprite(GfxObj gfx)
{
if (!gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0 || _dats is null)
return false;
try
{
var degrade = _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade);
return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2;
}
catch
{
return false;
}
}
private static float FallbackParticleExtent(float value)
=> value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f;
private static Quaternion ParticleOrientation(AcDream.Core.Vfx.ParticleEmitter em, Particle p)
{
Quaternion orientation = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0
? em.AnchorRot
: p.SpawnRotation;
if (em.Desc.Type is AcDream.Core.Vfx.ParticleType.ParabolicLVGAGR
or AcDream.Core.Vfx.ParticleType.ParabolicLVLALR
or AcDream.Core.Vfx.ParticleType.ParabolicGVGAGR)
{
Vector3 angular = p.C * p.Age;
float radians = angular.Length();
if (radians > 1e-6f)
orientation = Quaternion.Normalize(orientation * Quaternion.CreateFromAxisAngle(angular / radians, radians));
}
return orientation;
}
public void Dispose()
@ -216,4 +439,26 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.DeleteVertexArray(_quadVao);
_shader.Dispose();
}
private readonly record struct ParticleGfxInfo(
uint TextureHandle,
Vector2 Size,
Vector3 AxisX,
Vector3 AxisY,
Vector3 CenterOffset,
bool IsBillboard,
bool Additive,
bool HasMaterial)
{
public static ParticleGfxInfo Default { get; } =
Billboard(0u, Vector2.One, Vector3.Zero, additive: false, hasMaterial: false);
public static ParticleGfxInfo Billboard(
uint textureHandle,
Vector2 size,
Vector3 centerOffset,
bool additive,
bool hasMaterial) =>
new(textureHandle, size, Vector3.UnitX, Vector3.UnitY, centerOffset, true, additive, hasMaterial);
}
}

View file

@ -0,0 +1,63 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Two persistent GL sampler objects (Repeat + ClampToEdge) created once
/// per GL context. Renderers <see cref="GL.BindSampler"/> the appropriate
/// one to a texture unit instead of mutating per-texture
/// <c>GL_TEXTURE_WRAP_S/T</c> state — sampler state overrides the
/// texture's own wrap parameters, so two renderers can share the same
/// texture handle but sample it with different wrap modes safely.
///
/// <para>
/// Ported from
/// <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132</c>.
/// Filter modes match <see cref="TextureCache"/>'s upload defaults
/// (Linear / Linear, no mipmaps) so binding either sampler doesn't
/// change the visual filtering behavior — only the wrap behavior at
/// UVs outside [0, 1].
/// </para>
///
/// <para>
/// Lifetime: created once at GL init, disposed with the GL context.
/// Anything that binds a sampler MUST unbind it (<c>BindSampler(unit, 0)</c>)
/// before yielding to a renderer that doesn't use samplers, otherwise
/// the bound sampler's wrap mode will silently override that renderer's
/// per-texture wrap state.
/// </para>
/// </summary>
public sealed class SamplerCache : IDisposable
{
private readonly GL _gl;
/// <summary>Sampler with WrapS = WrapT = Repeat. The default for textures uploaded by <see cref="TextureCache"/>.</summary>
public uint Wrap { get; }
/// <summary>Sampler with WrapS = WrapT = ClampToEdge. Used by sky meshes whose authored UVs are strictly in [0, 1] to avoid bilinear-filter bleed at seam edges.</summary>
public uint Clamp { get; }
public SamplerCache(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
Wrap = _gl.GenSampler();
_gl.SamplerParameter(Wrap, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat);
_gl.SamplerParameter(Wrap, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat);
_gl.SamplerParameter(Wrap, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear);
_gl.SamplerParameter(Wrap, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
Clamp = _gl.GenSampler();
_gl.SamplerParameter(Clamp, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge);
_gl.SamplerParameter(Clamp, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge);
_gl.SamplerParameter(Clamp, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear);
_gl.SamplerParameter(Clamp, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
}
public void Dispose()
{
if (Wrap != 0) _gl.DeleteSampler(Wrap);
if (Clamp != 0) _gl.DeleteSampler(Clamp);
}
}

View file

@ -4,15 +4,23 @@ in vec2 vTex;
in vec4 vColor;
out vec4 fragColor;
// Procedural rain/snow streak — no texture, just a radial falloff
// centred on the quad so droplets read as small soft circles. Good
// enough for weather + basic spell auras without a texture pipeline.
uniform sampler2D uParticleTexture;
uniform bool uUseTexture;
void main() {
// Signed distance from quad center (in UV space).
vec2 d = vTex - vec2(0.5, 0.5);
float r = length(d) * 2.0; // 0 at center, 1 at corner
float falloff = smoothstep(1.0, 0.4, r);
if (falloff < 0.02) discard;
fragColor = vec4(vColor.rgb, vColor.a * falloff);
vec4 texel;
if (uUseTexture) {
texel = texture(uParticleTexture, vTex);
} else {
vec2 d = vTex - vec2(0.5, 0.5);
float r = length(d) * 2.0;
float falloff = smoothstep(1.0, 0.4, r);
texel = vec4(1.0, 1.0, 1.0, falloff);
}
vec4 color = texel * vColor;
if (color.a < 0.02)
discard;
fragColor = color;
}

View file

@ -4,26 +4,21 @@
layout(location = 0) in vec2 aQuad;
layout(location = 1) in vec2 aTex;
// Per-instance: world-space center + size
layout(location = 2) in vec4 aPosAndSize;
layout(location = 3) in vec4 aColor;
// Per-instance: world-space center, authored sheet axes, color.
layout(location = 2) in vec4 aCenter;
layout(location = 3) in vec4 aAxisX;
layout(location = 4) in vec4 aAxisY;
layout(location = 5) in vec4 aColor;
uniform mat4 uViewProjection;
uniform vec3 uCameraRight;
uniform vec3 uCameraUp;
out vec2 vTex;
out vec4 vColor;
void main() {
vec3 center = aPosAndSize.xyz;
float size = aPosAndSize.w;
// Billboard: offset the quad vertex along the camera's right + up
// basis vectors so it always faces the viewer.
vec3 world = center
+ uCameraRight * (aQuad.x * size)
+ uCameraUp * (aQuad.y * size);
vec3 world = aCenter.xyz
+ aAxisX.xyz * aQuad.x
+ aAxisY.xyz * aQuad.y;
vTex = aTex;
vColor = aColor;

View file

@ -1,46 +1,15 @@
#version 430 core
// Sky mesh fragment shader — final composite matching retail's
// D3D fixed-function:
//
// fragment.rgb = texture.rgb × vTint + lightning_flash
// fragment.a = texture.a × (1 - uTransparency) × uSurfTranslucency
// (uSurfTranslucency is OPACITY directly per retail's
// D3DPolyRender::SetSurface at 0x59c7a6, NOT 1-x)
//
// vTint arrives from the vertex shader with retail's per-vertex
// lighting formula baked in (Emissive + lightAmbient + lightDiffuse ×
// max(N·L, 0)) — see sky.vert for the decompile citation. The keyframe
// SkyObjectReplace.Luminosity override is folded into uEmissive on the
// CPU side (SkyRenderer.cs) so vTint already saturates properly for
// bright keyframes; the previous shader had a redundant uLuminosity
// multiply that was double-dimming clouds, removed 2026-04-26.
//
// See `docs/research/2026-04-23-sky-material-state.md`.
in vec2 vTex;
in vec3 vTint;
in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far)
in float vFogFactor; // 1 = no fog, 0 = full fog color
out vec4 fragColor;
uniform sampler2D uDiffuse;
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
// 1.0 = apply fog mix to this submesh; 0.0 = skip fog (Additive sky
// surfaces — sun/moon/stars per retail SetFFFogAlphaDisabled(1) at
// D3DPolyRender::SetSurface 0x59c882). Set per-submesh on the CPU side.
uniform float uApplyFog;
// Surface.Translucency float (0..1) used DIRECTLY as opacity (NOT 1-x).
// Distinct from uTransparency (per-keyframe Replace override). Retail
// D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) reads
// Surface.Translucency when the Translucent (0x10) bit is set and feeds
// _ftol2(translucency × 255) directly as vertex alpha. ACViewer
// (TextureCache.cs:142) + WorldBuilder (ObjectMeshManager.cs:1115) both
// invert it (1-x) and are wrong. For non-Translucent surfaces the CPU
// side (GfxObjMesh.Build) sets uSurfTranslucency = 1.0 ⇒ no effect.
uniform float uSurfTranslucency;
uniform float uTransparency; // keyframe transparency: 0 visible, 1 transparent
uniform float uApplyFog; // 1 for foggable sky layers; raw-additive surfaces keep retail fog disabled
uniform float uSurfOpacity; // final surface opacity multiplier from the CPU
// Shared SceneLighting UBO — fog params drive the mix, flash channel
// bumps sky brightness during lightning strikes. Matches sky.vert's
// declaration exactly.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
@ -58,79 +27,21 @@ layout(std140, binding = 1) uniform SceneLighting {
void main() {
vec4 sampled = texture(uDiffuse, vTex);
// Composite: texture × per-vertex lit. Replace.Luminosity (per
// keyframe) and Surface.Luminosity are both folded into uEmissive
// on the CPU side (SkyRenderer.cs) so vTint already carries the
// right tint for the time-of-day. Retail's fragment formula
// (FUN_0059da60 non-luminous branch) is texture × litColor ×
// vertex.color(=white), so `texture × vTint` is the retail-faithful
// composite.
vec3 rgb = sampled.rgb * vTint;
// Retail-faithful sky fog mix with a "fog floor" mitigation:
//
// Dereth sky meshes are authored at radii 10501820m. At midnight
// (storm keyframes FogEnd ~400m) the raw vFogFactor saturates to 0
// for every dome pixel — `mix(fogColor, rgb, 0)` would render the
// entire dome as flat fogColor, destroying stars / moon / texture.
// That was the reason fog was disabled on sky 2026-04-24 (issue #4).
//
// Retail clearly DOES apply fog to its sky meshes — distant horizon
// mountains and the dome itself fade toward the fog color in retail
// screenshots. Mechanism unknown (sky-specific FogEnd? elevation-
// weighted? different formula?). Until pinned, the workaround is
// a clamp on the minimum fog factor so the dome NEVER mixes more
// than (1 - SKY_FOG_FLOOR) toward fogColor — preserves stars/moon
// while still letting the horizon haze visibly in low-FogEnd
// keyframes.
//
// SKY_FOG_FLOOR=0.2 means dome shows AT LEAST 20% raw texture, AT
// MOST 80% fog color even at extreme distances. Tuned via dual-
// client visual comparison 2026-04-27 — adjust if night sky goes
// back to flat-fog or stays too vivid vs retail.
// Skip fog mix entirely on Additive surfaces (sun, moon, stars,
// additive cloud sheets) — retail's SetFFFogAlphaDisabled(1) at
// D3DPolyRender::SetSurface 0x59c882. Without this gate the sun
// dims to fog color at horizon, which doesn't match retail.
if (uApplyFog > 0.5) {
const float SKY_FOG_FLOOR = 0.2;
float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR);
rgb = mix(uFogColor.rgb, rgb, skyFogFactor);
}
// Lightning additive bump — client-driven during storm flashes.
// NOTE: the exact retail mechanism for lightning visual is still
// under research (agent #5, 2026-04-23). Keeping the uFogParams.z
// channel wired so if it ends up being a per-frame flash uniform
// that's what it becomes; if lightning turns out to be a particle
// system effect instead, this bump becomes a no-op (flash stays 0).
float flash = uFogParams.z;
rgb += flash * vec3(1.5, 1.5, 1.8);
// Normal-frame cap at 1.0 (retail D3D framebuffer clamps per-channel
// on output). Flash relaxes ceiling to 3.0 so storm strobes blow
// out visibly.
float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0));
rgb = min(rgb, vec3(cap));
// Final fragment alpha:
// uTransparency — keyframe-replace transparency override (0..1).
// 0 = fully visible, 1 = fully transparent.
// Applied as (1 - x).
// uSurfTranslucency — the dat's Surface.Translucency value when the
// Translucent flag is set, else 1.0. Despite the
// name, retail uses this as OPACITY directly (per
// D3DPolyRender::SetSurface at 0x59c7a6 which
// writes _ftol2(translucency × 255) into vertex
// alpha). Multiply directly — NOT (1 - x).
//
// For the rain mesh 0x01004C42/4C44 (translucency=0.5): a = 1*1*0.5 = 0.5
// matches retail curr_alpha=127, halves the additive streak.
// For cloud surface 0x08000023 (translucency=0.25): a = 1*1*0.25 = 0.25
// matches retail curr_alpha=63, dim cloud (was 3× too bright with
// the previous 1-x formula).
// For non-Translucent surfaces uSurfTranslucency = 1.0, no effect.
float a = sampled.a * (1.0 - uTransparency) * uSurfTranslucency;
float a = sampled.a * (1.0 - uTransparency) * uSurfOpacity;
if (a < 0.01) discard;
fragColor = vec4(rgb, a);
}

View file

@ -47,6 +47,7 @@ uniform vec3 uSunDir; // unit vector FROM surface TO sun
// Per-submesh (from Surface.Luminosity float):
uniform float uEmissive;
uniform float uDiffuseFactor;
// Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to
// compute the vertex fog factor. Must match sky.frag's declaration.
@ -87,7 +88,7 @@ void main() {
float diff = max(dot(worldNormal, uSunDir), 0.0);
vec3 lit = vec3(uEmissive) // material.Emissive
+ uAmbientColor // material.Ambient(1) × light.Ambient
+ uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L
+ (uSunColor * uDiffuseFactor) * diff;
vTint = clamp(lit, 0.0, 1.0);
// Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR,

View file

@ -48,6 +48,7 @@ public sealed unsafe class SkyRenderer : IDisposable
private readonly DatCollection _dats;
private readonly Shader _shader;
private readonly TextureCache _textures;
private readonly SamplerCache _samplers;
// Lazily-built GPU resources per sky-GfxObj.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
@ -61,12 +62,13 @@ public sealed unsafe class SkyRenderer : IDisposable
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f;
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures)
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures, SamplerCache samplers)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_shader = shader ?? throw new ArgumentNullException(nameof(shader));
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
_samplers = samplers ?? throw new ArgumentNullException(nameof(samplers));
}
/// <summary>
@ -106,8 +108,10 @@ public sealed unsafe class SkyRenderer : IDisposable
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false);
SkyKeyframe keyframe,
bool environOverrideActive = false)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
postScenePass: false, environOverrideActive: environOverrideActive);
/// <summary>
/// Draw the POST-SCENE sky objects (the foreground rain mesh
@ -134,8 +138,10 @@ public sealed unsafe class SkyRenderer : IDisposable
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true);
SkyKeyframe keyframe,
bool environOverrideActive = false)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
postScenePass: true, environOverrideActive: environOverrideActive);
/// <summary>
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
@ -151,7 +157,8 @@ public sealed unsafe class SkyRenderer : IDisposable
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe,
bool postScenePass)
bool postScenePass,
bool environOverrideActive)
{
if (group is null || group.SkyObjects.Count == 0) return;
@ -227,6 +234,11 @@ public sealed unsafe class SkyRenderer : IDisposable
// foreground rain — double-thick rain not matching retail.
if (obj.IsPostScene != postScenePass) continue;
if (!obj.IsVisible(dayFraction)) continue;
// Retail GameSky::Draw (0x00506ff0) skips Properties bit 0x02
// objects while an AdminEnvirons fog override is active. Normal
// DayGroup fog/tint still draws them.
if (environOverrideActive && (obj.Properties & 0x02u) != 0u)
continue;
// Apply per-keyframe replace overrides.
uint gfxObjId = obj.GfxObjId;
@ -243,20 +255,18 @@ public sealed unsafe class SkyRenderer : IDisposable
// NO Dereth sky surface carries the SurfaceType.Luminous flag
// bit (0x40) — the differentiator is purely the float field.
float replaceLuminosity = float.NaN;
float replaceDiffuse = float.NaN;
if (replaces.TryGetValue((uint)i, out var rep))
{
if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId;
if (rep.Rotate != 0f) headingDeg = rep.Rotate;
transparent = Math.Clamp(rep.Transparent, 0f, 1f);
if (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity;
// MaxBright is a CAP: even if the surface authored Lum=1.0,
// a per-keyframe MaxBright trims it. When no explicit
// Luminosity replace exists, MaxBright still acts as the
// ceiling (applied against sub.SurfLuminosity at draw time).
// Retail GameSky::UseTime routes max_bright through
// CPhysicsObj::SetDiffusion, so it replaces material diffuse,
// not emissive/luminosity.
if (rep.MaxBright > 0f)
replaceLuminosity = float.IsNaN(replaceLuminosity)
? rep.MaxBright
: MathF.Min(replaceLuminosity, rep.MaxBright);
replaceDiffuse = rep.MaxBright;
}
if (gfxObjId == 0) continue;
@ -277,18 +287,24 @@ public sealed unsafe class SkyRenderer : IDisposable
// if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
// int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
//
// Weather objects (property bit 0x04 set, bit 0x08 unset)
// have their frame origin set to player_pos + (0, 0, -120m).
// The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local
// Z range 0.11..814.90 (815m tall, 113m radius). Without the
// offset the cylinder bottom sits at z=0.11 ABOVE the camera
// (skyView translation is zeroed so model-origin == camera);
// looking horizontally shows nothing, looking up shows a
// distant cylinder. With -120m the cylinder spans z =
// (camera-119.89)..(camera+694.90) in view space — camera
// is inside, looking in any direction shows surrounding
// walls — the volumetric foreground-rain look retail has.
if (postScenePass)
// Gate: bit 0x04 (weather) set AND bit 0x08 unset. NOT every
// post-scene SkyObject — bit 0x01 (post-scene) is independent
// of bit 0x04 (weather). Today's Dereth ships every post-scene
// entry as also weather-flagged so the previous unconditional
// offset was a no-op divergence, but a future DayGroup with a
// post-scene-but-not-weather entry (e.g. a foreground sun rim)
// would have been pushed 120m below the camera and rendered as
// floor lint.
//
// Without the offset on the rain cylinder GfxObjs
// 0x01004C42/0x01004C44 (local Z range 0.11..814.90) the
// cylinder bottom sits at z=0.11 ABOVE the camera (skyView
// translation is zeroed so model-origin == camera); looking
// horizontally shows nothing. With -120m the cylinder spans z
// = (camera-119.89)..(camera+694.90) — camera is inside,
// looking in any direction shows surrounding walls — the
// volumetric foreground-rain look retail has.
if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u)
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
_shader.SetMatrix4("uModel", model);
@ -343,20 +359,17 @@ public sealed unsafe class SkyRenderer : IDisposable
float effEmissive = float.IsNaN(replaceLuminosity)
? sub.SurfLuminosity
: replaceLuminosity;
float effDiffuse = float.IsNaN(replaceDiffuse)
? sub.SurfDiffuse
: replaceDiffuse;
_shader.SetFloat("uEmissive", effEmissive);
_shader.SetFloat("uDiffuseFactor", effDiffuse);
// Retail per-Surface translucency override (D3DPolyRender::SetSurface
// at 0x59c7a6, decomp 425255-425260): when the Surface's
// Translucent (0x10) bit is set, retail computes
// curr_alpha = _ftol2(translucency × 255) and writes it as vertex
// alpha — i.e. the dat's Translucency float is the OPACITY
// directly, NOT inverted. ACViewer and WorldBuilder both invert
// it (1 - x) and are wrong by the same misread. The shader uses
// it directly as an opacity multiplier; for non-Translucent
// surfaces the GfxObjMesh.Build() path keeps SurfTranslucency=1.0
// (no effect). Critical for rain (Translucency=0.5 → opacity 0.5)
// and clouds (Translucency=0.25 → opacity 0.25, dim like retail).
_shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency);
// Material alpha is final opacity: 1 - Surface.Translucency
// for Translucent surfaces, 1 for non-Translucent surfaces.
// The CPU computes it once so the shader just multiplies it
// with texture alpha and keyframe transparency.
_shader.SetFloat("uSurfOpacity", sub.SurfOpacity);
// Retail D3DPolyRender::SetSurface at 0x59c882 calls
// SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
@ -364,9 +377,12 @@ public sealed unsafe class SkyRenderer : IDisposable
// additive cloud sheet are drawn WITHOUT fog. Skipping fog
// on additive surfaces keeps the sun bright at horizon
// dusk/dawn (where fog would otherwise dim it to fog color).
// Non-additive sky meshes (the dome, opaque cloud layers)
// still mix toward fog with the floor mitigation in sky.frag.
_shader.SetFloat("uApplyFog", sub.IsAdditive ? 0f : 1f);
// Non-additive sky meshes (the dome/background layers)
// still mix toward keyframe fog with the floor mitigation
// in sky.frag. That restores the broad green/purple Rainy
// DayGroup tint behind the cloud sheet while raw-additive
// 0x08000023 remains unfogged and keeps the pink detail.
_shader.SetFloat("uApplyFog", sub.DisableFog ? 0f : 1f);
uint tex = _textures.GetOrUpload(sub.SurfaceId);
_gl.ActiveTexture(TextureUnit.Texture0);
@ -393,14 +409,17 @@ public sealed unsafe class SkyRenderer : IDisposable
// Scrolling clouds are also forced to REPEAT (the running
// UV offset can drift outside [0,1] regardless of authored
// range, and they'd show their own seam bleed otherwise).
//
// Implementation: bind a persistent sampler object to
// texture unit 0. Sampler state overrides the texture's
// own wrap state, so two renderers can share the same
// texture handle but sample it with different wrap modes
// safely. Ported from WorldBuilder
// (Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:312).
bool needsRepeat = sub.NeedsUvRepeat
|| obj.TexVelocityX != 0f
|| obj.TexVelocityY != 0f;
int wrapMode = needsRepeat
? (int)TextureWrapMode.Repeat
: (int)TextureWrapMode.ClampToEdge;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, wrapMode);
_gl.BindSampler(0, needsRepeat ? _samplers.Wrap : _samplers.Clamp);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles,
@ -411,6 +430,12 @@ public sealed unsafe class SkyRenderer : IDisposable
}
// Restore GL state expected by the rest of the pipeline.
// Critical: unbind the sampler from unit 0. While bound, sampler
// state overrides the texture's own wrap parameters, so leaving
// (e.g.) Clamp bound would silently force ClampToEdge on every
// subsequent draw on unit 0 regardless of how that texture was
// configured at upload time.
_gl.BindSampler(0, 0);
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
_gl.Enable(EnableCap.DepthTest);
@ -639,7 +664,7 @@ public sealed unsafe class SkyRenderer : IDisposable
Console.WriteLine(
$"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " +
$"OrigTexture=0x{origTex:X8} Translucency={trans} " +
$"SurfLuminosity={surface.Luminosity:F4} SurfTranslucency={surface.Translucency:F4}");
$"SurfLuminosity={surface.Luminosity:F4} SurfaceTranslucency={surface.Translucency:F4}");
}
}
@ -692,8 +717,10 @@ public sealed unsafe class SkyRenderer : IDisposable
SurfaceId = sm.SurfaceId,
IsAdditive = isAdditive,
SurfLuminosity = sm.Luminosity,
SurfDiffuse = sm.Diffuse,
NeedsUvRepeat = sm.NeedsUvRepeat,
SurfTranslucency = sm.SurfTranslucency,
SurfOpacity = sm.SurfOpacity,
DisableFog = sm.DisableFog,
};
}
@ -733,6 +760,7 @@ public sealed unsafe class SkyRenderer : IDisposable
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
/// </summary>
public float SurfLuminosity;
public float SurfDiffuse;
/// <summary>
/// True when the source mesh's authored UVs exceed [0,1] (e.g.
/// the inner sky/star layer 0x010015EF and the cloud meshes —
@ -744,17 +772,11 @@ public sealed unsafe class SkyRenderer : IDisposable
/// </summary>
public bool NeedsUvRepeat;
/// <summary>
/// <c>Surface.Translucency</c> float (0..1) carried through from
/// <see cref="GfxObjSubMesh.SurfTranslucency"/>. Passed to the
/// sky fragment shader as <c>uSurfTranslucency</c> and used
/// DIRECTLY as opacity (NOT <c>1 - x</c>). Retail's
/// <c>D3DPolyRender::SetSurface</c> at <c>0x59c7a6</c>
/// (decomp lines 425255-425260) computes
/// <c>curr_alpha = _ftol2(translucency × 255)</c> and writes that
/// as vertex.color.alpha — i.e. translucency is opacity directly.
/// For non-Translucent surfaces the GfxObjMesh.Build() path keeps
/// this at 1.0 so they stay fully opaque.
/// Final surface opacity from <see cref="GfxObjSubMesh.SurfOpacity"/>.
/// Translucent surfaces use <c>1 - Surface.Translucency</c>; other
/// surfaces stay at 1.0.
/// </summary>
public float SurfTranslucency;
public float SurfOpacity;
public bool DisableFog;
}
}

View file

@ -178,8 +178,9 @@ public sealed unsafe class TextureCache : IDisposable
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
return DecodedTexture.Magenta;
var rs = _dats.Get<RenderSurface>((uint)surfaceTexture.Textures[0]);
if (rs is null)
uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return DecodedTexture.Magenta;
// Start with the texture's default palette, then apply overlays.

View file

@ -156,22 +156,20 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.VictimNotification, e =>
{
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
if (p is not null) combat.OnVictimNotification(
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType);
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
});
dispatcher.Register(GameEventType.DefenderNotification, e =>
{
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
if (p is not null) combat.OnDefenderNotification(
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
p.Value.AttackerName, 0u, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
});
dispatcher.Register(GameEventType.AttackerNotification, e =>
{
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
if (p is not null) combat.OnAttackerNotification(
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent);
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent);
});
dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
{
@ -188,12 +186,15 @@ public static class GameEventWiring
var p = GameEvents.ParseAttackDone(e.Payload.Span);
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
});
dispatcher.Register(GameEventType.CombatCommenceAttack, e =>
{
if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span))
combat.OnCombatCommenceAttack();
});
dispatcher.Register(GameEventType.KillerNotification, e =>
{
// ISSUES.md #10 — orphan parser, never registered before. The
// server fires this after a player lands a killing blow.
var p = GameEvents.ParseKillerNotification(e.Payload.Span);
if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid);
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info);
});
// ── Spells ────────────────────────────────────────────────

View file

@ -3,60 +3,79 @@ using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound <c>0x0008 AttackTargetRequest</c> GameAction.
/// Outbound combat attack GameActions.
///
/// Retail/ACE use distinct payloads for melee and missile:
///
/// <para>
/// Wire layout (inside the <c>0xF7B1</c> GameAction envelope):
/// <code>
/// u32 0xF7B1 // GameAction envelope opcode
/// u32 gameActionSequence // client sequence
/// u32 0x0008 // sub-opcode
/// u32 targetGuid // who to attack
/// f32 powerLevel // [0.0, 1.0] — the power bar position
/// f32 accuracyLevel // [0.0, 1.0] — for missile weapons
/// u32 0x0008 // TargetedMeleeAttack
/// u32 targetGuid
/// u32 attackHeight // 1=High, 2=Medium, 3=Low
/// f32 powerLevel // [0.0, 1.0]
///
/// u32 0xF7B1
/// u32 gameActionSequence
/// u32 0x000A // TargetedMissileAttack
/// u32 targetGuid
/// u32 attackHeight
/// f32 accuracyLevel // [0.0, 1.0]
/// </code>
/// </para>
///
/// <para>
/// The server ALREADY knows the attacker (it's the session's player),
/// so this message only carries the target + attack params. The server
/// then rolls damage, picks a body part, and broadcasts
/// <see cref="GameEventType.VictimNotification"/> / AttackerNotification
/// / DefenderNotification / EvasionAttackerNotification /
/// EvasionDefenderNotification with the result.
/// </para>
///
/// <para>
/// References: r02 §7 (wire format), r08 §3 opcode 0x0008.
/// </para>
/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10,
/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE
/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and
/// holtburger protocol game_action.rs.
/// </summary>
public static class AttackTargetRequest
{
public const uint GameActionEnvelope = 0xF7B1u;
public const uint SubOpcode = 0x0008u;
public const uint TargetedMeleeAttackOpcode = 0x0008u;
public const uint TargetedMissileAttackOpcode = 0x000Au;
public const uint CancelAttackOpcode = 0x01B7u;
/// <summary>
/// Build the wire body for an attack request.
/// </summary>
/// <param name="powerLevel">[0..1] melee power bar position.</param>
/// <param name="accuracyLevel">[0..1] missile accuracy bar position; pass 0 for melee.</param>
/// <param name="attackHeight">1=High, 2=Medium, 3=Low.</param>
public static byte[] Build(
/// <summary>Build the wire body for a targeted melee attack.</summary>
public static byte[] BuildMelee(
uint gameActionSequence,
uint targetGuid,
float powerLevel,
float accuracyLevel,
uint attackHeight)
uint attackHeight,
float powerLevel)
{
byte[] body = new byte[28];
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMeleeAttackOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), powerLevel);
return body;
}
/// <summary>Build the wire body for a targeted missile attack.</summary>
public static byte[] BuildMissile(
uint gameActionSequence,
uint targetGuid,
uint attackHeight,
float accuracyLevel)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMissileAttackOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight);
return body;
}
/// <summary>Build the wire body for cancelling an active attack request.</summary>
public static byte[] BuildCancel(uint gameActionSequence)
{
byte[] body = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), CancelAttackOpcode);
return body;
}
}

View file

@ -22,9 +22,17 @@ public static class CharacterActions
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
[Flags]
public enum CombatMode : uint
{
Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5,
Undef = 0,
NonCombat = 0x01,
Melee = 0x02,
Missile = 0x04,
Magic = 0x08,
ValidCombat = NonCombat | Melee | Missile | Magic,
CombatCombat = Melee | Missile | Magic,
}
/// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary>

View file

@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages;
/// </list>
///
/// <para>
/// All other fields (weenie header, object description, motion tables,
/// Most other fields (extended weenie header, object description, motion tables,
/// palettes, texture overrides, animation frames, velocity, ...) are
/// consumed-but-ignored so the parse position ends up wherever the
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
/// the end of the body to return useful output. We stop after PhysicsData
/// since that's the last segment containing fields acdream cares about
/// in this phase.
/// the end of the body to return useful output. We read through the fixed
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
/// tails.
/// </para>
///
/// <para>
@ -51,6 +51,8 @@ public static class CreateObject
public const uint PaletteTypePrefix = 0x04000000u;
/// <summary>SurfaceTexture dat id type prefix.</summary>
public const uint SurfaceTextureTypePrefix = 0x05000000u;
/// <summary>Icon dat id type prefix.</summary>
public const uint IconTypePrefix = 0x06000000u;
[Flags]
public enum PhysicsDescriptionFlag : uint
@ -78,9 +80,9 @@ public static class CreateObject
}
/// <summary>
/// The three fields acdream cares about. Position and SetupTableId are
/// nullable because their corresponding physics-description-flag bits
/// may not be set on every CreateObject.
/// The spawn fields acdream currently cares about. Position and
/// SetupTableId are nullable because their corresponding
/// physics-description-flag bits may not be set on every CreateObject.
/// </summary>
public readonly record struct Parsed(
uint Guid,
@ -92,6 +94,7 @@ public static class CreateObject
uint? BasePaletteId,
float? ObjScale,
string? Name,
uint? ItemType,
ServerMotionState? MotionState,
uint? MotionTableId,
ushort InstanceSequence = 0,
@ -136,7 +139,59 @@ public static class CreateObject
ushort? SideStepCommand = null,
float? SideStepSpeed = null,
ushort? TurnCommand = null,
float? TurnSpeed = null);
float? TurnSpeed = null,
byte MovementType = 0,
uint? MoveToParameters = null,
float? MoveToSpeed = null,
float? MoveToRunRate = null,
MoveToPathData? MoveToPath = null)
{
/// <summary>
/// ACE/retail movement types 6 and 7 are server-controlled
/// MoveToObject/MoveToPosition packets. Their union body does not
/// carry an InterpretedMotionState.ForwardCommand, so command absence
/// is not a stop signal.
/// </summary>
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
public bool MoveToCanRun => !MoveToParameters.HasValue
|| (MoveToParameters.Value & 0x2u) != 0;
/// <summary>
/// MovementParameters bit 9 (mask 0x200) — set when the creature is
/// chasing its target. Cross-checked against acclient.h:31423-31443
/// (named retail) + ACE <c>MovementParamFlags.MoveTowards</c>.
/// </summary>
public bool MoveTowards => MoveToParameters.HasValue
&& (MoveToParameters.Value & 0x200u) != 0;
}
/// <summary>
/// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
/// Wire layout per <c>MovementParameters::UnPackNet</c> @ <c>0x0052ac50</c>
/// + the leading <c>Origin</c> + optional target guid for type 6:
/// <list type="bullet">
/// <item>type 6 (MoveToObject) only: u32 <c>TargetGuid</c></item>
/// <item>Origin: u32 <c>cellId</c>, then 3 floats (local x/y/z within the landblock)</item>
/// <item>MovementParameters (28 bytes, exact retail order):
/// u32 flags, f32 <c>distance_to_object</c>, f32 <c>min_distance</c>,
/// f32 <c>fail_distance</c>, f32 <c>speed</c>, f32 <c>walk_run_threshhold</c>,
/// f32 <c>desired_heading</c></item>
/// </list>
/// (The trailing <c>runRate</c> float is captured separately on
/// <see cref="ServerMotionState.MoveToRunRate"/>.)
/// </summary>
public readonly record struct MoveToPathData(
uint? TargetGuid,
uint OriginCellId,
float OriginX,
float OriginY,
float OriginZ,
float DistanceToObject,
float MinDistance,
float FailDistance,
float WalkRunThreshold,
float DesiredHeading);
/// <summary>
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
@ -390,27 +445,39 @@ public static class CreateObject
pos += 9 * 2;
AlignTo4(ref pos);
// --- WeenieHeader: read just the Name field (second after flags). ---
// --- WeenieHeader: read the fixed prefix fields we need. ---
// ACE WorldObject_Networking.SerializeCreateObject writes:
// weenieFlags, Name, WeenieClassId(PackedDword),
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
// ObjectDescriptionFlags, align.
string? name = null;
uint? itemType = null;
if (body.Length - pos >= 4)
{
pos += 4; // skip weenieFlags u32
try
{
name = ReadString16L(body, ref pos);
_ = ReadPackedDword(body, ref pos); // WeenieClassId
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
if (body.Length - pos >= 4)
itemType = ReadU32(body, ref pos);
if (body.Length - pos >= 4)
_ = ReadU32(body, ref pos); // ObjectDescriptionFlags
AlignTo4(ref pos);
}
catch { /* truncated name — partial result is still useful */ }
}
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new(
guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId);
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId);
}
catch
{
@ -528,6 +595,9 @@ public static class CreateObject
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
uint? moveToParameters = null;
float? moveToSpeed = null;
float? moveToRunRate = null;
List<MotionItem>? commands = null;
// 0 = Invalid is the only union variant we care about for static
@ -630,14 +700,62 @@ public static class CreateObject
}
done:;
}
else if (movementType is 6 or 7)
{
TryParseMoveToPayload(
mv,
p,
movementType,
out moveToParameters,
out moveToSpeed,
out moveToRunRate);
}
return new ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed);
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
movementType,
moveToParameters,
moveToSpeed,
moveToRunRate);
}
catch
{
return null;
}
}
private static bool TryParseMoveToPayload(
ReadOnlySpan<byte> body,
int pos,
byte movementType,
out uint? movementParameters,
out float? speed,
out float? runRate)
{
movementParameters = null;
speed = null;
runRate = null;
if (movementType == 6)
{
if (body.Length - pos < 4) return false;
pos += 4; // target guid
}
if (body.Length - pos < 16 + 28 + 4) return false;
pos += 16; // Origin
movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
pos += 4; // distanceToObject
pos += 4; // minDistance
pos += 4; // failDistance
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
pos += 4; // walkRunThreshold
pos += 4; // desiredHeading
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
return true;
}
}

View file

@ -0,0 +1,39 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>ObjectDelete</c> GameMessage (opcode <c>0xF747</c>).
///
/// <para>
/// Retail dispatch path:
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 reads guid from
/// <c>buf+4</c> and instance sequence from <c>buf+8</c>, then calls
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0. ACE emits the same
/// layout from <c>GameMessageDeleteObject</c>.
/// </para>
/// </summary>
public static class DeleteObject
{
public const uint Opcode = 0xF747u;
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
/// <summary>
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
/// 4-byte opcode, matching every other parser in this namespace.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 10)
return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
if (opcode != Opcode)
return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
return new Parsed(guid, instanceSequence);
}
}

View file

@ -147,56 +147,34 @@ public static class GameEvents
// ── Combat notifications ────────────────────────────────────────────────
/// <summary>0x01AC VictimNotification — "you got hit for X".</summary>
public readonly record struct VictimNotification(
string AttackerName,
uint AttackerGuid,
uint DamageType,
uint Damage,
uint HitQuadrant,
uint Critical,
uint AttackType);
/// <summary>0x01AC VictimNotification - death message for the victim.</summary>
public readonly record struct VictimNotification(string DeathMessage);
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 24) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint atkType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new VictimNotification(name, guid, damageType, damage, quad, crit, atkType);
}
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// <summary>0x01AD KillerNotification — "you killed X".</summary>
public readonly record struct KillerNotification(string VictimName, uint VictimGuid);
/// <summary>0x01AD KillerNotification - death message for the killer.</summary>
public readonly record struct KillerNotification(string DeathMessage);
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 4) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
return new KillerNotification(name, guid);
}
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// <summary>0x01B1 AttackerNotification — "you hit X for Y%".</summary>
/// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
public readonly record struct AttackerNotification(
string DefenderName,
uint DamageType,
double HealthPercent,
uint Damage,
float DamagePercent);
uint Critical,
ulong AttackConditions);
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
{
@ -204,23 +182,26 @@ public static class GameEvents
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 12) return null;
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4;
return new AttackerNotification(name, damageType, damage, pct);
if (payload.Length - pos < 28) return null;
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new AttackerNotification(name, damageType, pct, damage, crit, cond);
}
catch { return null; }
}
/// <summary>0x01B2 DefenderNotification — "X hit you for Y".</summary>
/// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
public readonly record struct DefenderNotification(
string AttackerName,
uint AttackerGuid,
uint DamageType,
double HealthPercent,
uint Damage,
uint HitQuadrant,
uint Critical);
uint Critical,
ulong AttackConditions);
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
{
@ -228,40 +209,42 @@ public static class GameEvents
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 20) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new DefenderNotification(name, guid, dtype, dmg, quad, crit);
if (payload.Length - pos < 32) return null;
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
}
catch { return null; }
}
/// <summary>0x01B3 EvasionAttackerNotification "X evaded".</summary>
/// <summary>0x01B3 EvasionAttackerNotification - "X evaded".</summary>
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>0x01B4 EvasionDefenderNotification "you evaded X".</summary>
/// <summary>0x01B4 EvasionDefenderNotification - "you evaded X".</summary>
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>0x01A7 AttackDone — (attackSequence, weenieError).</summary>
/// <summary>0x01B8 CombatCommenceAttack - empty payload.</summary>
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new AttackDone(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
if (payload.Length < 4) return null;
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
// ── Spell enchantments ──────────────────────────────────────────────────

View file

@ -127,6 +127,10 @@ public static class UpdateMotion
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
uint? moveToParameters = null;
float? moveToSpeed = null;
float? moveToRunRate = null;
CreateObject.MoveToPathData? moveToPath = null;
List<CreateObject.MotionItem>? commands = null;
if (movementType == 0)
@ -135,7 +139,7 @@ public static class UpdateMotion
// MovementInvalid branch, just reached via the header'd path.
// Includes the Commands list (MotionItem[]) that carries
// Actions, emotes, and other one-shots not in ForwardCommand.
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
uint flags = packed & 0x7Fu;
@ -158,13 +162,13 @@ public static class UpdateMotion
if ((flags & 0x1u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((flags & 0x2u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
@ -221,14 +225,108 @@ public static class UpdateMotion
}
done:;
}
else if (movementType is 6 or 7)
{
TryParseMoveToPayload(
body,
pos,
movementType,
out moveToParameters,
out moveToSpeed,
out moveToRunRate,
out moveToPath);
}
return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
movementType,
moveToParameters,
moveToSpeed,
moveToRunRate,
moveToPath));
}
catch
{
return null;
}
}
private static bool TryParseMoveToPayload(
ReadOnlySpan<byte> body,
int pos,
byte movementType,
out uint? movementParameters,
out float? speed,
out float? runRate,
out CreateObject.MoveToPathData? path)
{
movementParameters = null;
speed = null;
runRate = null;
path = null;
// Retail MovementManager::PerformMovement (0x00524440) consumes
// MoveToObject/MoveToPosition as:
// [object guid, for MoveToObject only]
// Origin(cell + xyz)
// MovementParameters::UnPackNet (0x0052AC50): flags, distance,
// min, fail, speed, walk/run threshold, desired heading
// f32 runRate copied into CMotionInterp::my_run_rate.
//
// Phase L.1c (2026-04-28): the full path payload is now retained on
// <see cref="CreateObject.MoveToPathData"/> so the per-tick remote
// body driver can steer toward Origin instead of holding velocity at
// zero between sparse UpdatePosition snaps. The 882a07c stabilizer
// was deliberately conservative because we only had speed+runRate;
// with the rest of the packet captured, the body solver has full
// path data and can run faithfully.
uint? targetGuid = null;
if (movementType == 6)
{
if (body.Length - pos < 4) return false;
targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
if (body.Length - pos < 16 + 28 + 4) return false;
uint originCellId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
float originX = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float originY = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float originZ = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
float distanceToObject = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float minDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float failDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float walkRunThreshold = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
path = new CreateObject.MoveToPathData(
targetGuid,
originCellId,
originX,
originY,
originZ,
distanceToObject,
minDistance,
failDistance,
walkRunThreshold,
desiredHeading);
return true;
}
}

View file

@ -1,6 +1,7 @@
using System.Buffers.Binary;
using System.Net;
using System.Threading.Channels;
using AcDream.Core.Combat;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
@ -53,12 +54,23 @@ public sealed class WorldSession : IDisposable
uint? BasePaletteId,
float? ObjScale,
string? Name,
uint? ItemType,
CreateObject.ServerMotionState? MotionState,
uint? MotionTableId);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
/// <summary>
/// Fires when the session parses a 0xF747 ObjectDelete game message.
/// Retail routes this through
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 →
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0; ACE emits it when
/// an object leaves the world, including the living creature object
/// after its corpse is created.
/// </summary>
public event Action<DeleteObject.Parsed>? EntityDeleted;
/// <summary>
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
/// whose motion changed and its new server-side stance + forward command.
@ -634,10 +646,17 @@ public sealed class WorldSession : IDisposable
parsed.Value.BasePaletteId,
parsed.Value.ObjScale,
parsed.Value.Name,
parsed.Value.ItemType,
parsed.Value.MotionState,
parsed.Value.MotionTableId));
}
}
else if (op == DeleteObject.Opcode)
{
var parsed = DeleteObject.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(parsed.Value);
}
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
@ -909,6 +928,48 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{
uint seq = NextGameActionSequence();
byte[] body = CharacterActions.BuildChangeCombatMode(
seq,
(CharacterActions.CombatMode)(uint)mode);
SendGameAction(body);
}
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMelee(
seq,
targetGuid,
(uint)attackHeight,
powerLevel);
SendGameAction(body);
}
/// <summary>Send retail TargetedMissileAttack (0x000A).</summary>
public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMissile(
seq,
targetGuid,
(uint)attackHeight,
accuracyLevel);
SendGameAction(body);
}
/// <summary>Send retail CancelAttack (0x01B7).</summary>
public void SendCancelAttack()
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildCancel(seq);
SendGameAction(body);
}
/// <summary>
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
/// global community room (General / Trade / LFG / Roleplay /

View file

@ -0,0 +1,308 @@
using AcDream.Core.Physics;
namespace AcDream.Core.Combat;
/// <summary>
/// Retail-faithful combat animation planner for server-sent motion commands.
///
/// Retail evidence:
/// - <c>ClientCombatSystem::ExecuteAttack</c> (0x0056BB70) only sends the
/// targeted melee/missile GameAction and sets response state; it does not
/// locally choose or play a swing animation.
/// - <c>ClientCombatSystem::HandleCommenceAttackEvent</c> (0x0056AD20)
/// updates the power bar/busy state; it carries no MotionCommand.
/// - ACE <c>Player_Melee.DoSwingMotion</c> chooses a swing via
/// <c>CombatManeuverTable.GetMotion</c> and broadcasts that MotionCommand
/// in <c>UpdateMotion</c>.
///
/// So acdream treats combat GameEvents as state/UI signals and treats
/// UpdateMotion command IDs as the animation authority.
/// </summary>
public static class CombatAnimationPlanner
{
public static CombatAnimationPlan PlanForEvent(CombatAnimationEvent combatEvent)
{
_ = combatEvent;
return CombatAnimationPlan.None;
}
public static CombatAnimationPlan PlanFromWireCommand(ushort wireCommand, float speedMod = 1f)
{
uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
return PlanFromFullCommand(fullCommand, speedMod);
}
public static CombatAnimationPlan PlanFromFullCommand(uint fullCommand, float speedMod = 1f)
{
var kind = ClassifyMotionCommand(fullCommand);
if (kind == CombatAnimationKind.None)
return CombatAnimationPlan.None;
return new CombatAnimationPlan(
kind,
AnimationCommandRouter.Classify(fullCommand),
fullCommand,
speedMod);
}
public static CombatAnimationKind ClassifyMotionCommand(uint fullCommand)
{
return fullCommand switch
{
CombatAnimationMotionCommands.HandCombat
or CombatAnimationMotionCommands.SwordCombat
or CombatAnimationMotionCommands.SwordShieldCombat
or CombatAnimationMotionCommands.TwoHandedSwordCombat
or CombatAnimationMotionCommands.TwoHandedStaffCombat
or CombatAnimationMotionCommands.BowCombat
or CombatAnimationMotionCommands.CrossbowCombat
or CombatAnimationMotionCommands.SlingCombat
or CombatAnimationMotionCommands.DualWieldCombat
or CombatAnimationMotionCommands.ThrownWeaponCombat
or CombatAnimationMotionCommands.AtlatlCombat
or CombatAnimationMotionCommands.ThrownShieldCombat
or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance,
CombatAnimationMotionCommands.ThrustMed
or CombatAnimationMotionCommands.ThrustLow
or CombatAnimationMotionCommands.ThrustHigh
or CombatAnimationMotionCommands.SlashHigh
or CombatAnimationMotionCommands.SlashMed
or CombatAnimationMotionCommands.SlashLow
or CombatAnimationMotionCommands.BackhandHigh
or CombatAnimationMotionCommands.BackhandMed
or CombatAnimationMotionCommands.BackhandLow
or CombatAnimationMotionCommands.DoubleSlashLow
or CombatAnimationMotionCommands.DoubleSlashMed
or CombatAnimationMotionCommands.DoubleSlashHigh
or CombatAnimationMotionCommands.TripleSlashLow
or CombatAnimationMotionCommands.TripleSlashMed
or CombatAnimationMotionCommands.TripleSlashHigh
or CombatAnimationMotionCommands.DoubleThrustLow
or CombatAnimationMotionCommands.DoubleThrustMed
or CombatAnimationMotionCommands.DoubleThrustHigh
or CombatAnimationMotionCommands.TripleThrustLow
or CombatAnimationMotionCommands.TripleThrustMed
or CombatAnimationMotionCommands.TripleThrustHigh
or CombatAnimationMotionCommands.OffhandSlashHigh
or CombatAnimationMotionCommands.OffhandSlashMed
or CombatAnimationMotionCommands.OffhandSlashLow
or CombatAnimationMotionCommands.OffhandThrustHigh
or CombatAnimationMotionCommands.OffhandThrustMed
or CombatAnimationMotionCommands.OffhandThrustLow
or CombatAnimationMotionCommands.OffhandDoubleSlashLow
or CombatAnimationMotionCommands.OffhandDoubleSlashMed
or CombatAnimationMotionCommands.OffhandDoubleSlashHigh
or CombatAnimationMotionCommands.OffhandTripleSlashLow
or CombatAnimationMotionCommands.OffhandTripleSlashMed
or CombatAnimationMotionCommands.OffhandTripleSlashHigh
or CombatAnimationMotionCommands.OffhandDoubleThrustLow
or CombatAnimationMotionCommands.OffhandDoubleThrustMed
or CombatAnimationMotionCommands.OffhandDoubleThrustHigh
or CombatAnimationMotionCommands.OffhandTripleThrustLow
or CombatAnimationMotionCommands.OffhandTripleThrustMed
or CombatAnimationMotionCommands.OffhandTripleThrustHigh
or CombatAnimationMotionCommands.OffhandKick
or CombatAnimationMotionCommands.PunchFastHigh
or CombatAnimationMotionCommands.PunchFastMed
or CombatAnimationMotionCommands.PunchFastLow
or CombatAnimationMotionCommands.PunchSlowHigh
or CombatAnimationMotionCommands.PunchSlowMed
or CombatAnimationMotionCommands.PunchSlowLow
or CombatAnimationMotionCommands.OffhandPunchFastHigh
or CombatAnimationMotionCommands.OffhandPunchFastMed
or CombatAnimationMotionCommands.OffhandPunchFastLow
or CombatAnimationMotionCommands.OffhandPunchSlowHigh
or CombatAnimationMotionCommands.OffhandPunchSlowMed
or CombatAnimationMotionCommands.OffhandPunchSlowLow => CombatAnimationKind.MeleeSwing,
CombatAnimationMotionCommands.Shoot
or CombatAnimationMotionCommands.MissileAttack1
or CombatAnimationMotionCommands.MissileAttack2
or CombatAnimationMotionCommands.MissileAttack3
or CombatAnimationMotionCommands.Reload => CombatAnimationKind.MissileAttack,
CombatAnimationMotionCommands.AttackHigh1
or CombatAnimationMotionCommands.AttackMed1
or CombatAnimationMotionCommands.AttackLow1
or CombatAnimationMotionCommands.AttackHigh2
or CombatAnimationMotionCommands.AttackMed2
or CombatAnimationMotionCommands.AttackLow2
or CombatAnimationMotionCommands.AttackHigh3
or CombatAnimationMotionCommands.AttackMed3
or CombatAnimationMotionCommands.AttackLow3
or CombatAnimationMotionCommands.AttackHigh4
or CombatAnimationMotionCommands.AttackMed4
or CombatAnimationMotionCommands.AttackLow4
or CombatAnimationMotionCommands.AttackHigh5
or CombatAnimationMotionCommands.AttackMed5
or CombatAnimationMotionCommands.AttackLow5
or CombatAnimationMotionCommands.AttackHigh6
or CombatAnimationMotionCommands.AttackMed6
or CombatAnimationMotionCommands.AttackLow6 => CombatAnimationKind.CreatureAttack,
CombatAnimationMotionCommands.CastSpell
or CombatAnimationMotionCommands.UseMagicStaff
or CombatAnimationMotionCommands.UseMagicWand => CombatAnimationKind.SpellCast,
CombatAnimationMotionCommands.FallDown
or CombatAnimationMotionCommands.Twitch1
or CombatAnimationMotionCommands.Twitch2
or CombatAnimationMotionCommands.Twitch3
or CombatAnimationMotionCommands.Twitch4
or CombatAnimationMotionCommands.StaggerBackward
or CombatAnimationMotionCommands.StaggerForward
or CombatAnimationMotionCommands.Sanctuary => CombatAnimationKind.HitReaction,
MotionCommand.Dead => CombatAnimationKind.Death,
_ => CombatAnimationKind.None,
};
}
}
public readonly record struct CombatAnimationPlan(
CombatAnimationKind Kind,
AnimationCommandRouteKind RouteKind,
uint MotionCommand,
float SpeedMod)
{
public static CombatAnimationPlan None { get; } = new(
CombatAnimationKind.None,
AnimationCommandRouteKind.None,
0u,
0f);
public bool HasMotion => Kind != CombatAnimationKind.None && MotionCommand != 0;
}
public enum CombatAnimationEvent
{
CombatCommenceAttack,
AttackDone,
AttackerNotification,
DefenderNotification,
EvasionAttackerNotification,
EvasionDefenderNotification,
VictimNotification,
KillerNotification,
}
public enum CombatAnimationKind
{
None = 0,
CombatStance,
MeleeSwing,
MissileAttack,
CreatureAttack,
SpellCast,
HitReaction,
Death,
}
internal static class CombatAnimationMotionCommands
{
public const uint HandCombat = 0x8000003Cu;
public const uint SwordCombat = 0x8000003Eu;
public const uint BowCombat = 0x8000003Fu;
public const uint SwordShieldCombat = 0x80000040u;
public const uint CrossbowCombat = 0x80000041u;
public const uint SlingCombat = 0x80000043u;
public const uint TwoHandedSwordCombat = 0x80000044u;
public const uint TwoHandedStaffCombat = 0x80000045u;
public const uint DualWieldCombat = 0x80000046u;
public const uint ThrownWeaponCombat = 0x80000047u;
public const uint Magic = 0x80000049u;
public const uint AtlatlCombat = 0x8000013Bu;
public const uint ThrownShieldCombat = 0x8000013Cu;
public const uint FallDown = 0x10000050u;
public const uint Twitch1 = 0x10000051u;
public const uint Twitch2 = 0x10000052u;
public const uint Twitch3 = 0x10000053u;
public const uint Twitch4 = 0x10000054u;
public const uint StaggerBackward = 0x10000055u;
public const uint StaggerForward = 0x10000056u;
public const uint Sanctuary = 0x10000057u;
public const uint ThrustMed = 0x10000058u;
public const uint ThrustLow = 0x10000059u;
public const uint ThrustHigh = 0x1000005Au;
public const uint SlashHigh = 0x1000005Bu;
public const uint SlashMed = 0x1000005Cu;
public const uint SlashLow = 0x1000005Du;
public const uint BackhandHigh = 0x1000005Eu;
public const uint BackhandMed = 0x1000005Fu;
public const uint BackhandLow = 0x10000060u;
public const uint Shoot = 0x10000061u;
public const uint AttackHigh1 = 0x10000062u;
public const uint AttackMed1 = 0x10000063u;
public const uint AttackLow1 = 0x10000064u;
public const uint AttackHigh2 = 0x10000065u;
public const uint AttackMed2 = 0x10000066u;
public const uint AttackLow2 = 0x10000067u;
public const uint AttackHigh3 = 0x10000068u;
public const uint AttackMed3 = 0x10000069u;
public const uint AttackLow3 = 0x1000006Au;
public const uint MissileAttack1 = 0x100000D0u;
public const uint MissileAttack2 = 0x100000D1u;
public const uint MissileAttack3 = 0x100000D2u;
public const uint CastSpell = 0x400000D3u;
public const uint Reload = 0x100000D4u;
public const uint UseMagicStaff = 0x400000E0u;
public const uint UseMagicWand = 0x400000E1u;
public const uint DoubleSlashLow = 0x1000011Fu;
public const uint DoubleSlashMed = 0x10000120u;
public const uint DoubleSlashHigh = 0x10000121u;
public const uint TripleSlashLow = 0x10000122u;
public const uint TripleSlashMed = 0x10000123u;
public const uint TripleSlashHigh = 0x10000124u;
public const uint DoubleThrustLow = 0x10000125u;
public const uint DoubleThrustMed = 0x10000126u;
public const uint DoubleThrustHigh = 0x10000127u;
public const uint TripleThrustLow = 0x10000128u;
public const uint TripleThrustMed = 0x10000129u;
public const uint TripleThrustHigh = 0x1000012Au;
public const uint OffhandSlashHigh = 0x10000170u;
public const uint OffhandSlashMed = 0x10000171u;
public const uint OffhandSlashLow = 0x10000172u;
public const uint OffhandThrustHigh = 0x10000173u;
public const uint OffhandThrustMed = 0x10000174u;
public const uint OffhandThrustLow = 0x10000175u;
public const uint OffhandDoubleSlashLow = 0x10000176u;
public const uint OffhandDoubleSlashMed = 0x10000177u;
public const uint OffhandDoubleSlashHigh = 0x10000178u;
public const uint OffhandTripleSlashLow = 0x10000179u;
public const uint OffhandTripleSlashMed = 0x1000017Au;
public const uint OffhandTripleSlashHigh = 0x1000017Bu;
public const uint OffhandDoubleThrustLow = 0x1000017Cu;
public const uint OffhandDoubleThrustMed = 0x1000017Du;
public const uint OffhandDoubleThrustHigh = 0x1000017Eu;
public const uint OffhandTripleThrustLow = 0x1000017Fu;
public const uint OffhandTripleThrustMed = 0x10000180u;
public const uint OffhandTripleThrustHigh = 0x10000181u;
public const uint OffhandKick = 0x10000182u;
public const uint AttackHigh4 = 0x10000183u;
public const uint AttackMed4 = 0x10000184u;
public const uint AttackLow4 = 0x10000185u;
public const uint AttackHigh5 = 0x10000186u;
public const uint AttackMed5 = 0x10000187u;
public const uint AttackLow5 = 0x10000188u;
public const uint AttackHigh6 = 0x10000189u;
public const uint AttackMed6 = 0x1000018Au;
public const uint AttackLow6 = 0x1000018Bu;
public const uint PunchFastHigh = 0x1000018Cu;
public const uint PunchFastMed = 0x1000018Du;
public const uint PunchFastLow = 0x1000018Eu;
public const uint PunchSlowHigh = 0x1000018Fu;
public const uint PunchSlowMed = 0x10000190u;
public const uint PunchSlowLow = 0x10000191u;
public const uint OffhandPunchFastHigh = 0x10000192u;
public const uint OffhandPunchFastMed = 0x10000193u;
public const uint OffhandPunchFastLow = 0x10000194u;
public const uint OffhandPunchSlowHigh = 0x10000195u;
public const uint OffhandPunchSlowMed = 0x10000196u;
public const uint OffhandPunchSlowLow = 0x10000197u;
}

View file

@ -0,0 +1,89 @@
using DatReaderWriter.DBObjs;
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
using DatMotionStance = DatReaderWriter.Enums.MotionStance;
using DatAttackHeight = DatReaderWriter.Enums.AttackHeight;
using DatAttackType = DatReaderWriter.Enums.AttackType;
namespace AcDream.Core.Combat;
/// <summary>
/// Selects combat swing motions from the retail <c>CombatTable</c> DBObj.
///
/// Retail evidence:
/// - <c>CombatManeuverTable::Get</c> (0x0056AB60) loads DB type
/// <c>0x1000000D</c> for a 0x30xxxxxx combat table id.
/// - ACE <c>CombatManeuverTable.GetMotion</c> indexes maneuvers by
/// stance, attack height, and attack type, returning all matching motions.
/// - ACE <c>Player_Melee.GetSwingAnimation</c> then chooses
/// <c>motions[1]</c> when more than one motion exists and power is below
/// the subdivision threshold; otherwise it uses <c>motions[0]</c>.
/// </summary>
public static class CombatManeuverSelector
{
public const float DefaultSubdivision = 0.33f;
public const float ThrustSlashSubdivision = 0.66f;
public static CombatManeuverSelection SelectMotion(
CombatTable table,
DatMotionStance stance,
DatAttackHeight attackHeight,
DatAttackType attackType,
float powerLevel,
bool isThrustSlashWeapon = false)
{
var motions = FindMotions(table, stance, attackHeight, attackType);
if (motions.Count == 0)
return CombatManeuverSelection.None;
float subdivision = isThrustSlashWeapon
? ThrustSlashSubdivision
: DefaultSubdivision;
var motion = motions.Count > 1 && powerLevel < subdivision
? motions[1]
: motions[0];
return new CombatManeuverSelection(
Found: true,
Motion: motion,
Candidates: motions,
EffectiveAttackType: attackType,
Subdivision: subdivision);
}
public static IReadOnlyList<DatMotionCommand> FindMotions(
CombatTable table,
DatMotionStance stance,
DatAttackHeight attackHeight,
DatAttackType attackType)
{
var result = new List<DatMotionCommand>();
foreach (var maneuver in table.CombatManeuvers)
{
if (maneuver.Style == stance
&& maneuver.AttackHeight == attackHeight
&& maneuver.AttackType == attackType)
{
result.Add(maneuver.Motion);
}
}
return result;
}
}
public readonly record struct CombatManeuverSelection(
bool Found,
DatMotionCommand Motion,
IReadOnlyList<DatMotionCommand> Candidates,
DatAttackType EffectiveAttackType,
float Subdivision)
{
public static CombatManeuverSelection None { get; } = new(
Found: false,
Motion: DatMotionCommand.Invalid,
Candidates: Array.Empty<DatMotionCommand>(),
EffectiveAttackType: DatAttackType.Undef,
Subdivision: 0f);
}

View file

@ -7,14 +7,17 @@ namespace AcDream.Core.Combat;
// Full research: docs/research/deepdives/r02-combat-system.md
// ─────────────────────────────────────────────────────────────────────
[Flags]
public enum CombatMode
{
Undef = 0,
NonCombat = 1,
Melee = 2,
Missile = 3,
Magic = 4,
Peaceful = 5,
NonCombat = 0x01,
Melee = 0x02,
Missile = 0x04,
Magic = 0x08,
ValidCombat = NonCombat | Melee | Missile | Magic,
CombatCombat = Melee | Missile | Magic,
}
public enum AttackHeight
@ -24,6 +27,51 @@ public enum AttackHeight
Low = 3,
}
public enum CombatAttackAction
{
Low,
Medium,
High,
}
/// <summary>
/// Retail input-facing combat decisions. The heavyweight parts of the combat
/// system remain server authoritative; this helper only maps UI intent to the
/// mode / attack-height values sent on the wire.
///
/// References:
/// named-retail ClientCombatSystem::ToggleCombatMode (0x0056C8C0),
/// ClientCombatSystem::SetCombatMode (0x0056BE30), and
/// ClientCombatSystem::ExecuteAttack (0x0056BB70).
/// Cross-check: holtburger DesiredAttackProfile::to_attack_request only emits
/// targeted attacks for Melee and Missile modes.
/// </summary>
public static class CombatInputPlanner
{
public static CombatMode ToggleMode(
CombatMode currentMode,
CombatMode defaultCombatMode = CombatMode.Melee)
{
if ((currentMode & CombatMode.CombatCombat) != 0)
return CombatMode.NonCombat;
return (defaultCombatMode & CombatMode.CombatCombat) != 0
? defaultCombatMode
: CombatMode.Melee;
}
public static bool SupportsTargetedAttack(CombatMode mode) =>
mode == CombatMode.Melee || mode == CombatMode.Missile;
public static AttackHeight HeightFor(CombatAttackAction action) => action switch
{
CombatAttackAction.Low => AttackHeight.Low,
CombatAttackAction.Medium => AttackHeight.Medium,
CombatAttackAction.High => AttackHeight.High,
_ => AttackHeight.Medium,
};
}
/// <summary>
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
@ -31,20 +79,26 @@ public enum AttackHeight
[Flags]
public enum AttackType : uint
{
None = 0,
Punch = 0x0001,
Kick = 0x0002,
Thrust = 0x0004,
Slash = 0x0008,
DoubleSlash = 0x0010,
TripleSlash = 0x0020,
DoubleThrust = 0x0040,
TripleThrust = 0x0080,
Offhand = 0x0100,
OffhandSlash = 0x0200,
OffhandThrust = 0x0400,
ThrustSlash = 0x0800,
// more in r02 §2
None = 0,
Punch = 0x0001,
Thrust = 0x0002,
Slash = 0x0004,
Kick = 0x0008,
OffhandPunch = 0x0010,
DoubleSlash = 0x0020,
TripleSlash = 0x0040,
DoubleThrust = 0x0080,
TripleThrust = 0x0100,
OffhandThrust = 0x0200,
OffhandSlash = 0x0400,
OffhandDoubleSlash = 0x0800,
OffhandTripleSlash = 0x1000,
OffhandDoubleThrust = 0x2000,
OffhandTripleThrust = 0x4000,
Unarmed = Punch | Kick | OffhandPunch,
MultiStrike = DoubleSlash | TripleSlash | DoubleThrust | TripleThrust
| OffhandDoubleSlash | OffhandTripleSlash
| OffhandDoubleThrust | OffhandTripleThrust,
}
[Flags]

View file

@ -39,6 +39,8 @@ public sealed class CombatState
{
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
@ -57,6 +59,12 @@ public sealed class CombatState
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
/// <summary>The server accepted the attack and the power bar/animation can begin.</summary>
public event Action? AttackCommenced;
/// <summary>The locally requested or server-confirmed combat mode changed.</summary>
public event Action<CombatMode>? CombatModeChanged;
/// <summary>
/// Fires when the server confirms the player landed a killing blow
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
@ -94,6 +102,15 @@ public sealed class CombatState
HealthChanged?.Invoke(targetGuid, healthPercent);
}
public void SetCombatMode(CombatMode mode)
{
if (CurrentMode == mode)
return;
CurrentMode = mode;
CombatModeChanged?.Invoke(mode);
}
public void OnVictimNotification(
string attackerName, uint attackerGuid, uint damageType, uint damage,
uint hitQuadrant, uint critical, uint attackType)
@ -140,5 +157,8 @@ public sealed class CombatState
public void OnAttackDone(uint attackSequence, uint weenieError)
=> AttackDone?.Invoke(attackSequence, weenieError);
public void OnCombatCommenceAttack()
=> AttackCommenced?.Invoke();
public void Clear() => _healthByGuid.Clear();
}

View file

@ -200,21 +200,14 @@ public static class GfxObjMesh
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
var translucency = TranslucencyKind.Opaque;
var luminosity = 0f;
// SurfTranslucency = the OPACITY multiplier the shader applies
// to fragment alpha. 1.0 = fully opaque (default, non-Translucent
// surfaces). For Translucent-flag surfaces, retail's
// D3DPolyRender::SetSurface at 0x59c7a6 (decomp lines 425255-
// 425260) computes curr_alpha = _ftol2(translucency × 255) and
// feeds that as vertex.color.alpha — so the dat's Translucency
// float is the OPACITY directly (NOT inverted). For rain
// (translucency=0.5) opacity is 0.5; for cloud surface
// 0x08000023 (translucency=0.25) opacity is 0.25 — that's why
// retail's clouds are dim and acdream's were 3× too bright
// before this fix (we used 1-translucency, inverting the
// semantic). ACViewer's TextureCache.cs:142 and WorldBuilder's
// ObjectMeshManager.cs:1115 also use 1-translucency and are
// both wrong by the same misread.
var surfTranslucency = 1.0f;
// SurfOpacity = (1 - Surface.Translucency) for Translucent
// surfaces, 1.0 otherwise. See
// TranslucencyKindExtensions.OpacityFromSurfaceTranslucency for
// the decomp citation (CMaterial::SetTranslucencySimple at
// 0x005396f0 writes material alpha as 1 - translucency).
var diffuse = 1f;
var surfOpacity = 1f;
var disableFog = false;
if (dats is not null)
{
var surface = dats.Get<Surface>(surfaceId);
@ -222,13 +215,16 @@ public static class GfxObjMesh
{
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
luminosity = surface.Luminosity;
diffuse = surface.Diffuse;
// Apply the dat's Translucency value as opacity ONLY
// when the Translucent flag (0x10) is set on the
// Surface. Without this gate, surfaces with
// Translucency=0 (non-Translucent default) would
// render fully transparent.
if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0)
surfTranslucency = surface.Translucency;
surfOpacity = TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(
surface.Type,
surface.Translucency);
disableFog = TranslucencyKindExtensions.DisablesFixedFunctionFog(surface.Type);
}
}
@ -256,8 +252,10 @@ public static class GfxObjMesh
{
Translucency = translucency,
Luminosity = luminosity,
Diffuse = diffuse,
NeedsUvRepeat = needsUvRepeat,
SurfTranslucency = surfTranslucency,
SurfOpacity = surfOpacity,
DisableFog = disableFog,
});
}
return result;

View file

@ -13,67 +13,40 @@ public sealed record GfxObjSubMesh(
{
/// <summary>
/// How this sub-mesh should be composited into the frame.
/// Populated from Surface.Type flags at upload time (requires a DatCollection).
/// Defaults to <see cref="TranslucencyKind.Opaque"/> so offline fixtures
/// that don't supply dat access compile and pass unchanged.
/// Populated from Surface.Type flags at upload time.
/// </summary>
public TranslucencyKind Translucency { get; init; } = TranslucencyKind.Opaque;
/// <summary>
/// Self-illumination strength of the Surface (<c>Surface.Luminosity</c>
/// field, 0..1 fraction — NOT the <c>SurfaceType.Luminous</c> flag bit).
/// Retail uses this as an emissive coefficient in the per-vertex
/// lighting formula:
/// <code>
/// tint = clamp(vec3(Luminosity) + AmbColor + diffuse * DirColor, 0, 1)
/// fragment = texture * tint
/// </code>
/// For Dereth's sky meshes, the DOME (0x010015EE) and SUN/MOON
/// (0x01001348) have <c>Luminosity=1.0</c> (self-illuminated — emissive
/// saturates the lighting math so the baked texture always renders
/// at full brightness). CLOUDS (0x010015EF, 0x01004C36) have
/// <c>Luminosity=0.0</c> (lit by ambient+diffuse — pick up the
/// time-of-day tint). See
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
/// Defaults to 0.0 (fully lit) so non-sky meshes render through the
/// normal lighting path without change.
/// Surface.Luminosity. Retail uses this as material emissive.
/// </summary>
public float Luminosity { get; init; } = 0f;
/// <summary>
/// True when at least one vertex's UV component lies outside the
/// <c>[0, 1]</c> range, meaning the mesh was authored to have its
/// texture tile across the geometry (i.e. it expects
/// <c>GL_REPEAT</c>/<c>D3DTADDRESS_WRAP</c>). The sky renderer reads
/// this to decide between <c>GL_REPEAT</c> (this flag set, or any
/// scrolling layer) and <c>GL_CLAMP_TO_EDGE</c> (all UVs strictly
/// in <c>[0,1]</c>), which avoids wall-seam bleed on the dome
/// (UVs in <c>[0,1]</c>) while still tiling the inner star/cloud
/// layers (UVs in <c>[~0.4, ~4.6]</c>) correctly.
/// Defaults to false so non-sky consumers get the previous behavior.
/// Surface.Diffuse. Retail sky keyframes route SkyObjectReplace.MaxBright
/// through CPhysicsObj::SetDiffusion (0x005119e0), which lands in
/// CMaterial::SetDiffuseSimple (0x00539750).
/// </summary>
public float Diffuse { get; init; } = 1f;
/// <summary>
/// True when at least one vertex UV component lies outside [0, 1], so
/// the mesh expects texture repeat instead of clamp.
/// </summary>
public bool NeedsUvRepeat { get; init; } = false;
/// <summary>
/// <c>Surface.Translucency</c> float (0..1) treated as an OPACITY
/// multiplier on fragment alpha. 1.0 = fully opaque (default for
/// non-Translucent surfaces). Distinct from the
/// <see cref="TranslucencyKind"/> classifier above, which buckets the
/// flag bits. Retail's <c>D3DPolyRender::SetSurface</c> at
/// <c>0x59c7a6</c> (decomp lines 425255-425260) reads
/// <c>Surface.Translucency</c> when the <c>Translucent</c> (0x10) bit
/// is set, computes <c>curr_alpha = _ftol2(translucency × 255)</c>,
/// and writes that as vertex alpha — i.e. the dat's Translucency float
/// is used DIRECTLY as opacity, NOT inverted. ACViewer
/// (<c>TextureCache.cs:142</c>) and WorldBuilder
/// (<c>ObjectMeshManager.cs:1115</c>) both use <c>1 - translucency</c>
/// and are wrong by the same misread.
/// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5;
/// with the <c>(SrcAlpha, One)</c> additive blend the rain streaks
/// contribute at half intensity. For cloud surface 0x08000023
/// (translucency=0.25): opacity = 0.25 (matches retail's dim clouds).
/// Defaults to 1.0 (fully opaque) so non-Translucent surfaces render
/// at full opacity without change.
/// Final opacity multiplier derived from Surface.Translucency. Retail
/// translucency is transparency: 0.0 is opaque and 1.0 is invisible.
/// CMaterial::SetTranslucencySimple at 0x005396f0 writes material alpha
/// as 1 - translucency.
/// </summary>
public float SurfTranslucency { get; init; } = 1f;
public float SurfOpacity { get; init; } = 1f;
/// <summary>
/// True when the raw Surface.Type has the Additive bit. Retail disables
/// fixed-function fog alpha for this raw bit even if the final blend mode
/// is forced to AlphaBlend by the Translucent+ClipMap branch.
/// </summary>
public bool DisableFog { get; init; } = false;
}

View file

@ -106,4 +106,25 @@ public static class TranslucencyKindExtensions
return TranslucencyKind.Opaque;
}
/// <summary>
/// Retail translucency is transparency: 0 = opaque, 1 = invisible.
/// CMaterial::SetTranslucencySimple at 0x005396f0 writes material alpha
/// as <c>1 - translucency</c>.
/// </summary>
public static float OpacityFromSurfaceTranslucency(SurfaceType type, float translucency)
{
if ((type & SurfaceType.Translucent) == 0)
return 1f;
return Math.Clamp(1f - translucency, 0f, 1f);
}
/// <summary>
/// D3DPolyRender::SetSurface at 0x0059c882 disables fixed-function fog
/// alpha whenever the raw Additive surface bit is present, even when the
/// Translucent+ClipMap branch later forces alpha blending.
/// </summary>
public static bool DisablesFixedFunctionFog(SurfaceType type)
=> (type & SurfaceType.Additive) != 0;
}

View file

@ -0,0 +1,97 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Central routing for full retail MotionCommand values after the wire's
/// 16-bit command id has been reconstructed.
///
/// Retail/ACE split motion commands by class mask:
/// - Action and ChatEmote commands play through link/action data.
/// - Modifier commands play through modifier data.
/// - SubState commands become the new cyclic state.
/// - Style/UI/Toggle commands do not directly drive an animation overlay here.
///
/// References:
/// CMotionTable::GetObjectSequence 0x00522860,
/// CMotionInterp::DoInterpretedMotion 0x00528360,
/// ACE MotionTable.GetObjectSequence, and
/// docs/research/deepdives/r03-motion-animation.md section 3.
/// </summary>
public static class AnimationCommandRouter
{
private const uint ActionMask = 0x10000000u;
private const uint ModifierMask = 0x20000000u;
private const uint SubStateMask = 0x40000000u;
private const uint ClassMask = 0xFF000000u;
/// <summary>
/// Classifies a reconstructed full MotionCommand.
/// </summary>
public static AnimationCommandRouteKind Classify(uint fullCommand)
{
if (fullCommand == 0)
return AnimationCommandRouteKind.None;
uint cls = fullCommand & ClassMask;
if (cls == 0x12000000u || cls == 0x13000000u)
return AnimationCommandRouteKind.ChatEmote;
if ((fullCommand & ModifierMask) != 0)
return AnimationCommandRouteKind.Modifier;
if ((fullCommand & ActionMask) != 0)
return AnimationCommandRouteKind.Action;
if ((fullCommand & SubStateMask) != 0)
return AnimationCommandRouteKind.SubState;
return AnimationCommandRouteKind.Ignored;
}
/// <summary>
/// Reconstructs and routes a 16-bit wire command.
/// </summary>
public static AnimationCommandRouteKind RouteWireCommand(
AnimationSequencer sequencer,
uint currentStyle,
ushort wireCommand,
float speedMod = 1f)
{
uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
return RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod);
}
/// <summary>
/// Routes a full MotionCommand to the matching sequencer API.
/// </summary>
public static AnimationCommandRouteKind RouteFullCommand(
AnimationSequencer sequencer,
uint currentStyle,
uint fullCommand,
float speedMod = 1f)
{
var route = Classify(fullCommand);
switch (route)
{
case AnimationCommandRouteKind.Action:
case AnimationCommandRouteKind.Modifier:
case AnimationCommandRouteKind.ChatEmote:
sequencer.PlayAction(fullCommand, speedMod);
break;
case AnimationCommandRouteKind.SubState:
sequencer.SetCycle(currentStyle, fullCommand, speedMod);
break;
}
return route;
}
}
public enum AnimationCommandRouteKind
{
None = 0,
Action,
Modifier,
ChatEmote,
SubState,
Ignored,
}

View file

@ -330,6 +330,33 @@ public sealed class AnimationSequencer
/// makes the jump look delayed (legs stand still for ~100 ms while
/// the link drains, then fold into Falling). Defaults to false to
/// preserve normal smooth transitions for everything else.</param>
/// <summary>
/// Check whether the underlying MotionTable contains a cycle for the
/// given (style, motion) pair. Useful for callers that want to fall
/// back to a known-good motion (e.g. <c>WalkForward</c> →
/// <c>Ready</c>) instead of triggering <see cref="SetCycle"/>'s
/// unconditional <c>ClearCyclicTail</c> path on a missing cycle —
/// which leaves the body without any animation tail and snaps every
/// part to the setup-default offset (visible as "torso on the
/// ground" since most creatures' setup-default has limbs at the
/// torso origin).
/// </summary>
public bool HasCycle(uint style, uint motion)
{
// adjust_motion remapping (mirrors the head of SetCycle):
// TurnLeft, SideStepLeft, WalkBackward map to their right/forward
// mirror cycles.
uint adjustedMotion = motion;
switch (motion & 0xFFFFu)
{
case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break;
case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break;
case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break;
}
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
return _mtable.Cycles.ContainsKey(cycleKey);
}
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
{
// ── adjust_motion: remap left→right / backward→forward variants ───

View file

@ -84,6 +84,24 @@ public static class MotionCommandResolver
result[lo] = full;
}
}
ApplyNamedRetailOverrides(result);
return result;
}
private static void ApplyNamedRetailOverrides(Dictionary<ushort, uint> result)
{
// The generated DRW enum is shifted by three entries starting at
// AllegianceHometownRecall. The named Sept 2013 retail command_ids
// table is authoritative here:
// named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658
// and command-name table lines 1068272-1068313.
//
// These values cover recall, offhand, attack 4-6, and fast/slow punch
// actions. Without the override, wire command 0x0170 reconstructs to
// IssueSlashCommand instead of OffhandSlashHigh, so offhand swing
// animations route as UI commands and never play.
for (ushort lo = 0x016E; lo <= 0x0197; lo++)
result[lo] = 0x10000000u | lo;
}
}

View file

@ -72,12 +72,20 @@ public static class MotionCommand
/// regular SetCycle transition.
/// </summary>
public const uint FallDown = 0x10000050u;
/// <summary>0x10000057 — Dead.</summary>
public const uint Dead = 0x10000057u;
/// <summary>0x40000011 - persistent dead substate.</summary>
public const uint Dead = 0x40000011u;
/// <summary>0x10000057 - Sanctuary death-trigger action.</summary>
public const uint Sanctuary = 0x10000057u;
/// <summary>0x41000012 - crouching substate.</summary>
public const uint Crouch = 0x41000012u;
/// <summary>0x41000013 - sitting substate.</summary>
public const uint Sitting = 0x41000013u;
/// <summary>0x41000014 - sleeping substate.</summary>
public const uint Sleeping = 0x41000014u;
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
public const uint CrouchLowerBound = 0x41000011u;
/// <summary>0x41000014 — upper bound of crouch/sit/sleep range.</summary>
public const uint CrouchUpperBound = 0x41000014u;
/// <summary>0x41000015 - exclusive upper bound of crouch/sit/sleep range.</summary>
public const uint CrouchUpperExclusive = 0x41000015u;
}
/// <summary>
@ -819,7 +827,7 @@ public sealed class MotionInterpreter
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
/// return 0x49
/// uVar1 = InterpretedState.ForwardCommand
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead):
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead):
/// return 0x48
/// if 0x41000011 &lt; uVar1 &lt; 0x41000015 (crouch/sit/sleep range):
/// return 0x48
@ -850,7 +858,7 @@ public sealed class MotionInterpreter
return false;
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound)
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive)
return false;
// Need Gravity flag + Contact + OnWalkable for ground-based motion.

View file

@ -0,0 +1,304 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-tick steering for server-controlled remote creatures while a
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
/// is the active locomotion source.
///
/// <para>
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
/// stabilizer. With the full MoveTo path payload now captured on
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
/// the body solver has the destination + heading + thresholds it needs to
/// run the retail per-tick loop instead of waiting for sparse
/// UpdatePosition snap corrections.
/// </para>
///
/// <para>
/// Retail references:
/// <list type="bullet">
/// <item><description>
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
/// per-tick driver. Computes heading-to-target, fires an aux
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| &gt; 20°, snaps
/// orientation when within tolerance, and tests arrival via
/// <c>dist &lt;= min_distance</c> (chase) or
/// <c>dist &gt;= distance_to_object</c> (flee).
/// </description></item>
/// <item><description>
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
/// MoveToManager itself does NOT touch the body. The body's actual
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
/// the body's orientation.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Acdream port scope: minimum viable subset. We skip target re-tracking
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
/// StickTo, fail-distance progress detector, and the sphere-cylinder
/// distance variant — all server-side concerns the local body doesn't need
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
/// arrival detection via <c>min_distance</c>.
/// </para>
///
/// <para>
/// ACE divergence: ACE swaps the chase/flee arrival predicates
/// (<c>dist &lt;= DistanceToObject</c> vs retail's <c>dist &lt;= MinDistance</c>).
/// We follow retail.
/// </para>
/// </summary>
public static class RemoteMoveToDriver
{
/// <summary>
/// Heading tolerance below which we snap orientation directly to the
/// target heading (ACE's <c>set_heading(target, true)</c>
/// server-tic-rate fudge). Above tolerance we rotate at
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
/// </summary>
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
/// <summary>
/// Default angular rate for in-motion heading correction when delta
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
/// when the per-creature value differs, the future port can wire it
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
/// </summary>
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Float-comparison slack for the arrival predicate. With
/// <c>min_distance == 0</c> in a chase packet, exact equality is
/// unreachable due to integration wobble; this epsilon prevents the
/// driver from over-shooting by a sub-meter and snap-flipping back.
/// </summary>
public const float ArrivalEpsilon = 0.05f;
/// <summary>
/// Maximum staleness (seconds) of the most recent MoveTo packet
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
/// during active chase; if no fresh packet arrives for this long,
/// the entity has likely either left our streaming view, switched
/// to a non-MoveTo motion the server's broadcast didn't reach us
/// for, or had its move cancelled server-side without our seeing
/// the cancel UM. In any of those cases, continuing to drive the
/// body toward a stale destination produces the "monster runs in
/// place after popping back into view" symptom (2026-04-28).
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
/// while still failing fast on real loss-of-state.
/// </summary>
public const double StaleDestinationSeconds = 1.5;
public enum DriveResult
{
/// <summary>Within arrival window — caller should zero velocity.</summary>
Arrived,
/// <summary>Steering active — caller should let
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
Steering,
}
/// <summary>
/// Steer body orientation toward <paramref name="destinationWorld"/>
/// and report whether the body has arrived or should keep running.
/// Pure function — emits the updated orientation via
/// <paramref name="newOrientation"/> (the input is not mutated; the
/// caller assigns the new value back to its body).
/// </summary>
/// <param name="minDistance">
/// <c>min_distance</c> from the wire's MovementParameters block —
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
/// </param>
/// <param name="distanceToObject">
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
/// threshold (default 0.6 m, the melee range). The actual arrival
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
/// when retail sends <c>min_distance</c> &gt; 0, ACE-compatible when
/// ACE puts the value in <c>distance_to_object</c> with
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
/// chase packets never arrive — the body keeps re-targeting around
/// the player at melee range and visibly oscillates between facings,
/// which is the user-reported "monster keeps running in different
/// directions when it should be attacking" symptom (2026-04-28).
/// </param>
public static DriveResult Drive(
Vector3 bodyPosition,
Quaternion bodyOrientation,
Vector3 destinationWorld,
float minDistance,
float distanceToObject,
float dt,
bool moveTowards,
out Quaternion newOrientation)
{
// Horizontal distance only — server owns Z, our body Z is
// hard-snapped to the latest UpdatePosition.
float dx = destinationWorld.X - bodyPosition.X;
float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
// MoveToManager.cs:476:
//
// chase (MoveTowards): dist <= distance_to_object
// flee (MoveAway): dist >= min_distance
//
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
// defensive guess; cross-checked with two independent research
// agents against the named retail decomp + ACE port + holtburger,
// the chase threshold is unambiguously DistanceToObject —
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
// at melee range, not run to zero.)
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
// Degenerate — already on target horizontally; preserve heading.
if (dist < 1e-4f)
{
newOrientation = bodyOrientation;
return DriveResult.Steering;
}
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
// World forward = Transform((0,1,0), orientation). Yaw extracted
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
var localForward = new Vector3(0f, 1f, 0f);
var worldForward = Vector3.Transform(localForward, bodyOrientation);
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
// Desired heading: face the target. (dx, dy) is the world-space
// offset to the target. With local-forward=+Y we want yaw such
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
// to yaw = atan2(-dx, dy).
float desiredYaw = MathF.Atan2(-dx, dy);
float delta = WrapPi(desiredYaw - currentYaw);
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
{
// ACE's set_heading(target, true) — sync to server-tic-rate.
// We have the same sparse-UP problem ACE does, so the same
// fudge applies.
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
}
else
{
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
// rotate at TurnRate clamped to dt, in the shorter direction.
float maxStep = TurnRateRadPerSec * dt;
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
// Apply incremental yaw around world +Z (preserving any
// server-supplied pitch/roll from the latest UpdatePosition).
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
}
return DriveResult.Steering;
}
/// <summary>
/// Convert a landblock-local Origin from a MoveTo packet
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
/// into acdream's render world space using the same arithmetic as
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
/// from the live-mode center.
/// </summary>
public static Vector3 OriginToWorld(
uint originCellId,
float originX,
float originY,
float originZ,
int liveCenterLandblockX,
int liveCenterLandblockY)
{
int lbX = (int)((originCellId >> 24) & 0xFFu);
int lbY = (int)((originCellId >> 16) & 0xFFu);
return new Vector3(
originX + (lbX - liveCenterLandblockX) * 192f,
originY + (lbY - liveCenterLandblockY) * 192f,
originZ);
}
/// <summary>
/// Cap horizontal velocity so the body lands exactly at
/// <paramref name="arrivalThreshold"/> rather than overshooting past
/// it during the final tick of approach. Without this clamp, a body
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
/// 60 fps) — visible as the creature "running slightly through" the
/// player it's about to attack (user-reported 2026-04-28).
///
/// <para>
/// The clamp is a strict scale-down of the horizontal component
/// (X/Y); the vertical component (Z) is left to gravity / terrain
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
/// no-op since fleeing has no overshoot risk — the body wants to
/// move AWAY from the destination.
/// </para>
/// </summary>
public static Vector3 ClampApproachVelocity(
Vector3 bodyPosition,
Vector3 currentVelocity,
Vector3 destinationWorld,
float arrivalThreshold,
float dt,
bool moveTowards)
{
if (!moveTowards || dt <= 0f) return currentVelocity;
float dx = destinationWorld.X - bodyPosition.X;
float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
float remaining = MathF.Max(0f, dist - arrivalThreshold);
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
+ currentVelocity.Y * currentVelocity.Y);
if (vxy < 1e-3f) return currentVelocity;
float advance = vxy * dt;
if (advance <= remaining) return currentVelocity;
// Already inside or right at the threshold: zero horizontal
// velocity, keep Z. (The arrival predicate in Drive() should
// have fired this tick, but this is the belt-and-braces guard.)
if (remaining < 1e-3f)
return new Vector3(0f, 0f, currentVelocity.Z);
float scale = remaining / advance;
return new Vector3(
currentVelocity.X * scale,
currentVelocity.Y * scale,
currentVelocity.Z);
}
/// <summary>Wrap an angle in radians to [-π, π].</summary>
private static float WrapPi(float r)
{
const float TwoPi = MathF.PI * 2f;
r %= TwoPi;
if (r > MathF.PI) r -= TwoPi;
if (r < -MathF.PI) r += TwoPi;
return r;
}
}

View file

@ -0,0 +1,87 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Chooses the visible locomotion cycle for server-controlled remotes whose
/// UpdateMotion packet is a MoveToObject/MoveToPosition union rather than an
/// InterpretedMotionState.
///
/// Retail references:
/// <list type="bullet">
/// <item><description>
/// <c>MovementManager::PerformMovement</c> (0x00524440) dispatches movement
/// types 6/7 into <c>MoveToManager::MoveToObject/MoveToPosition</c> instead
/// of unpacking an InterpretedMotionState.
/// </description></item>
/// <item><description>
/// <c>MovementParameters::UnPackNet</c> (0x0052AC50) shows MoveTo packets
/// carry movement params + run rate, not a ForwardCommand field.
/// </description></item>
/// <item><description>
/// ACE <c>MovementData.Write</c> uses the same movement type union; holtburger
/// documents the matching <c>MovementType::MoveToPosition = 7</c>.
/// </description></item>
/// </list>
/// </summary>
public static class ServerControlledLocomotion
{
public const float StopSpeed = 0.20f;
public const float RunThreshold = 1.25f;
public const float MinSpeedMod = 0.25f;
public const float MaxSpeedMod = 3.00f;
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
// (0x0052AA00) seeds forward motion before the next position update.
public static LocomotionCycle PlanMoveToStart(
float moveToSpeed = 1f,
float runRate = 1f,
bool canRun = true)
{
moveToSpeed = SanitizePositive(moveToSpeed);
runRate = SanitizePositive(runRate);
if (!canRun)
return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
return new LocomotionCycle(
MotionCommand.RunForward,
moveToSpeed * runRate,
true);
}
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
{
float horizontalSpeed = MathF.Sqrt(
worldVelocity.X * worldVelocity.X +
worldVelocity.Y * worldVelocity.Y);
if (horizontalSpeed < StopSpeed)
return new LocomotionCycle(MotionCommand.Ready, 1f, false);
if (horizontalSpeed < RunThreshold)
{
float speedMod = Math.Clamp(
horizontalSpeed / MotionInterpreter.WalkAnimSpeed,
MinSpeedMod,
MaxSpeedMod);
return new LocomotionCycle(MotionCommand.WalkForward, speedMod, true);
}
return new LocomotionCycle(
MotionCommand.RunForward,
Math.Clamp(horizontalSpeed / MotionInterpreter.RunAnimSpeed, MinSpeedMod, MaxSpeedMod),
true);
}
public readonly record struct LocomotionCycle(
uint Motion,
float SpeedMod,
bool IsMoving);
private static float SanitizePositive(float value)
{
return float.IsFinite(value) && value > 0f ? value : 1f;
}
}

View file

@ -37,9 +37,9 @@ public static class SurfaceDecoder
PixelFormat.PFID_R8G8B8 => DecodeR8G8B8(rs),
PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs),
PixelFormat.PFID_X8R8G8B8 => DecodeX8R8G8B8(rs),
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1),
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2),
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3),
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap),
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap),
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap),
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap),
PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap),
@ -245,7 +245,7 @@ public static class SurfaceDecoder
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format)
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap)
{
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);
var rgba = new byte[rs.Width * rs.Height * 4];
@ -256,6 +256,8 @@ public static class SurfaceDecoder
rgba[s + 1] = pixels[i].g;
rgba[s + 2] = pixels[i].b;
rgba[s + 3] = pixels[i].a;
if (isClipMap && rgba[s + 0] == 0 && rgba[s + 1] == 0 && rgba[s + 2] == 0)
rgba[s + 3] = 0;
}
return new DecodedTexture(rgba, rs.Width, rs.Height);
}

View file

@ -1,73 +1,38 @@
using System;
using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter;
using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter;
using DatEmitterType = DatReaderWriter.Enums.EmitterType;
using DatParticleType = DatReaderWriter.Enums.ParticleType;
namespace AcDream.Core.Vfx;
/// <summary>
/// Resolves <see cref="EmitterDesc"/> instances by their retail emitter
/// dat id (<c>0x32xxxxxx</c> range). The current build of
/// Chorizite.DatReaderWriter (v2.1.7) doesn't yet ship a
/// <c>ParticleEmitterInfo</c> DBObj class, so we maintain a small
/// registry of synthesized descriptors for the handful of emitters
/// acdream actually needs (portal swirl, chimney smoke, fireplace
/// flames, footstep dust, spell auras, weapon trails) and fall back to
/// a generic "puff" for unknown ids. When a future DRW release adds
/// the dat-type, this class will additionally load + cache from dats.
///
/// <para>
/// Field mapping once the dat-type arrives (docs/research/deepdives/
/// r04-vfx-particles.md §1 + references/DatReaderWriter's own generated
/// <c>ParticleEmitterInfo.generated.cs</c>):
/// <list type="bullet">
/// <item><description>
/// <c>Birthrate</c> → <c>1 / EmitRate</c> (retail stores the avg
/// time between spawns, not the rate).
/// </description></item>
/// <item><description>
/// <c>Lifespan ± LifespanRand</c> → <c>LifetimeMin / LifetimeMax</c>
/// range.
/// </description></item>
/// <item><description>
/// <c>A, MinA, MaxA</c> → primary initial velocity with magnitude
/// jitter; <c>B</c> / <c>C</c> are secondary spread components.
/// </description></item>
/// <item><description>
/// <c>StartScale, FinalScale</c> / <c>StartTrans, FinalTrans</c>
/// interpolate linearly over life.
/// </description></item>
/// </list>
/// </para>
/// Resolves retail <c>ParticleEmitterInfo</c> dat records
/// (<c>0x32xxxxxx</c>) into acdream runtime descriptors.
/// </summary>
public sealed class EmitterDescRegistry
{
private const uint FallbackEmitterId = 0xFFFFFFFFu;
private readonly Func<uint, DatParticleEmitter?>? _resolver;
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
public EmitterDescRegistry()
: this((Func<uint, DatParticleEmitter?>?)null)
{
// Seed with a handful of well-known AC emitter ids plus a
// fallback. Ids here come from empirical ACViewer dat dumps —
// see r04 §5.2 for the more complete inventory.
Register(new EmitterDesc
{
DatId = 0xFFFFFFFFu, // "default" sentinel
Type = ParticleType.LocalVelocity,
Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera,
EmitRate = 10f,
MaxParticles = 32,
LifetimeMin = 0.6f,
LifetimeMax = 1.2f,
OffsetDir = new Vector3(0, 0, 1),
MinOffset = 0f,
MaxOffset = 0.1f,
SpawnDiskRadius = 0.1f,
InitialVelocity = new Vector3(0, 0, 0.5f),
VelocityJitter = 0.3f,
StartSize = 0.25f,
EndSize = 0.6f,
StartAlpha = 0.85f,
EndAlpha = 0f,
});
}
public EmitterDescRegistry(DatCollection dats)
: this(id => SafeGet(dats, id))
{
}
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
{
_resolver = resolver;
Register(BuildFallback());
}
public void Register(EmitterDesc desc)
@ -78,10 +43,159 @@ public sealed class EmitterDescRegistry
public EmitterDesc Get(uint emitterId)
{
if (_byId.TryGetValue(emitterId, out var desc)) return desc;
if (_byId.TryGetValue(0xFFFFFFFFu, out var fallback)) return fallback;
if (_byId.TryGetValue(emitterId, out var desc))
return desc;
if (_resolver is not null)
{
var dat = _resolver(emitterId);
if (dat is not null)
{
desc = FromDat(emitterId, dat);
_byId[emitterId] = desc;
return desc;
}
}
if (_byId.TryGetValue(FallbackEmitterId, out var fallback))
return fallback;
throw new InvalidOperationException("No default emitter registered in registry.");
}
public int Count => _byId.Count;
public static EmitterDesc FromDat(uint emitterId, DatParticleEmitter dat)
{
ArgumentNullException.ThrowIfNull(dat);
float birthrate = MathF.Max(0f, (float)dat.Birthrate);
float lifespan = MathF.Max(0f, (float)dat.Lifespan);
float lifespanRand = MathF.Abs((float)dat.LifespanRand);
float lifetimeMin = MathF.Max(0f, lifespan - lifespanRand);
float lifetimeMax = MathF.Max(lifetimeMin, lifespan + lifespanRand);
// ParticleEmitterInfo has no "additive" field; retail derives blend
// state from the particle GfxObj surface material.
var flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera;
if (dat.IsParentLocal)
flags |= EmitterFlags.AttachLocal;
// ParticleEmitterInfo stores translucency, not opacity. Retail feeds
// StartTrans/FinalTrans to PhysicsPart::SetTranslucency; the GL path
// uses the complement as source alpha.
float startOpacity = 1f - Math.Clamp((float)dat.StartTrans, 0f, 1f);
float endOpacity = 1f - Math.Clamp((float)dat.FinalTrans, 0f, 1f);
return new EmitterDesc
{
DatId = emitterId,
Type = MapParticleType(dat.ParticleType),
EmitterKind = MapEmitterKind(dat.EmitterType),
Flags = flags,
GfxObjId = dat.GfxObjId.DataId,
HwGfxObjId = dat.HwGfxObjId.DataId,
Birthrate = birthrate,
EmitRate = dat.EmitterType == DatEmitterType.BirthratePerSec && birthrate > 0f
? 1f / birthrate
: 0f,
MaxParticles = Math.Max(1, dat.MaxParticles),
InitialParticles = Math.Max(0, dat.InitialParticles),
TotalParticles = Math.Max(0, dat.TotalParticles),
TotalDuration = MathF.Max(0f, (float)dat.TotalSeconds),
Lifespan = lifespan,
LifespanRand = lifespanRand,
LifetimeMin = lifetimeMin,
LifetimeMax = lifetimeMax,
OffsetDir = dat.OffsetDir,
MinOffset = dat.MinOffset,
MaxOffset = dat.MaxOffset,
SpawnDiskRadius = dat.MaxOffset,
InitialVelocity = dat.A,
Gravity = dat.B,
A = dat.A,
MinA = dat.MinA,
MaxA = dat.MaxA,
B = dat.B,
MinB = dat.MinB,
MaxB = dat.MaxB,
C = dat.C,
MinC = dat.MinC,
MaxC = dat.MaxC,
StartSize = dat.StartScale,
EndSize = dat.FinalScale,
ScaleRand = dat.ScaleRand,
StartAlpha = startOpacity,
EndAlpha = endOpacity,
TransRand = dat.TransRand,
};
}
private static DatParticleEmitter? SafeGet(DatCollection dats, uint id)
{
if (dats is null)
return null;
try
{
return dats.Get<DatParticleEmitter>(id);
}
catch
{
return null;
}
}
private static EmitterDesc BuildFallback() => new()
{
DatId = FallbackEmitterId,
Type = ParticleType.LocalVelocity,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera,
Birthrate = 0.1f,
EmitRate = 10f,
MaxParticles = 32,
LifetimeMin = 0.6f,
LifetimeMax = 1.2f,
Lifespan = 0.9f,
LifespanRand = 0.3f,
OffsetDir = new Vector3(0, 0, 1),
MinOffset = 0f,
MaxOffset = 0.1f,
SpawnDiskRadius = 0.1f,
InitialVelocity = new Vector3(0, 0, 0.5f),
VelocityJitter = 0.3f,
A = new Vector3(0, 0, 0.5f),
MinA = 1f,
MaxA = 1f,
B = Vector3.Zero,
C = Vector3.Zero,
StartSize = 0.25f,
EndSize = 0.6f,
StartAlpha = 0.85f,
EndAlpha = 0f,
};
private static ParticleEmitterKind MapEmitterKind(DatEmitterType type) => type switch
{
DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec,
DatEmitterType.BirthratePerMeter => ParticleEmitterKind.BirthratePerMeter,
_ => ParticleEmitterKind.Unknown,
};
private static ParticleType MapParticleType(DatParticleType type) => type switch
{
DatParticleType.Still => ParticleType.Still,
DatParticleType.LocalVelocity => ParticleType.LocalVelocity,
DatParticleType.ParabolicLVGA => ParticleType.ParabolicLVGA,
DatParticleType.ParabolicLVGAGR => ParticleType.ParabolicLVGAGR,
DatParticleType.Swarm => ParticleType.Swarm,
DatParticleType.Explode => ParticleType.Explode,
DatParticleType.Implode => ParticleType.Implode,
DatParticleType.ParabolicLVLA => ParticleType.ParabolicLVLA,
DatParticleType.ParabolicLVLALR => ParticleType.ParabolicLVLALR,
DatParticleType.ParabolicGVGA => ParticleType.ParabolicGVGA,
DatParticleType.ParabolicGVGAGR => ParticleType.ParabolicGVGAGR,
DatParticleType.GlobalVelocity => ParticleType.GlobalVelocity,
_ => ParticleType.Unknown,
};
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Numerics;
using System.Threading;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
@ -62,10 +63,30 @@ public sealed class ParticleHookSink : IAnimationHookSink
// key ("the smoke trail I spawned 2 seconds ago"), so we track by
// (entity, emitterId).
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
// entityId → set of live emitter handles. Dictionary-as-set so we can
// remove individual handles when their emitter dies (M4 fix —
// ConcurrentBag couldn't drop entries, so handles for naturally-expired
// emitters used to leak).
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly ConcurrentDictionary<uint, Quaternion> _rotationByEntity = new();
private int _anonymousEmitterSerial;
public ParticleHookSink(ParticleSystem system)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_system.EmitterDied += OnEmitterDied;
}
private void OnEmitterDied(int handle)
{
if (!_trackingByHandle.TryRemove(handle, out var t))
return;
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
bag.TryRemove(handle, out _);
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
@ -104,6 +125,54 @@ public sealed class ParticleHookSink : IAnimationHookSink
}
}
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
=> _renderPassByEntity[entityId] = renderPass;
public void SetEntityRotation(uint entityId, Quaternion rotation)
=> _rotationByEntity[entityId] = rotation;
public void ClearEntityRenderPass(uint entityId)
=> _renderPassByEntity.TryRemove(entityId, out _);
/// <summary>
/// Refresh every live emitter on this entity to a new world anchor +
/// rotation. The owning subsystem (sky-PES driver, animation tick)
/// drives this each frame for AttachLocal emitters so they track their
/// moving parent — retail-faithful via
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c>, which
/// re-reads the parent frame each tick when <c>is_parent_local != 0</c>.
/// Safe to call for entities with no live emitters (no-op).
/// </summary>
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
{
_rotationByEntity[entityId] = rotation;
if (!_handlesByEntity.TryGetValue(entityId, out var bag))
return;
foreach (var handle in bag.Keys)
_system.UpdateEmitterAnchor(handle, anchor, rotation);
}
public void StopAllForEntity(uint entityId, bool fadeOut)
{
if (_handlesByEntity.TryRemove(entityId, out var handles))
{
foreach (var handle in handles.Keys)
{
_system.StopEmitter(handle, fadeOut);
_trackingByHandle.TryRemove(handle, out _);
}
}
foreach (var key in _handlesByKey.Keys)
{
if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _);
}
ClearEntityRenderPass(entityId);
_rotationByEntity.TryRemove(entityId, out _);
}
private void SpawnFromHook(
uint entityId,
Vector3 worldPos,
@ -115,15 +184,35 @@ public sealed class ParticleHookSink : IAnimationHookSink
// Spawn position: entity pose + hook offset. PartIndex will be
// used when the renderer passes per-part transforms through; for
// now, fold it into the root pos.
var anchor = worldPos + offset;
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
? rot
: Quaternion.Identity;
var anchor = worldPos + Vector3.Transform(offset, rotation);
var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
? pass
: ParticleRenderPass.Scene;
int handle = _system.SpawnEmitterById(
emitterId: emitterInfoId,
anchor: anchor,
rot: Quaternion.Identity,
rot: rotation,
attachedObjectId: entityId,
attachedPartIndex: partIndex);
attachedPartIndex: partIndex,
renderPass: renderPass);
_handlesByKey[(entityId, logicalId)] = handle;
uint keyId = logicalId != 0
? logicalId
: 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
{
_system.StopEmitter(oldHandle, fadeOut: false);
_trackingByHandle.TryRemove(oldHandle, out _);
}
_handlesByKey[(entityId, keyId)] = handle;
_handlesByEntity
.GetOrAdd(entityId, _ => new ConcurrentDictionary<int, byte>())
.TryAdd(handle, 0);
_trackingByHandle[handle] = (entityId, keyId);
}
}

View file

@ -5,33 +5,18 @@ using System.Numerics;
namespace AcDream.Core.Vfx;
/// <summary>
/// Runtime particle orchestrator — port of retail's <c>CParticleManager</c>
/// (r04 §2). Owns a pool of active <see cref="ParticleEmitter"/> instances,
/// advances each per-frame via one of 13 motion integrators, fades colour /
/// scale over life, and exposes a flat particle stream for the renderer.
///
/// <para>
/// Not thread-safe — called only from the render thread (same thread that
/// drives TickAnimations).
/// </para>
///
/// <para>
/// Handle-based API so callers can stop a specific emitter later (cast
/// interrupt, fadeout). <see cref="SpawnEmitter"/> returns a positive
/// integer; <see cref="StopEmitter"/> accepts it.
/// </para>
/// Runtime particle orchestrator. The data and update rules are a direct
/// port of retail's <c>ParticleEmitterInfo</c>, <c>ParticleEmitter</c>, and
/// <c>Particle::Update</c> paths from the named retail decompilation.
/// </summary>
public sealed class ParticleSystem : IParticleSystem
{
private readonly EmitterDescRegistry _registry;
private readonly Random _rng;
// All live emitters keyed by our handle. Lookup is cheap; iteration is
// per-frame so we also keep a flat list for stable ordering (draw order).
private readonly Dictionary<int, ParticleEmitter> _byHandle = new();
private readonly List<int> _handleOrder = new();
private int _nextHandle = 1;
private int _nextHandle = 1;
private float _time;
private int _activeParticleCount;
@ -49,7 +34,8 @@ public sealed class ParticleSystem : IParticleSystem
Vector3 anchor,
Quaternion? rot = null,
uint attachedObjectId = 0,
int attachedPartIndex = -1)
int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
{
ArgumentNullException.ThrowIfNull(desc);
@ -61,43 +47,45 @@ public sealed class ParticleSystem : IParticleSystem
AnchorRot = rot ?? Quaternion.Identity,
AttachedObjectId = attachedObjectId,
AttachedPartIndex = attachedPartIndex,
RenderPass = renderPass,
Particles = new Particle[Math.Max(1, desc.MaxParticles)],
StartedAt = _time,
LastEmitTime = _time,
LastEmitOffset = anchor,
};
_byHandle[handle] = emitter;
_handleOrder.Add(handle);
for (int i = 0; i < desc.InitialParticles; i++)
SpawnOne(emitter, allowWhenFull: false);
return handle;
}
/// <summary>
/// Convenience: spawn by retail emitter id — the registry resolves to
/// the correct <see cref="EmitterDesc"/>, or falls back to the default
/// if unknown. Used by the hook sink when a CreateParticleHook arrives.
/// </summary>
public int SpawnEmitterById(
uint emitterId,
Vector3 anchor,
Quaternion? rot = null,
uint attachedObjectId = 0,
int attachedPartIndex = -1)
int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
{
var desc = _registry.Get(emitterId);
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex);
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass);
}
public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f)
{
// Full PhysicsScript dispatch is on hold until the DatReaderWriter
// library exposes ParticleEmitterInfo / PhysicsScript. For now,
// this is a no-op — callers use SpawnEmitter or the hook sink.
// Full PhysicsScript scheduling lives in PhysicsScriptRunner.
}
public void StopEmitter(int handle, bool fadeOut)
{
if (!_byHandle.TryGetValue(handle, out var em)) return;
if (!_byHandle.TryGetValue(handle, out var em))
return;
em.Finished = true;
// fadeOut=false would stop instantly; our renderer currently drops
// Finished emitters that have no living particles each tick.
if (!fadeOut)
{
for (int i = 0; i < em.Particles.Length; i++)
@ -105,259 +93,454 @@ public sealed class ParticleSystem : IParticleSystem
}
}
/// <summary>
/// Refresh an active emitter's world anchor + orientation. Required for
/// retail's <c>is_parent_local=1</c> (acdream's
/// <see cref="EmitterFlags.AttachLocal"/>) semantics: retail
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c> reads the
/// LIVE parent frame each tick when <c>is_parent_local != 0</c>. The
/// caller (typically a tick loop tracking a moving parent — the camera
/// for sky-PES, an entity for animation hooks) drives this every frame.
/// </summary>
public void UpdateEmitterAnchor(int handle, Vector3 anchor, Quaternion? rot = null)
{
if (!_byHandle.TryGetValue(handle, out var em))
return;
em.AnchorPos = anchor;
if (rot.HasValue)
em.AnchorRot = rot.Value;
}
/// <summary>True when the given handle still maps to a live emitter.</summary>
public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle);
/// <summary>
/// Fired exactly once per emitter when it is removed from the live set
/// (either because it finished naturally or was stopped without fade).
/// Subscribers (e.g. <see cref="ParticleHookSink"/>) use this to prune
/// per-entity handle tracking so the per-entity bag doesn't grow without
/// bound during a long session.
/// </summary>
public event Action<int>? EmitterDied;
public void Tick(float dt)
{
if (dt <= 0f) return;
if (dt <= 0f)
return;
_time += dt;
_activeParticleCount = 0;
// Iterate handles by a snapshot so StopEmitter-inside-emit is safe.
for (int i = 0; i < _handleOrder.Count; i++)
{
int handle = _handleOrder[i];
if (!_byHandle.TryGetValue(handle, out var em)) continue;
if (!_byHandle.TryGetValue(handle, out var em))
continue;
AdvanceEmitter(em, dt);
_activeParticleCount += CountAlive(em);
AdvanceEmitter(em);
int live = CountAlive(em);
em.ActiveCount = live;
_activeParticleCount += live;
bool durationDone = em.Desc.TotalDuration > 0f
&& (_time - em.StartedAt) > em.Desc.TotalDuration;
if (durationDone) em.Finished = true;
if (em.Desc.TotalDuration > 0f && (_time - em.StartedAt) > em.Desc.TotalDuration)
em.Finished = true;
// Drop emitter entirely when it has no live particles AND is
// marked finished (duration elapsed, StopEmitter, etc).
if (em.Finished && CountAlive(em) == 0)
if (em.Desc.TotalParticles > 0 && em.TotalEmitted >= em.Desc.TotalParticles)
em.Finished = true;
if (em.Finished && live == 0)
{
_byHandle.Remove(handle);
_handleOrder.RemoveAt(i);
i--;
EmitterDied?.Invoke(handle);
}
}
}
/// <summary>
/// Enumerate every live particle with its emitter description for
/// the renderer. Yields (emitter, particleIndex) so the caller can
/// read <c>em.Particles[idx]</c> directly.
/// </summary>
public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive()
{
foreach (var handle in _handleOrder)
{
if (!_byHandle.TryGetValue(handle, out var em)) continue;
if (!_byHandle.TryGetValue(handle, out var em))
continue;
for (int i = 0; i < em.Particles.Length; i++)
{
if (em.Particles[i].Alive) yield return (em, i);
if (em.Particles[i].Alive)
yield return (em, i);
}
}
}
// ── Private: emission + integration ──────────────────────────────────────
private void AdvanceEmitter(ParticleEmitter em, float dt)
private void AdvanceEmitter(ParticleEmitter em)
{
if (!em.Finished && em.Desc.EmitRate > 0f)
{
em.EmittedAccumulator += dt * em.Desc.EmitRate;
while (em.EmittedAccumulator >= 1.0f)
{
em.EmittedAccumulator -= 1.0f;
SpawnOne(em);
}
}
// Update every particle slot.
for (int i = 0; i < em.Particles.Length; i++)
{
ref var p = ref em.Particles[i];
if (!p.Alive) continue;
if (!p.Alive)
continue;
p.Age += dt;
if (p.Age >= p.Lifetime)
p.Age = _time - p.SpawnedAt;
if (p.Lifetime <= 0f || p.Age >= p.Lifetime)
{
p.Alive = false;
continue;
}
Integrate(ref p, em, dt);
p.Position = ComputePosition(em, p);
float tLife = Math.Clamp(p.Age / p.Lifetime, 0f, 1f);
p.Size = Lerp(em.Desc.StartSize, em.Desc.EndSize, tLife);
float alpha = Lerp(em.Desc.StartAlpha, em.Desc.EndAlpha, tLife);
p.Size = Lerp(p.StartSize, p.EndSize, tLife);
p.Rotation = Lerp(em.Desc.StartRotation, em.Desc.EndRotation, tLife);
float alpha = Lerp(p.StartAlpha, p.EndAlpha, tLife);
p.ColorArgb = Color32(alpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, tLife);
}
if (em.Finished || _time < em.StartedAt + em.Desc.StartDelay)
return;
while (ShouldEmitParticle(em))
{
if (!SpawnOne(em, allowWhenFull: false))
break;
}
if (em.Desc.Birthrate <= 0f && em.Desc.EmitRate > 0f)
{
float dt = _time - em.LastEmitTime;
em.EmittedAccumulator += dt * em.Desc.EmitRate;
em.LastEmitTime = _time;
while (em.EmittedAccumulator >= 1f)
{
em.EmittedAccumulator -= 1f;
if (!SpawnOne(em, allowWhenFull: false))
break;
}
}
}
private void SpawnOne(ParticleEmitter em)
private bool ShouldEmitParticle(ParticleEmitter em)
{
// Find a free slot; overwrite the oldest if pool is full.
int slot = -1;
for (int i = 0; i < em.Particles.Length; i++)
var desc = em.Desc;
if (desc.TotalParticles > 0 && em.TotalEmitted >= desc.TotalParticles)
return false;
if (CountAlive(em) >= desc.MaxParticles)
return false;
if (desc.Birthrate <= 0f)
return false;
return desc.EmitterKind switch
{
if (!em.Particles[i].Alive) { slot = i; break; }
}
ParticleEmitterKind.BirthratePerSec => (_time - em.LastEmitTime) > desc.Birthrate,
ParticleEmitterKind.BirthratePerMeter =>
Vector3.DistanceSquared(em.AnchorPos, em.LastEmitOffset) > desc.Birthrate * desc.Birthrate,
_ => false,
};
}
private bool SpawnOne(ParticleEmitter em, bool allowWhenFull)
{
int slot = FindFreeSlot(em);
if (slot < 0 && allowWhenFull)
slot = FindOldestSlot(em);
if (slot < 0)
{
// Pool saturated; overwrite the slot closest to dying (oldest
// by age / lifetime ratio). Matches retail's behaviour of
// recycling the expiring particle rather than dropping.
float best = -1f;
for (int i = 0; i < em.Particles.Length; i++)
{
ref var p = ref em.Particles[i];
float r = p.Lifetime > 0 ? p.Age / p.Lifetime : 1f;
if (r > best) { best = r; slot = i; }
}
if (slot < 0) return;
}
return false;
ref var particle = ref em.Particles[slot];
particle = default;
particle.Alive = true;
particle.Age = 0f;
particle.Lifetime = Lerp(em.Desc.LifetimeMin, em.Desc.LifetimeMax,
(float)_rng.NextDouble());
// Position = emitter anchor + random offset in a disk perpendicular
// to OffsetDir. This models the retail annulus.
Vector3 disk = RandomDiskVector(em.Desc.OffsetDir, em.Desc.MaxOffset);
particle.Position = em.AnchorPos + disk;
particle.SpawnedAt = _time;
particle.Lifetime = RandomLifespan(em.Desc);
particle.EmissionOrigin = em.AnchorPos;
particle.SpawnRotation = em.AnchorRot;
// Velocity = initial vector ± jitter in all three axes.
Vector3 v = em.Desc.InitialVelocity;
if (em.Desc.VelocityJitter > 0f)
Vector3 localOffset = RandomOffset(em.Desc);
Vector3 localA = RandomVector(em.Desc.A, em.Desc.MinA, em.Desc.MaxA);
Vector3 localB = RandomVector(em.Desc.B, em.Desc.MinB, em.Desc.MaxB);
Vector3 localC = RandomVector(em.Desc.C, em.Desc.MinC, em.Desc.MaxC);
if (localA == Vector3.Zero && em.Desc.InitialVelocity != Vector3.Zero)
{
v += new Vector3(
RandomCentered(em.Desc.VelocityJitter),
RandomCentered(em.Desc.VelocityJitter),
RandomCentered(em.Desc.VelocityJitter));
localA = em.Desc.InitialVelocity;
if (em.Desc.VelocityJitter > 0f)
{
localA += new Vector3(
RandomCentered(em.Desc.VelocityJitter),
RandomCentered(em.Desc.VelocityJitter),
RandomCentered(em.Desc.VelocityJitter));
}
}
particle.Velocity = v;
particle.Size = em.Desc.StartSize;
particle.Rotation = em.Desc.StartRotation;
particle.ColorArgb = em.Desc.StartColorArgb;
if (localB == Vector3.Zero && em.Desc.Gravity != Vector3.Zero)
localB = em.Desc.Gravity;
InitParticleVectors(em, ref particle, localOffset, localA, localB, localC);
particle.Velocity = particle.A;
particle.StartSize = RandomScale(em.Desc.StartSize, em.Desc.ScaleRand);
particle.EndSize = RandomScale(em.Desc.EndSize, em.Desc.ScaleRand);
particle.StartAlpha = RandomTrans(em.Desc.StartAlpha, em.Desc.TransRand);
particle.EndAlpha = RandomTrans(em.Desc.EndAlpha, em.Desc.TransRand);
particle.Size = particle.StartSize;
particle.ColorArgb = Color32(particle.StartAlpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, 0f);
particle.Position = ComputePosition(em, particle);
em.TotalEmitted++;
em.LastEmitTime = _time;
em.LastEmitOffset = em.AnchorPos;
return true;
}
// ── 13 retail motion integrators (r04 §3) ────────────────────────────────
private void Integrate(ref Particle p, ParticleEmitter em, float dt)
private Vector3 ComputePosition(ParticleEmitter em, Particle p)
{
float t = p.Age;
Vector3 origin = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0
? em.AnchorPos
: p.EmissionOrigin;
Vector3 offset = p.Offset;
Vector3 a = p.A;
Vector3 b = p.B;
Vector3 c = p.C;
return em.Desc.Type switch
{
ParticleType.Still => origin + offset,
ParticleType.LocalVelocity or ParticleType.GlobalVelocity =>
origin + offset + t * a,
ParticleType.ParabolicLVGA or ParticleType.ParabolicLVLA or ParticleType.ParabolicGVGA =>
origin + offset + t * a + 0.5f * t * t * b,
ParticleType.ParabolicLVGAGR or ParticleType.ParabolicLVLALR or ParticleType.ParabolicGVGAGR =>
origin + offset + t * a + 0.5f * t * t * b,
ParticleType.Swarm =>
origin + offset + t * a + new Vector3(
MathF.Cos(t * b.X) * c.X,
MathF.Sin(t * b.Y) * c.Y,
MathF.Cos(t * b.Z) * c.Z),
ParticleType.Explode =>
origin + offset + new Vector3(
(t * b.X + c.X * a.X) * t,
(t * b.Y + c.Y * a.X) * t,
(t * b.Z + c.Z * a.X + a.Z) * t),
ParticleType.Implode =>
origin + offset + MathF.Cos(a.X * t) * c + t * t * b,
_ => origin + offset + t * a,
};
}
private void InitParticleVectors(
ParticleEmitter em,
ref Particle particle,
Vector3 localOffset,
Vector3 localA,
Vector3 localB,
Vector3 localC)
{
// Retail Particle::Init 0x0051c930 resolves local/global vector
// spaces once at spawn; Particle::Update 0x0051c290 then integrates
// those stored world-space coefficients each frame.
particle.Offset = ToSpawnWorld(em, localOffset);
particle.A = localA;
particle.B = localB;
particle.C = localC;
switch (em.Desc.Type)
{
case ParticleType.Still:
// No motion. Age + fade only.
break;
case ParticleType.LocalVelocity:
// Constant spawn velocity, no acceleration.
p.Position += p.Velocity * dt;
break;
case ParticleType.GlobalVelocity:
// Uses emitter's InitialVelocity (global/world-space);
// each particle keeps its own copy already (set at spawn),
// so behaves identically to LocalVelocity at runtime.
p.Position += p.Velocity * dt;
break;
case ParticleType.Parabolic:
case ParticleType.ParabolicLVGV:
case ParticleType.ParabolicLVGA:
particle.A = ToSpawnWorld(em, localA);
break;
case ParticleType.ParabolicLVLA:
case ParticleType.ParabolicGVGA:
case ParticleType.ParabolicGVLA:
case ParticleType.ParabolicLALV:
// Velocity decays with gravity; position integrates.
p.Velocity += em.Desc.Gravity * dt;
p.Position += p.Velocity * dt;
particle.A = ToSpawnWorld(em, localA);
particle.B = ToSpawnWorld(em, localB);
break;
case ParticleType.ParabolicLVGAGR:
particle.A = ToSpawnWorld(em, localA);
particle.C = localC;
break;
case ParticleType.Swarm:
// Orbital drift around anchor. Apply a tangential swirl.
{
Vector3 toCenter = em.AnchorPos - p.Position;
Vector3 axis = em.Desc.OffsetDir == Vector3.Zero ? Vector3.UnitZ : em.Desc.OffsetDir;
Vector3 tangent = Vector3.Normalize(Vector3.Cross(axis, toCenter));
p.Velocity = Vector3.Lerp(p.Velocity, tangent * em.Desc.InitialVelocity.Length(), dt * 4f);
p.Position += p.Velocity * dt;
}
particle.A = ToSpawnWorld(em, localA);
break;
case ParticleType.Explode:
// Push outward along (position - anchor).
{
Vector3 dir = p.Position - em.AnchorPos;
if (dir.LengthSquared() < 1e-6f) dir = Vector3.UnitZ;
else dir = Vector3.Normalize(dir);
p.Velocity = dir * em.Desc.InitialVelocity.Length();
p.Position += p.Velocity * dt;
}
particle.A = localA;
particle.B = localB;
particle.C = RandomExplodeDirection(localC);
break;
case ParticleType.Implode:
// Pull inward toward anchor.
{
Vector3 dir = em.AnchorPos - p.Position;
float dist = dir.Length();
if (dist < 0.01f) { p.Alive = false; break; }
dir /= dist;
p.Velocity = dir * em.Desc.InitialVelocity.Length();
p.Position += p.Velocity * dt;
}
particle.A = localA;
particle.B = localB;
particle.Offset = new Vector3(
particle.Offset.X * localC.X,
particle.Offset.Y * localC.Y,
particle.Offset.Z * localC.Z);
particle.C = particle.Offset;
break;
default:
p.Position += p.Velocity * dt;
case ParticleType.ParabolicLVLALR:
particle.A = ToSpawnWorld(em, localA);
particle.B = ToSpawnWorld(em, localB);
particle.C = ToSpawnWorld(em, localC);
break;
case ParticleType.ParabolicGVGAGR:
particle.C = localC;
break;
}
}
// ── Utility ──────────────────────────────────────────────────────────────
private static Vector3 ToSpawnWorld(ParticleEmitter em, Vector3 value)
=> em.AnchorRot == Quaternion.Identity ? value : Vector3.Transform(value, em.AnchorRot);
private Vector3 RandomExplodeDirection(Vector3 localC)
{
float yaw = RandomRange(-MathF.PI, MathF.PI);
float pitch = RandomRange(-MathF.PI, MathF.PI);
float cosPitch = MathF.Cos(pitch);
Vector3 c = new(
MathF.Cos(yaw) * localC.X * cosPitch,
MathF.Sin(yaw) * localC.Y * cosPitch,
MathF.Sin(pitch) * localC.Z);
return NormalizeCheckSmall(ref c) ? Vector3.Zero : c;
}
private int FindFreeSlot(ParticleEmitter em)
{
for (int i = 0; i < em.Particles.Length; i++)
{
if (!em.Particles[i].Alive)
return i;
}
return -1;
}
private static int FindOldestSlot(ParticleEmitter em)
{
int slot = -1;
float best = -1f;
for (int i = 0; i < em.Particles.Length; i++)
{
ref var p = ref em.Particles[i];
float r = p.Lifetime > 0f ? p.Age / p.Lifetime : 1f;
if (r > best)
{
best = r;
slot = i;
}
}
return slot;
}
private static int CountAlive(ParticleEmitter em)
{
int n = 0;
for (int i = 0; i < em.Particles.Length; i++)
if (em.Particles[i].Alive) n++;
{
if (em.Particles[i].Alive)
n++;
}
return n;
}
private float RandomLifespan(EmitterDesc desc)
{
float lifespan = desc.Lifespan > 0f ? desc.Lifespan : (desc.LifetimeMin + desc.LifetimeMax) * 0.5f;
float rand = desc.LifespanRand > 0f ? desc.LifespanRand : MathF.Abs(desc.LifetimeMax - desc.LifetimeMin) * 0.5f;
float value = lifespan + RandomCentered(rand);
if (value <= 0f && desc.LifetimeMax > 0f)
value = Lerp(desc.LifetimeMin, desc.LifetimeMax, (float)_rng.NextDouble());
return MathF.Max(0f, value);
}
private Vector3 RandomOffset(EmitterDesc desc)
{
float min = MathF.Min(desc.MinOffset, desc.MaxOffset);
float max = MathF.Max(desc.MinOffset, desc.MaxOffset);
if (max <= 0f)
return Vector3.Zero;
Vector3 axis = NormalizeOrZero(desc.OffsetDir);
Vector3 v = new(
RandomCentered(1f),
RandomCentered(1f),
RandomCentered(1f));
if (axis != Vector3.Zero)
v -= axis * Vector3.Dot(v, axis);
if (v.LengthSquared() < 1e-8f)
v = axis != Vector3.Zero ? Perpendicular(axis) : Vector3.UnitX;
else
v = Vector3.Normalize(v);
return v * Lerp(min, max, (float)_rng.NextDouble());
}
private Vector3 RandomVector(Vector3 direction, float min, float max)
{
if (direction == Vector3.Zero)
return Vector3.Zero;
if (max < min)
(min, max) = (max, min);
return direction * Lerp(min, max, (float)_rng.NextDouble());
}
private float RandomScale(float baseValue, float rand)
=> Math.Clamp(baseValue + RandomCentered(rand), 0.1f, 10f);
private float RandomTrans(float baseValue, float rand)
=> Math.Clamp(baseValue + RandomCentered(rand), 0f, 1f);
private float RandomCentered(float halfWidth)
=> ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth;
private float RandomRange(float min, float max)
=> Lerp(min, max, (float)_rng.NextDouble());
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
private static Vector3 NormalizeOrZero(Vector3 v)
=> v.LengthSquared() > 1e-8f ? Vector3.Normalize(v) : Vector3.Zero;
private static bool NormalizeCheckSmall(ref Vector3 v)
{
float length = v.Length();
if (length < 1e-8f)
return true;
v /= length;
return false;
}
private static Vector3 Perpendicular(Vector3 v)
{
Vector3 basis = MathF.Abs(v.X) < 0.9f ? Vector3.UnitX : Vector3.UnitY;
return Vector3.Normalize(Vector3.Cross(v, basis));
}
private static uint Color32(float alpha, uint startArgb, uint endArgb, float t)
{
// Blend RGB channels linearly; apply alpha override from fade.
byte sa = (byte)((startArgb >> 24) & 0xFF);
byte sr = (byte)((startArgb >> 16) & 0xFF);
byte sg = (byte)((startArgb >> 8) & 0xFF);
byte sb = (byte)( startArgb & 0xFF);
byte ea = (byte)((endArgb >> 24) & 0xFF);
byte sg = (byte)((startArgb >> 8) & 0xFF);
byte sb = (byte)(startArgb & 0xFF);
byte er = (byte)((endArgb >> 16) & 0xFF);
byte eg = (byte)((endArgb >> 8) & 0xFF);
byte eb = (byte)( endArgb & 0xFF);
byte eg = (byte)((endArgb >> 8) & 0xFF);
byte eb = (byte)(endArgb & 0xFF);
byte r = (byte)Math.Clamp(sr + (er - sr) * t, 0f, 255f);
byte g = (byte)Math.Clamp(sg + (eg - sg) * t, 0f, 255f);
byte b = (byte)Math.Clamp(sb + (eb - sb) * t, 0f, 255f);
byte a = (byte)Math.Clamp(alpha * 255f, 0f, 255f);
return ((uint)a << 24) | ((uint)r << 16) | ((uint)g << 8) | b;
}
private Vector3 RandomDiskVector(Vector3 axis, float maxRadius)
{
if (maxRadius <= 0f) return Vector3.Zero;
// Two perpendicular vectors to axis.
Vector3 n = Vector3.Normalize(axis == Vector3.Zero ? Vector3.UnitZ : axis);
Vector3 t1 = Math.Abs(n.X) < 0.9f
? Vector3.Normalize(Vector3.Cross(n, Vector3.UnitX))
: Vector3.Normalize(Vector3.Cross(n, Vector3.UnitY));
Vector3 t2 = Vector3.Normalize(Vector3.Cross(n, t1));
float theta = (float)(_rng.NextDouble() * Math.PI * 2.0);
float r = maxRadius * MathF.Sqrt((float)_rng.NextDouble());
return (t1 * MathF.Cos(theta) + t2 * MathF.Sin(theta)) * r;
}
private float RandomCentered(float halfWidth)
{
return ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth;
}
}

View file

@ -139,15 +139,7 @@ public sealed class PhysicsScriptRunner
_active.RemoveAt(i);
}
_active.Add(new ActiveScript
{
Script = script,
ScriptId = scriptId,
EntityId = entityId,
AnchorWorld = anchorWorldPos,
StartTimeAbs = _now,
NextHookIndex = 0,
});
AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0);
if (DiagEnabled)
{
@ -159,6 +151,24 @@ public sealed class PhysicsScriptRunner
return true;
}
private void AddActiveScript(
DatPhysicsScript script,
uint scriptId,
uint entityId,
Vector3 anchorWorldPos,
float delaySeconds)
{
_active.Add(new ActiveScript
{
Script = script,
ScriptId = scriptId,
EntityId = entityId,
AnchorWorld = anchorWorldPos,
StartTimeAbs = _now + Math.Max(0f, delaySeconds),
NextHookIndex = 0,
});
}
/// <summary>
/// Advance every active script by <paramref name="dtSeconds"/>.
/// Fires each hook whose <see cref="PhysicsScriptData.StartTime"/>
@ -233,18 +243,18 @@ public sealed class PhysicsScriptRunner
if (hook is CallPESHook call)
{
// CallPESHook.PES = sub-script id; Pause = delay before the
// sub-script starts (retail's ScriptManager links it into
// the list with StartTime = now + Pause). For our flat-list
// design we just recurse Play() — the sub-script schedules
// its own hooks from its own time zero. If Pause > 0 we
// delay by baking it into the sub-script's StartTimeAbs.
Play(call.PES, a.EntityId, a.AnchorWorld);
if (call.Pause > 0f && _active.Count > 0)
// sub-script starts. Retail links it into the active script
// list with StartTime = now + Pause; our flat list preserves
// that timing without replacing the currently running script.
var subScript = ResolveScript(call.PES);
if (subScript is null || subScript.ScriptData.Count == 0)
{
var sub = _active[^1];
sub.StartTimeAbs = _now + call.Pause;
_active[^1] = sub;
if (DiagEnabled)
Console.WriteLine($"[pes] CallPES: script 0x{call.PES:X8} not found / empty");
return;
}
AddActiveScript(subScript, call.PES, a.EntityId, a.AnchorWorld, call.Pause);
return;
}

View file

@ -4,90 +4,123 @@ using System.Numerics;
namespace AcDream.Core.Vfx;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R4 — VFX / particle system data model.
// Full research: docs/research/deepdives/r04-vfx-particles.md
// Runtime GPU batching lives in AcDream.App/Rendering/Vfx (Silk.NET GL).
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 13 retail particle motion integrators. See r04 §1.
/// Parabolic variants apply gravity with different orientation/decay rules.
/// Retail particle motion integrators from <c>ParticleType</c> in
/// <c>acclient.h</c>. Values are the retail dat values.
/// </summary>
public enum ParticleType
{
Still = 0, // static, fades out in place
LocalVelocity = 1, // moves at its spawn velocity
Parabolic = 2, // gravity arc
ParabolicLVGV = 3, // local+global velocity parabolic
ParabolicLVGA = 4,
ParabolicLVLA = 5,
ParabolicGVGA = 6,
ParabolicGVLA = 7,
ParabolicLALV = 8,
Swarm = 9, // orbits spawn point with randomness
Explode = 10, // all particles push outward
Implode = 11, // all particles pull inward
GlobalVelocity = 12,
Unknown = 0,
Still = 1,
LocalVelocity = 2,
ParabolicLVGA = 3,
ParabolicLVGAGR = 4,
Swarm = 5,
Explode = 6,
Implode = 7,
ParabolicLVLA = 8,
ParabolicLVLALR = 9,
ParabolicGVGA = 10,
ParabolicGVGAGR = 11,
GlobalVelocity = 12,
NumParticleType = 13,
}
/// <summary>
/// Retail <c>EmitterType</c> from <c>acclient.h</c>.
/// </summary>
public enum ParticleEmitterKind
{
Unknown = 0,
BirthratePerSec = 1,
BirthratePerMeter = 2,
}
/// <summary>
/// Render stage for an active particle emitter.
/// </summary>
public enum ParticleRenderPass
{
Scene = 0,
SkyPreScene = 1,
SkyPostScene = 2,
}
[Flags]
public enum EmitterFlags : uint
{
None = 0,
Additive = 0x01, // blend mode: SrcAlpha / One (vs default SrcAlpha / InvSrcAlpha)
Billboard = 0x02,
None = 0,
Additive = 0x01,
Billboard = 0x02,
FaceCamera = 0x04,
AttachLocal= 0x08, // particles follow parent anchor frame
AttachLocal = 0x08,
}
/// <summary>
/// Per-emitter configuration from the <c>ParticleEmitterInfo</c> dat.
/// See r04 §1 + DatReaderWriter.ParticleEmitterInfo.
/// Per-emitter configuration from the retail <c>ParticleEmitterInfo</c>
/// dat object.
/// </summary>
public sealed class EmitterDesc
{
public uint DatId { get; init; }
public ParticleType Type { get; init; }
public EmitterFlags Flags { get; init; }
public uint TextureSurfaceId { get; init; } // 0x06xxxxxx
public uint SoundOnSpawn { get; init; }
public uint DatId { get; init; }
public ParticleType Type { get; init; }
public ParticleEmitterKind EmitterKind { get; init; } = ParticleEmitterKind.BirthratePerSec;
public EmitterFlags Flags { get; init; }
public uint TextureSurfaceId { get; init; }
public uint GfxObjId { get; init; }
public uint HwGfxObjId { get; init; }
public uint SoundOnSpawn { get; init; }
// Emission behavior
public float EmitRate { get; init; } // particles / sec
public int MaxParticles { get; init; }
public float LifetimeMin { get; init; }
public float LifetimeMax { get; init; }
public float StartDelay { get; init; }
public float TotalDuration { get; init; } // 0 = infinite
// Emission behavior.
public float Birthrate { get; init; }
public float EmitRate { get; init; }
public int MaxParticles { get; init; }
public int InitialParticles { get; init; }
public int TotalParticles { get; init; }
public float LifetimeMin { get; init; }
public float LifetimeMax { get; init; }
public float Lifespan { get; init; }
public float LifespanRand { get; init; }
public float StartDelay { get; init; }
public float TotalDuration { get; init; }
// Spawn geometry (disk annulus perpendicular to OffsetDir)
public Vector3 OffsetDir { get; init; } = new(0, 0, 1);
public float MinOffset { get; init; }
public float MaxOffset { get; init; }
public float SpawnDiskRadius { get; init; }
// Spawn geometry.
public Vector3 OffsetDir { get; init; } = new(0, 0, 1);
public float MinOffset { get; init; }
public float MaxOffset { get; init; }
public float SpawnDiskRadius { get; init; }
// Initial kinematics
public Vector3 InitialVelocity { get; init; }
public float VelocityJitter { get; init; }
public Vector3 Gravity { get; init; } = new(0, 0, -9.8f);
// Kinematics. A/B/C are the retail vector coefficients.
public Vector3 InitialVelocity { get; init; }
public float VelocityJitter { get; init; }
public Vector3 Gravity { get; init; } = new(0, 0, -9.8f);
public Vector3 A { get; init; }
public float MinA { get; init; } = 1f;
public float MaxA { get; init; } = 1f;
public Vector3 B { get; init; }
public float MinB { get; init; } = 1f;
public float MaxB { get; init; } = 1f;
public Vector3 C { get; init; }
public float MinC { get; init; } = 1f;
public float MaxC { get; init; } = 1f;
// Appearance over lifetime (retail: start + end, linearly interpolated)
public uint StartColorArgb { get; init; } = 0xFFFFFFFF;
public uint EndColorArgb { get; init; } = 0xFFFFFFFF;
public float StartAlpha { get; init; } = 1f;
public float EndAlpha { get; init; } = 0f;
public float StartSize { get; init; } = 0.5f;
public float EndSize { get; init; } = 0.5f;
public float StartRotation { get; init; }
public float EndRotation { get; init; }
// Appearance over lifetime.
public uint StartColorArgb { get; init; } = 0xFFFFFFFF;
public uint EndColorArgb { get; init; } = 0xFFFFFFFF;
public float StartAlpha { get; init; } = 1f;
public float EndAlpha { get; init; } = 0f;
public float StartSize { get; init; } = 0.5f;
public float EndSize { get; init; } = 0.5f;
public float ScaleRand { get; init; }
public float TransRand { get; init; }
public float StartRotation { get; init; }
public float EndRotation { get; init; }
}
/// <summary>
/// A PhysicsScript (0x3Axxxxxx range in retail) is a list of hooks to
/// fire at specific start-times. Each hook creates an emitter or plays
/// a sound. Chaining hooks at different times gives "animation".
/// See r04 §6.
/// </summary>
public sealed class PhysicsScript
{
@ -98,34 +131,43 @@ public sealed class PhysicsScript
public sealed record PhysicsScriptHook(
float StartTime,
PhysicsScriptHookType Type,
uint RefDataId, // EmitterInfo / Sound / PartTransform
int PartIndex, // attach to this part
uint RefDataId,
int PartIndex,
Vector3 Offset,
bool IsParentLocal);
public enum PhysicsScriptHookType
{
CreateParticle = 18, // matches retail animation-hook type
DestroyParticle= 19,
PlaySound = 1,
AnimationDone = 2,
CreateParticle = 18,
DestroyParticle = 19,
PlaySound = 1,
AnimationDone = 2,
}
/// <summary>
/// Individual runtime particle. Owned by the <c>ParticleSystem</c>;
/// advanced per-frame.
/// Individual runtime particle. Owned by the <c>ParticleSystem</c>.
/// </summary>
public struct Particle
{
public Vector3 Position;
public Vector3 Velocity;
public float SpawnedAt;
public float Lifetime; // seconds
public float Age;
public uint ColorArgb; // current
public float Size;
public float Rotation;
public bool Alive;
public Vector3 EmissionOrigin;
public Quaternion SpawnRotation;
public Vector3 Position;
public Vector3 Velocity;
public Vector3 Offset;
public Vector3 A;
public Vector3 B;
public Vector3 C;
public float SpawnedAt;
public float Lifetime;
public float Age;
public float StartSize;
public float EndSize;
public float StartAlpha;
public float EndAlpha;
public uint ColorArgb;
public float Size;
public float Rotation;
public bool Alive;
}
/// <summary>
@ -134,16 +176,20 @@ public struct Particle
/// </summary>
public sealed class ParticleEmitter
{
public EmitterDesc Desc { get; init; } = null!;
public Vector3 AnchorPos { get; set; }
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
public uint AttachedObjectId { get; set; } // 0 = world-space only
public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!;
public int ActiveCount;
public float EmittedAccumulator; // fractional particles pending
public float StartedAt; // game-time seconds
public bool Finished;
public EmitterDesc Desc { get; init; } = null!;
public Vector3 AnchorPos { get; set; }
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
public uint AttachedObjectId { get; set; }
public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!;
public ParticleRenderPass RenderPass { get; init; }
public int ActiveCount;
public float EmittedAccumulator;
public float StartedAt;
public float LastEmitTime;
public Vector3 LastEmitOffset;
public int TotalEmitted;
public bool Finished;
}
/// <summary>
@ -151,20 +197,25 @@ public sealed class ParticleEmitter
/// </summary>
public interface IParticleSystem
{
/// <summary>Spawn an emitter attached to a world position (or entity).</summary>
int SpawnEmitter(EmitterDesc desc, Vector3 anchor, Quaternion? rot = null,
uint attachedObjectId = 0, int attachedPartIndex = -1);
/// <summary>Spawn an emitter attached to a world position or entity.</summary>
int SpawnEmitter(
EmitterDesc desc,
Vector3 anchor,
Quaternion? rot = null,
uint attachedObjectId = 0,
int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene);
/// <summary>Fire a full PhysicsScript at a target (the retail PlayScript dispatch).</summary>
/// <summary>Fire a full PhysicsScript at a target.</summary>
void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f);
/// <summary>Advance all active emitters by dt seconds.</summary>
void Tick(float dt);
/// <summary>Stop an emitter early (e.g. cast interrupted).</summary>
/// <summary>Stop an emitter early.</summary>
void StopEmitter(int handle, bool fadeOut);
/// <summary>Current active particle count (for HUD stats).</summary>
/// <summary>Current active particle count.</summary>
int ActiveParticleCount { get; }
int ActiveEmitterCount { get; }
int ActiveEmitterCount { get; }
}

View file

@ -34,6 +34,7 @@ public sealed class SkyObjectData
public float TexVelocityX;
public float TexVelocityY;
public uint GfxObjId;
public uint PesObjectId;
public uint Properties;
/// <summary>
@ -531,6 +532,7 @@ public static class SkyDescLoader
TexVelocityX = s.TexVelocityX,
TexVelocityY = s.TexVelocityY,
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u,
Properties = s.Properties,
};

View file

@ -241,28 +241,32 @@ public sealed class GameEventWiringTests
}
[Fact]
public void WireAll_KillerNotification_FiresKillLandedOnCombatState()
public void WireAll_KillerNotification_AppendsCombatLine()
{
// Issue #10 — orphan parser at GameEvents.ParseKillerNotification
// existed but was never registered for dispatch until 2026-04-25.
// Now wired: 0x01AD lands on CombatState.OnKillerNotification +
// fires the KillLanded event.
var (d, _, combat, _, _) = MakeAll();
string? gotVictimName = null;
uint gotVictimGuid = 0;
combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; };
// Wire shape: string16L victimName + u32 victimGuid
byte[] nameBytes = MakeString16L("Drudge");
byte[] payload = new byte[nameBytes.Length + 4];
Array.Copy(nameBytes, payload, nameBytes.Length);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u);
var (d, _, _, _, chat) = MakeAll();
byte[] payload = MakeString16L("You killed the drudge!");
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
d.Dispatch(env!.Value);
Assert.Equal("Drudge", gotVictimName);
Assert.Equal(0x80001234u, gotVictimGuid);
Assert.Equal(1, chat.Count);
var entry = chat.Snapshot()[0];
Assert.Equal(ChatKind.Combat, entry.Kind);
Assert.Equal(CombatLineKind.Info, entry.CombatKind);
Assert.Equal("You killed the drudge!", entry.Text);
}
[Fact]
public void WireAll_CombatCommenceAttack_FiresCombatStateEvent()
{
var (d, _, combat, _, _) = MakeAll();
bool commenced = false;
combat.AttackCommenced += () => commenced = true;
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty<byte>()));
d.Dispatch(env!.Value);
Assert.True(commenced);
}
[Fact]

View file

@ -57,4 +57,13 @@ public sealed class CharacterActionsTests
Assert.Equal(2u, // Melee = 2
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
}
[Fact]
public void CombatMode_UsesRetailAceBitValues()
{
Assert.Equal(1u, (uint)CharacterActions.CombatMode.NonCombat);
Assert.Equal(2u, (uint)CharacterActions.CombatMode.Melee);
Assert.Equal(4u, (uint)CharacterActions.CombatMode.Missile);
Assert.Equal(8u, (uint)CharacterActions.CombatMode.Magic);
}
}

View file

@ -1,6 +1,5 @@
using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
using Xunit;
@ -8,105 +7,140 @@ namespace AcDream.Core.Net.Tests.Messages;
public sealed class CombatEventTests
{
private static byte[] MakeString16L(string s)
{
byte[] data = Encoding.ASCII.GetBytes(s);
int recordSize = 2 + data.Length;
int padding = (4 - (recordSize & 3)) & 3;
byte[] result = new byte[recordSize + padding];
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
Array.Copy(data, 0, result, 2, data.Length);
return result;
}
[Fact]
public void AttackTargetRequest_Build_EmitsCorrectWireBytes()
public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes()
{
byte[] body = AttackTargetRequest.Build(
byte[] body = AttackTargetRequest.BuildMelee(
gameActionSequence: 3,
targetGuid: 0x12345678u,
powerLevel: 0.75f,
accuracyLevel: 0.5f,
attackHeight: 2);
attackHeight: 2,
powerLevel: 0.75f);
Assert.Equal(28, body.Length);
Assert.Equal(24, body.Length);
Assert.Equal(AttackTargetRequest.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(3u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(AttackTargetRequest.SubOpcode,
Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x12345678u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.75f,
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4);
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
}
[Fact]
public void AttackTargetRequest_BuildMissile_EmitsRetailWireBytes()
{
byte[] body = AttackTargetRequest.BuildMissile(
gameActionSequence: 4,
targetGuid: 0x87654321u,
attackHeight: 1,
accuracyLevel: 0.5f);
Assert.Equal(24, body.Length);
Assert.Equal(AttackTargetRequest.TargetedMissileAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x87654321u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.5f,
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
}
[Fact]
public void ParseVictimNotification_RoundTrip()
public void AttackTargetRequest_BuildCancel_HasNoPayload()
{
byte[] name = MakeString16L("Attacker");
byte[] tail = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu); // guid
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 1u); // damageType
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 42u); // damage
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(12), 3u); // quadrant
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(16), 1u); // crit
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(20), 8u); // attackType
byte[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5);
byte[] payload = new byte[name.Length + tail.Length];
Buffer.BlockCopy(name, 0, payload, 0, name.Length);
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
Assert.Equal(12, body.Length);
Assert.Equal(AttackTargetRequest.CancelAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
}
[Fact]
public void ParseAttackDone_HoltburgerFixture()
{
var env = ParseFixture("B0F700000000000000000000A701000036000000");
Assert.Equal(GameEventType.AttackDone, env.EventType);
var parsed = GameEvents.ParseAttackDone(env.Payload.Span);
var parsed = GameEvents.ParseVictimNotification(payload);
Assert.NotNull(parsed);
Assert.Equal("Attacker", parsed!.Value.AttackerName);
Assert.Equal(0xAAu, parsed.Value.AttackerGuid);
Assert.Equal(42u, parsed.Value.Damage);
Assert.Equal(0u, parsed!.Value.AttackSequence);
Assert.Equal(0x36u, parsed.Value.WeenieError);
}
[Fact]
public void ParseAttackerNotification_HoltburgerFixture()
{
var env = ParseFixture("B0F700000000000001000000B10100000E0044727564676520526176656E657201000000000000000000D03F25000000010000000600000000000000");
var parsed = GameEvents.ParseAttackerNotification(env.Payload.Span);
Assert.NotNull(parsed);
Assert.Equal("Drudge Ravener", parsed!.Value.DefenderName);
Assert.Equal(1u, parsed.Value.DamageType);
Assert.Equal(0.25, parsed.Value.HealthPercent, 6);
Assert.Equal(37u, parsed.Value.Damage);
Assert.Equal(1u, parsed.Value.Critical);
Assert.Equal(6ul, parsed.Value.AttackConditions);
}
[Fact]
public void ParseAttackerNotification_RoundTrip()
public void ParseDefenderNotification_HoltburgerFixture()
{
byte[] name = MakeString16L("Drudge");
byte[] tail = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(tail, 1u); // damageType
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 30u); // damage
BinaryPrimitives.WriteSingleLittleEndian(tail.AsSpan(8), 0.15f); // percent
var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000");
byte[] payload = new byte[name.Length + tail.Length];
Buffer.BlockCopy(name, 0, payload, 0, name.Length);
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
var parsed = GameEvents.ParseDefenderNotification(env.Payload.Span);
var parsed = GameEvents.ParseAttackerNotification(payload);
Assert.NotNull(parsed);
Assert.Equal("Drudge", parsed!.Value.DefenderName);
Assert.Equal(30u, parsed.Value.Damage);
Assert.Equal(0.15f, parsed.Value.DamagePercent, 4);
Assert.Equal("Banderling", parsed!.Value.AttackerName);
Assert.Equal(0x10u, parsed.Value.DamageType);
Assert.Equal(0.125, parsed.Value.HealthPercent, 6);
Assert.Equal(18u, parsed.Value.Damage);
Assert.Equal(1u, parsed.Value.HitQuadrant);
Assert.Equal(0u, parsed.Value.Critical);
Assert.Equal(8ul, parsed.Value.AttackConditions);
}
[Fact]
public void ParseEvasionAttackerNotification_RoundTrip()
public void ParseEvasionNotifications_HoltburgerFixtures()
{
byte[] payload = MakeString16L("Thrower");
Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload));
var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000");
var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000");
Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span));
Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span));
}
[Fact]
public void ParseAttackDone_RoundTrip()
public void ParseCombatCommenceAttack_HoltburgerFixture()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error
var env = ParseFixture("B0F700000000000005000000B8010000");
var parsed = GameEvents.ParseAttackDone(payload);
Assert.NotNull(parsed);
Assert.Equal(42u, parsed!.Value.AttackSequence);
Assert.Equal(0u, parsed.Value.WeenieError);
Assert.Equal(GameEventType.CombatCommenceAttack, env.EventType);
Assert.True(GameEvents.ParseCombatCommenceAttack(env.Payload.Span));
}
[Fact]
public void ParseDeathNotifications_HoltburgerFixtures()
{
var victim = ParseFixture("B0F700000000000006000000AC0100000E00596F752068617665206469656421");
var killer = ParseFixture("B0F700000000000007000000AD0100001600596F75206B696C6C6564207468652064727564676521");
Assert.Equal("You have died!", GameEvents.ParseVictimNotification(victim.Payload.Span)?.DeathMessage);
Assert.Equal("You killed the drudge!", GameEvents.ParseKillerNotification(killer.Payload.Span)?.DeathMessage);
}
private static GameEventEnvelope ParseFixture(string hex)
{
byte[] body = Convert.FromHexString(hex);
var env = GameEventEnvelope.TryParse(body);
Assert.NotNull(env);
return env.Value;
}
}

View file

@ -0,0 +1,99 @@
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class CreateObjectTests
{
[Fact]
public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000002u,
name: "Drudge",
itemType: (uint)ItemType.Creature);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x50000002u, parsed.Value.Guid);
Assert.Equal("Drudge", parsed.Value.Name);
Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
uint itemType)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
WriteU32(bytes, guid);
// ModelData header: marker, subpalette count, texture count, animpart count.
bytes.Add(0x11);
bytes.Add(0);
bytes.Add(0);
bytes.Add(0);
// PhysicsData: no flags, empty physics state, then 9 sequence stamps.
WriteU32(bytes, 0);
WriteU32(bytes, 0);
for (int i = 0; i < 9; i++)
WriteU16(bytes, 0);
Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
WriteU32(bytes, 0); // weenieFlags
WriteString16L(bytes, name);
WritePackedDword(bytes, 0x1234); // WeenieClassId
WritePackedDword(bytes, 0); // IconId via known-type writer
WriteU32(bytes, itemType);
WriteU32(bytes, 0); // ObjectDescriptionFlags
Align4(bytes);
return bytes.ToArray();
}
private static void WriteU32(List<byte> bytes, uint value)
{
Span<byte> tmp = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WriteU16(List<byte> bytes, ushort value)
{
Span<byte> tmp = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WritePackedDword(List<byte> bytes, uint value)
{
if (value <= 0x7FFF)
{
WriteU16(bytes, (ushort)value);
return;
}
WriteU16(bytes, (ushort)(((value >> 16) & 0x7FFF) | 0x8000));
WriteU16(bytes, (ushort)(value & 0xFFFF));
}
private static void WriteString16L(List<byte> bytes, string value)
{
byte[] encoded = Encoding.GetEncoding(1252).GetBytes(value);
WriteU16(bytes, checked((ushort)encoded.Length));
bytes.AddRange(encoded);
Align4(bytes);
}
private static void Align4(List<byte> bytes)
{
while ((bytes.Count & 3) != 0)
bytes.Add(0);
}
}

View file

@ -0,0 +1,39 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class DeleteObjectTests
{
[Fact]
public void RejectsWrongOpcode()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(DeleteObject.TryParse(body));
}
[Fact]
public void RejectsTruncated()
{
Assert.Null(DeleteObject.TryParse(ReadOnlySpan<byte>.Empty));
Assert.Null(DeleteObject.TryParse(new byte[9]));
}
[Fact]
public void ParsesGuidAndInstanceSequence()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000439u);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x1234);
var parsed = DeleteObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x80000439u, parsed!.Value.Guid);
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
}
}

View file

@ -185,7 +185,8 @@ public class UpdateMotionTests
[Fact]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
{
// movementType != 0 means one of the Move* variants we don't parse.
// movementType != 0 means one of the Move* variants; a truncated
// non-Invalid payload still returns the outer state.
// The parser must still return a valid Parsed with the outer stance
// and a null ForwardCommand rather than failing the whole message.
var body = new byte[4 + 4 + 2 + 6 + 4];
@ -194,7 +195,7 @@ public class UpdateMotionTests
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 1; // movementType = MoveToObject (non-Invalid)
body[p++] = 7; // movementType = MoveToPosition (non-Invalid)
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
@ -202,5 +203,152 @@ public class UpdateMotionTests
Assert.NotNull(result);
Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance);
Assert.Null(result.Value.MotionState.ForwardCommand);
Assert.Equal((byte)7, result.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
}
[Fact]
public void ParsesMoveToPositionSpeedAndRunRate()
{
// Layout after MovementData's movementType/motionFlags/currentStyle:
// Origin: cell + xyz (16 bytes)
// MoveToParameters: flags, distance, min, fail, speed,
// walk/run threshold, desired heading (28 bytes)
// runRate: f32
var body = new byte[4 + 4 + 2 + 6 + 4 + 16 + 28 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 7; // MoveToPosition
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 10f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 20f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 30f); p += 4;
const uint canWalkCanRunMoveTowards = 0x1u | 0x2u | 0x200u;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), canWalkCanRunMoveTowards); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 90.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4;
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)7, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
Assert.Equal((ushort)0x003D, result.Value.MotionState.Stance);
Assert.Null(result.Value.MotionState.ForwardCommand);
Assert.Equal(canWalkCanRunMoveTowards, result.Value.MotionState.MoveToParameters);
Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
Assert.True(result.Value.MotionState.MoveToCanRun);
Assert.True(result.Value.MotionState.MoveTowards);
// Phase L.1c (2026-04-28): full path payload retained.
Assert.NotNull(result.Value.MotionState.MoveToPath);
var path = result.Value.MotionState.MoveToPath!.Value;
Assert.Null(path.TargetGuid);
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
Assert.Equal(10f, path.OriginX);
Assert.Equal(20f, path.OriginY);
Assert.Equal(30f, path.OriginZ);
Assert.Equal(0.6f, path.DistanceToObject);
Assert.Equal(0.0f, path.MinDistance);
Assert.Equal(float.MaxValue, path.FailDistance);
Assert.Equal(15.0f, path.WalkRunThreshold);
Assert.Equal(90.0f, path.DesiredHeading);
}
[Fact]
public void ParsesAttackHigh1_AsActionForwardCommand()
{
// Phase L.1c followup (2026-04-28): regression that verifies the
// wire-format ACE uses for melee swings — mt=0 with
// ForwardCommand=AttackHigh1 (0x0062 in low 16 bits) and
// ForwardSpeed (typically the animSpeed). The receiver in
// GameWindow.OnLiveMotionUpdated relies on this layout to bulk-copy
// ForwardCommand into the body's InterpretedState so that
// get_state_velocity returns 0 (gate is RunForward||WalkForward).
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x800003B5u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // header padding
body[p++] = 0; // mt = Invalid (interpreted)
body[p++] = 0; // motion_flags
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003C); p += 2; // stance: HandCombat
// InterpretedMotionState: flags = ForwardCommand (0x02) | ForwardSpeed (0x04)
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x06u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0062); p += 2; // AttackHigh1 low bits
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // animSpeed
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)0, result!.Value.MotionState.MovementType);
Assert.False(result.Value.MotionState.IsServerControlledMoveTo);
Assert.Equal((ushort)0x0062, result.Value.MotionState.ForwardCommand);
Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed);
}
[Fact]
public void ParsesMoveToObjectTargetGuidAndOrigin()
{
// Type 6 (MoveToObject) prepends a u32 target guid before the
// standard Origin + MovementParameters + runRate payload.
// Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72.
var body = new byte[20 + 4 + 16 + 28 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // MovementData header padding
body[p++] = 6; // MoveToObject
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z
const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)6, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
Assert.NotNull(result.Value.MotionState.MoveToPath);
var path = result.Value.MotionState.MoveToPath!.Value;
Assert.Equal(0x80001234u, path.TargetGuid);
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
Assert.Equal(5f, path.OriginX);
Assert.Equal(6f, path.OriginY);
Assert.Equal(7f, path.OriginZ);
Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
}
}

View file

@ -0,0 +1,77 @@
using System.Net;
using AcDream.Core.Combat;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionCombatTests
{
private static WorldSession NewSession()
{
var ep = new IPEndPoint(IPAddress.Loopback, 65000);
return new WorldSession(ep);
}
[Fact]
public void SendChangeCombatMode_UsesSequenceAndRetailModeValue()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendChangeCombatMode(CombatMode.Magic);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildChangeCombatMode(
1,
CharacterActions.CombatMode.Magic), captured);
}
[Fact]
public void SendMeleeAttack_UsesRetailMeleeBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendMeleeAttack(0x50000002u, AttackHeight.High, 0.75f);
Assert.NotNull(captured);
Assert.Equal(AttackTargetRequest.BuildMelee(
1,
0x50000002u,
(uint)AttackHeight.High,
0.75f), captured);
}
[Fact]
public void SendMissileAttack_UsesRetailMissileBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendMissileAttack(0x50000003u, AttackHeight.Low, 0.5f);
Assert.NotNull(captured);
Assert.Equal(AttackTargetRequest.BuildMissile(
1,
0x50000003u,
(uint)AttackHeight.Low,
0.5f), captured);
}
[Fact]
public void SendCancelAttack_UsesRetailCancelBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendCancelAttack();
Assert.NotNull(captured);
Assert.Equal(AttackTargetRequest.BuildCancel(1), captured);
}
}

View file

@ -0,0 +1,89 @@
using AcDream.Core.Combat;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Combat;
public sealed class CombatAnimationPlannerTests
{
[Theory]
[InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed
[InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh
[InlineData(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed
[InlineData(0x1000018Eu, CombatAnimationKind.MeleeSwing)] // PunchFastLow
[InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot
[InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload
[InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1
[InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow6
[InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell
[InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff
[InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1
[InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward
[InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead
[InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat
[InlineData(0x80000043u, CombatAnimationKind.CombatStance)] // SlingCombat
[InlineData(0x80000044u, CombatAnimationKind.CombatStance)] // 2HandedSwordCombat
public void ClassifyMotionCommand_RecognisesRetailCombatCommands(
uint command,
CombatAnimationKind expected)
{
Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command));
}
[Theory]
[InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh
[InlineData(0x017D, 0x1000017Du)] // OffhandDoubleThrustMed
[InlineData(0x018B, 0x1000018Bu)] // AttackLow6
[InlineData(0x018E, 0x1000018Eu)] // PunchFastLow
public void MotionCommandResolver_UsesNamedRetailLateCombatCommands(
ushort wireCommand,
uint expectedFullCommand)
{
Assert.Equal(expectedFullCommand, MotionCommandResolver.ReconstructFullCommand(wireCommand));
}
[Fact]
public void PlanFromWireCommand_Swing_IsActionOverlay()
{
var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0058, speedMod: 1.25f);
Assert.Equal(CombatAnimationKind.MeleeSwing, plan.Kind);
Assert.Equal(AnimationCommandRouteKind.Action, plan.RouteKind);
Assert.Equal(0x10000058u, plan.MotionCommand);
Assert.Equal(1.25f, plan.SpeedMod);
Assert.True(plan.HasMotion);
}
[Fact]
public void PlanFromWireCommand_Dead_IsPersistentSubState()
{
var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0011);
Assert.Equal(CombatAnimationKind.Death, plan.Kind);
Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind);
Assert.Equal(MotionCommand.Dead, plan.MotionCommand);
}
[Fact]
public void PlanFromWireCommand_Unknown_IsNone()
{
var plan = CombatAnimationPlanner.PlanFromWireCommand(0xFFFF);
Assert.Equal(CombatAnimationPlan.None, plan);
Assert.False(plan.HasMotion);
}
[Theory]
[InlineData(CombatAnimationEvent.CombatCommenceAttack)]
[InlineData(CombatAnimationEvent.AttackDone)]
[InlineData(CombatAnimationEvent.AttackerNotification)]
[InlineData(CombatAnimationEvent.DefenderNotification)]
[InlineData(CombatAnimationEvent.EvasionAttackerNotification)]
[InlineData(CombatAnimationEvent.EvasionDefenderNotification)]
[InlineData(CombatAnimationEvent.VictimNotification)]
[InlineData(CombatAnimationEvent.KillerNotification)]
public void PlanForEvent_DoesNotInventAnimations(CombatAnimationEvent combatEvent)
{
Assert.Equal(CombatAnimationPlan.None, CombatAnimationPlanner.PlanForEvent(combatEvent));
}
}

View file

@ -0,0 +1,43 @@
using AcDream.Core.Combat;
namespace AcDream.Core.Tests.Combat;
public sealed class CombatInputPlannerTests
{
[Fact]
public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
{
Assert.Equal(CombatMode.Melee, CombatInputPlanner.ToggleMode(CombatMode.NonCombat));
Assert.Equal(
CombatMode.Missile,
CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile));
}
[Fact]
public void ToggleMode_FromCombat_ReturnsNonCombat()
{
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee));
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic));
}
[Theory]
[InlineData(CombatAttackAction.Low, AttackHeight.Low)]
[InlineData(CombatAttackAction.Medium, AttackHeight.Medium)]
[InlineData(CombatAttackAction.High, AttackHeight.High)]
public void HeightFor_MapsRetailAttackKeys(CombatAttackAction action, AttackHeight expected)
{
Assert.Equal(expected, CombatInputPlanner.HeightFor(action));
}
[Theory]
[InlineData(CombatMode.Melee, true)]
[InlineData(CombatMode.Missile, true)]
[InlineData(CombatMode.NonCombat, false)]
[InlineData(CombatMode.Magic, false)]
public void SupportsTargetedAttack_MatchesRetailExecuteAttackModes(
CombatMode mode,
bool expected)
{
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
}
}

View file

@ -0,0 +1,155 @@
using AcDream.Core.Combat;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DatAttackHeight = DatReaderWriter.Enums.AttackHeight;
using DatAttackType = DatReaderWriter.Enums.AttackType;
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
using DatMotionStance = DatReaderWriter.Enums.MotionStance;
using Xunit;
namespace AcDream.Core.Tests.Combat;
public sealed class CombatManeuverSelectorTests
{
[Fact]
public void SelectMotion_UsesFirstEntryAtOrAboveSubdivision()
{
var table = MakeTable(
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
DatAttackType.Slash, DatMotionCommand.SlashMed),
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
DatAttackType.Slash, DatMotionCommand.BackhandMed));
var atThreshold = CombatManeuverSelector.SelectMotion(
table,
DatMotionStance.SwordCombat,
DatAttackHeight.Medium,
DatAttackType.Slash,
powerLevel: CombatManeuverSelector.DefaultSubdivision);
var highPower = CombatManeuverSelector.SelectMotion(
table,
DatMotionStance.SwordCombat,
DatAttackHeight.Medium,
DatAttackType.Slash,
powerLevel: 1f);
Assert.Equal(DatMotionCommand.SlashMed, atThreshold.Motion);
Assert.Equal(DatMotionCommand.SlashMed, highPower.Motion);
}
[Fact]
public void SelectMotion_UsesSecondEntryBelowSubdivision()
{
var table = MakeTable(
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
DatAttackType.Slash, DatMotionCommand.SlashMed),
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
DatAttackType.Slash, DatMotionCommand.BackhandMed));
var selection = CombatManeuverSelector.SelectMotion(
table,
DatMotionStance.SwordCombat,
DatAttackHeight.Medium,
DatAttackType.Slash,
powerLevel: 0.2f);
Assert.True(selection.Found);
Assert.Equal(DatMotionCommand.BackhandMed, selection.Motion);
Assert.Equal(DatAttackType.Slash, selection.EffectiveAttackType);
Assert.Equal(2, selection.Candidates.Count);
}
[Fact]
public void SelectMotion_ThrustSlashWeaponUsesTwoThirdsSubdivision()
{
var table = MakeTable(
Entry(DatMotionStance.SwordCombat, DatAttackHeight.High,
DatAttackType.Slash, DatMotionCommand.SlashHigh),
Entry(DatMotionStance.SwordCombat, DatAttackHeight.High,
DatAttackType.Slash, DatMotionCommand.BackhandHigh));
var normal = CombatManeuverSelector.SelectMotion(
table,
DatMotionStance.SwordCombat,
DatAttackHeight.High,
DatAttackType.Slash,
powerLevel: 0.5f);
var thrustSlash = CombatManeuverSelector.SelectMotion(
table,
DatMotionStance.SwordCombat,
DatAttackHeight.High,
DatAttackType.Slash,
powerLevel: 0.5f,
isThrustSlashWeapon: true);
Assert.Equal(DatMotionCommand.SlashHigh, normal.Motion);
Assert.Equal(DatMotionCommand.BackhandHigh, thrustSlash.Motion);
Assert.Equal(CombatManeuverSelector.ThrustSlashSubdivision, thrustSlash.Subdivision);
}
[Fact]
public void SelectMotion_MissingLookupReturnsNone()
{
var table = MakeTable(
Entry(DatMotionStance.BowCombat, DatAttackHeight.High,
DatAttackType.Punch, DatMotionCommand.Shoot));
var selection = CombatManeuverSelector.SelectMotion(
table,
DatMotionStance.SwordCombat,
DatAttackHeight.High,
DatAttackType.Punch,
powerLevel: 0.5f);
Assert.Equal(CombatManeuverSelection.None, selection);
}
[Fact]
public void FindMotions_PreservesRetailTableOrder()
{
var table = MakeTable(
Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
DatAttackType.Kick, DatMotionCommand.AttackLow1),
Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
DatAttackType.Kick, (DatMotionCommand)0x1000018Eu),
Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
DatAttackType.Punch, DatMotionCommand.AttackLow2));
var motions = CombatManeuverSelector.FindMotions(
table,
DatMotionStance.HandCombat,
DatAttackHeight.Low,
DatAttackType.Kick);
Assert.Equal(new[]
{
DatMotionCommand.AttackLow1,
(DatMotionCommand)0x1000018Eu,
}, motions);
}
private static CombatTable MakeTable(params CombatManeuver[] maneuvers)
{
var table = new CombatTable();
table.CombatManeuvers.AddRange(maneuvers);
return table;
}
private static CombatManeuver Entry(
DatMotionStance stance,
DatAttackHeight height,
DatAttackType type,
DatMotionCommand motion)
{
return new CombatManeuver
{
Style = stance,
AttackHeight = height,
AttackType = type,
MinSkillLevel = 0,
Motion = motion,
};
}
}

View file

@ -27,6 +27,51 @@ public sealed class CombatStateTests
Assert.Equal(1f, state.GetHealthPercent(0xDEAD));
}
[Fact]
public void CombatMode_UsesRetailAceBitValues()
{
Assert.Equal(1, (int)CombatMode.NonCombat);
Assert.Equal(2, (int)CombatMode.Melee);
Assert.Equal(4, (int)CombatMode.Missile);
Assert.Equal(8, (int)CombatMode.Magic);
}
[Fact]
public void AttackType_UsesNamedRetailBitValues()
{
Assert.Equal(0x0001u, (uint)AttackType.Punch);
Assert.Equal(0x0002u, (uint)AttackType.Thrust);
Assert.Equal(0x0004u, (uint)AttackType.Slash);
Assert.Equal(0x0008u, (uint)AttackType.Kick);
Assert.Equal(0x0010u, (uint)AttackType.OffhandPunch);
Assert.Equal(0x79E0u, (uint)AttackType.MultiStrike);
}
[Fact]
public void SetCombatMode_TracksCurrentMode_AndFiresEvent()
{
var state = new CombatState();
CombatMode? seen = null;
state.CombatModeChanged += mode => seen = mode;
state.SetCombatMode(CombatMode.Missile);
Assert.Equal(CombatMode.Missile, state.CurrentMode);
Assert.Equal(CombatMode.Missile, seen);
}
[Fact]
public void OnCombatCommenceAttack_FiresAttackCommenced()
{
var state = new CombatState();
bool seen = false;
state.AttackCommenced += () => seen = true;
state.OnCombatCommenceAttack();
Assert.True(seen);
}
[Fact]
public void OnVictimNotification_FiresDamageTaken()
{

View file

@ -4,15 +4,12 @@ using DatReaderWriter.Enums;
namespace AcDream.Core.Tests.Meshing;
/// <summary>
/// Verifies that <see cref="TranslucencyKindExtensions.FromSurfaceType"/> maps
/// SurfaceType flag combinations to the correct <see cref="TranslucencyKind"/>
/// according to the documented priority order:
/// Additive &gt; InvAlpha &gt; AlphaBlend (Alpha|Translucent) &gt; ClipMap &gt; Opaque
/// Verifies the retail surface-state mapping used by the GL render split.
/// Priority order is:
/// Translucent+ClipMap override, Additive, InvAlpha, AlphaBlend, ClipMap, Opaque.
/// </summary>
public class TranslucencyKindTests
{
// ── Opaque cases ────────────────────────────────────────────────────────
[Fact]
public void Opaque_FromZeroFlags_ReturnsOpaque()
=> Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType((SurfaceType)0));
@ -25,8 +22,6 @@ public class TranslucencyKindTests
public void Opaque_FromBase1ImageFlag_ReturnsOpaque()
=> Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1Image));
// ── ClipMap cases ───────────────────────────────────────────────────────
[Fact]
public void ClipMap_FromBase1ClipMapFlag_ReturnsClipMap()
=> Assert.Equal(TranslucencyKind.ClipMap, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap));
@ -36,8 +31,6 @@ public class TranslucencyKindTests
=> Assert.Equal(TranslucencyKind.ClipMap,
TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap | SurfaceType.Gouraud));
// ── AlphaBlend cases ────────────────────────────────────────────────────
[Fact]
public void AlphaBlend_FromAlphaFlag_ReturnsAlphaBlend()
=> Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha));
@ -56,7 +49,14 @@ public class TranslucencyKindTests
=> Assert.Equal(TranslucencyKind.AlphaBlend,
TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha | SurfaceType.Base1ClipMap));
// ── InvAlpha cases ──────────────────────────────────────────────────────
[Fact]
public void AlphaBlend_TranslucentClipMapAdditiveCloud_ReturnsAlphaBlend()
=> Assert.Equal(TranslucencyKind.AlphaBlend,
TranslucencyKindExtensions.FromSurfaceType(
SurfaceType.Base1ClipMap
| SurfaceType.Translucent
| SurfaceType.Alpha
| SurfaceType.Additive));
[Fact]
public void InvAlpha_FromInvAlphaFlag_ReturnsInvAlpha()
@ -67,15 +67,40 @@ public class TranslucencyKindTests
=> Assert.Equal(TranslucencyKind.InvAlpha,
TranslucencyKindExtensions.FromSurfaceType(SurfaceType.InvAlpha | SurfaceType.Alpha));
// ── Additive cases ──────────────────────────────────────────────────────
[Fact]
public void Additive_FromAdditiveFlag_ReturnsAdditive()
=> Assert.Equal(TranslucencyKind.Additive, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Additive));
[Fact]
public void Additive_AdditiveBeatsAllOther()
public void Additive_AdditiveBeatsNonTranslucentBlendFlags()
=> Assert.Equal(TranslucencyKind.Additive,
TranslucencyKindExtensions.FromSurfaceType(
SurfaceType.Additive | SurfaceType.InvAlpha | SurfaceType.Alpha | SurfaceType.Base1ClipMap));
[Fact]
public void OpacityFromSurfaceTranslucency_NonTranslucentIgnoresRawValue()
{
Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Base1Image, 0f));
Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Base1Image, 0.75f));
}
[Fact]
public void OpacityFromSurfaceTranslucency_TranslucentInvertsAndClamps()
{
Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, -0.25f));
Assert.Equal(0.75f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, 0.25f));
Assert.Equal(0f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, 1.25f));
}
[Fact]
public void DisablesFixedFunctionFog_RawAdditiveEvenWhenBlendForcedToAlpha()
{
var cloud = SurfaceType.Base1ClipMap
| SurfaceType.Translucent
| SurfaceType.Alpha
| SurfaceType.Additive;
Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(cloud));
Assert.True(TranslucencyKindExtensions.DisablesFixedFunctionFog(cloud));
}
}

View file

@ -0,0 +1,66 @@
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public sealed class AnimationCommandRouterTests
{
private const uint NonCombat = 0x8000003Du;
[Theory]
[InlineData(0x00000000u, AnimationCommandRouteKind.None)]
[InlineData(0x10000057u, AnimationCommandRouteKind.Action)] // Sanctuary
[InlineData(0x2500003Bu, AnimationCommandRouteKind.Modifier)] // Jump
[InlineData(0x13000087u, AnimationCommandRouteKind.ChatEmote)] // Wave
[InlineData(0x41000003u, AnimationCommandRouteKind.SubState)] // Ready
[InlineData(0x40000011u, AnimationCommandRouteKind.SubState)] // Dead
[InlineData(0x8000003Du, AnimationCommandRouteKind.Ignored)] // NonCombat style
public void Classify_ReturnsRetailRouteKind(uint command, AnimationCommandRouteKind expected)
{
Assert.Equal(expected, AnimationCommandRouter.Classify(command));
}
[Fact]
public void RouteWireCommand_SubState_UsesSetCycle()
{
var seq = MakeEmptySequencer();
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011);
Assert.Equal(AnimationCommandRouteKind.SubState, route);
Assert.Equal(NonCombat, seq.CurrentStyle);
Assert.Equal(MotionCommand.Dead, seq.CurrentMotion);
}
[Fact]
public void RouteWireCommand_Sanctuary_IsActionNotDeadCycle()
{
var seq = MakeEmptySequencer();
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0057);
Assert.Equal(AnimationCommandRouteKind.Action, route);
Assert.Equal(0u, seq.CurrentMotion);
}
[Fact]
public void RouteWireCommand_Wave_IsChatEmote()
{
var seq = MakeEmptySequencer();
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0087);
Assert.Equal(AnimationCommandRouteKind.ChatEmote, route);
}
private static AnimationSequencer MakeEmptySequencer()
{
return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
}

View file

@ -223,6 +223,46 @@ public sealed class AnimationSequencerTests
}
}
[Fact]
public void HasCycle_PresentInTable_ReturnsTrue()
{
// Phase L.1c followup (2026-04-28): regression guard for
// "torso on the ground" — caller (GameWindow MoveTo path) needs
// to query the table before SetCycle to avoid the
// ClearCyclicTail wipe on a missing cycle.
const uint Style = 0x003Cu; // HandCombat
const uint Motion = 0x0003u; // Ready
const uint AnimId = 0x03000001u;
var setup = Fixtures.MakeSetup(2);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
// Caller passes the SAME shape SetCycle expects: full style with
// class byte (0x80000000) and full motion (0x40000000 / 0x10000000).
Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u));
}
[Fact]
public void HasCycle_MissingFromTable_ReturnsFalse()
{
const uint Style = 0x003Cu;
const uint ReadyMotion = 0x0003u;
const uint AnimId = 0x03000001u;
var setup = Fixtures.MakeSetup(2);
var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
// RunForward (0x44000007) is NOT in the table — caller should
// see false and fall back to a known motion (WalkForward / Ready).
Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u));
}
[Fact]
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
{
@ -1313,6 +1353,45 @@ public sealed class AnimationSequencerTests
Assert.Equal(99f, fr[0].Origin.X, 1);
}
[Fact]
public void PlayAction_ActionSurvivesImmediateReadyCycleEcho()
{
// ACE broadcasts creature attacks as Action-class ForwardCommand
// values followed by Ready. Retail keeps currState.Substate at Ready
// while the action link drains, so the Ready echo must not abort the
// in-flight swing.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint AttackMotion = 0x10000052u;
const uint IdleAnimId = 0x03000503u;
const uint AttackAnimId = 0x03000504u;
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(AttackMotion);
seq.SetCycle(Style, IdleMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(12f, fr[0].Origin.X, 1);
Assert.Equal(IdleMotion, seq.CurrentMotion);
}
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{

View file

@ -21,6 +21,10 @@ public class MotionCommandResolverTests
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
[InlineData(0x0015, 0x40000015u)] // Falling
[InlineData(0x0011, 0x40000011u)] // Dead
[InlineData(0x0012, 0x41000012u)] // Crouch
[InlineData(0x0013, 0x41000013u)] // Sitting
[InlineData(0x0014, 0x41000014u)] // Sleeping
// Action-class one-shots: melee attacks, death, portals
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
[InlineData(0x0058, 0x10000058u)] // ThrustMed

View file

@ -685,6 +685,33 @@ public sealed class MotionInterpreterTests
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_DeadState_RejectsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.Dead;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Theory]
[InlineData(MotionCommand.Crouch)]
[InlineData(MotionCommand.Sitting)]
[InlineData(MotionCommand.Sleeping)]
public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand)
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = postureCommand;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_CrouchRange_RejectsMove()
{

View file

@ -0,0 +1,296 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
/// per-tick steering port of retail
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
/// creatures.
/// </summary>
public class RemoteMoveToDriverTests
{
private const float Epsilon = 1e-3f;
private static float Yaw(Quaternion q)
{
var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
return MathF.Atan2(-fwd.X, fwd.Y);
}
[Fact]
public void Drive_AlreadyAtTarget_ReportsArrived()
{
var bodyPos = new Vector3(10f, 20f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(10f, 20.3f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
Assert.Equal(bodyRot, newOrient); // orientation untouched
}
[Fact]
public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
{
// ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
// Body at 0.5m from target should ARRIVE — not keep oscillating
// around the target the way it did pre-fix when only MinDistance
// was the gate. This is the "monster keeps running in different
// directions when it should be attacking" regression fix.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 0.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact]
public void Drive_FleeArrival_UsesMinDistance()
{
// Flee branch (moveTowards=false): arrival when dist >= MinDistance.
// Retail / ACE both use MinDistance for the flee-arrival threshold.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 6f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 5.0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: false,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact]
public void Drive_ChaseDoesNotArriveAtMinDistanceFloor()
{
// Regression: my earlier max(MinDistance, DistanceToObject) port
// would have arrived here because dist (1.5) <= MinDistance (2.0).
// Retail uses DistanceToObject for chase arrival, so a chase at
// dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 1.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 2.0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
}
[Fact]
public void Drive_ChasingButNotInRange_ReportsSteering()
{
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity; // facing +Y
var dest = new Vector3(0f, 50f, 0f); // straight ahead
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
// Already facing target → snap branch keeps yaw at 0.
Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
}
[Fact]
public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
{
// Body facing +Y; target at (1, 10, 0) — that's a small angle
// (about 5.7°), well within the 20° snap tolerance.
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity;
var dest = new Vector3(1f, 10f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
// Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
float expectedYaw = MathF.Atan2(-1f, 10f);
Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
// Verify orientation actually transforms +Y onto the (1,10) line.
var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
}
[Fact]
public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
{
// Body facing +Y; target at (-10, 0) — that's 90° to the left
// (well beyond the 20° snap tolerance), so we turn by at most
// TurnRateRadPerSec * dt this tick rather than snapping.
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity; // yaw = 0
var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
const float dt = 0.1f;
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
// We should turn LEFT (positive yaw) toward the target.
Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
}
[Fact]
public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
{
// Body facing +Y; target directly behind at (0, -10, 0).
// |delta| = π, equally close either way; the implementation
// picks one (sign depends on float wobble) — just assert
// we made progress (yaw changed by exactly TurnRate * dt).
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, -10f, 0f);
const float dt = 0.1f;
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
}
[Fact]
public void Drive_PreservesOrientationAtArrival()
{
var bodyPos = new Vector3(5f, 5f, 0f);
var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
var dest = new Vector3(5.01f, 5.01f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
// Caller would zero velocity; orientation should be untouched
// so the body settles facing whatever direction it was already.
Assert.Equal(bodyRot, newOrient);
}
[Fact]
public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold()
{
// Body 1 m from destination, running at 4 m/s, dt = 0.1 s.
// Naive advance = 0.4 m → would end at 0.6 m from dest, exactly
// on the threshold. With threshold=0.6 and remaining=0.4, the
// clamp should let the full velocity through (advance == remaining).
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, 4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true);
// Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the
// remaining distance. The clamp may apply a 0.99999×-style
// tiny scale due to FP rounding — accept anything ≥ 99.9% of
// the input as "no meaningful overshoot prevention applied."
Assert.InRange(clamped.Y, 4f * 0.999f, 4f);
Assert.Equal(0f, clamped.X);
Assert.Equal(0f, clamped.Z);
}
[Fact]
public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding()
{
// Body 1 m from destination, running at 4 m/s, dt = 0.2 s.
// Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m.
// remaining = 0.4 m, advance = 0.8 m → scale = 0.5.
// Velocity should be halved → 2 m/s.
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, 4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true);
Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon);
Assert.Equal(0f, clamped.X);
}
[Fact]
public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal()
{
// Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0.
// Any horizontal velocity would overshoot; clamp must zero it.
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 0.6f, 0f);
var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true);
Assert.Equal(0f, clamped.X);
Assert.Equal(0f, clamped.Y);
Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected
}
[Fact]
public void ClampApproachVelocity_FleeBranch_NoOp()
{
// moveTowards=false (flee): no overshoot risk, return velocity unchanged.
var bodyPos = Vector3.Zero;
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, -4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false);
Assert.Equal(vel, clamped);
}
[Fact]
public void OriginToWorld_AppliesLandblockGridShift()
{
// Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
// at (0xA9, 0xB4), that's one landblock west and zero north,
// so origin (10, 20, 0) inside that landblock should map to
// (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
var w = RemoteMoveToDriver.OriginToWorld(
originCellId: 0xA8B4000Eu,
originX: 10f, originY: 20f, originZ: 0f,
liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
Assert.Equal(-182f, w.X);
Assert.Equal(20f, w.Y);
Assert.Equal(0f, w.Z);
}
}

View file

@ -0,0 +1,88 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public sealed class ServerControlledLocomotionTests
{
[Fact]
public void PlanMoveToStart_SeedsImmediateRunCycle()
{
var plan = ServerControlledLocomotion.PlanMoveToStart();
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(1.0f, plan.SpeedMod);
}
[Fact]
public void PlanMoveToStart_AppliesRetailRunRate()
{
var plan = ServerControlledLocomotion.PlanMoveToStart(
moveToSpeed: 1.25f,
runRate: 1.5f,
canRun: true);
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(1.875f, plan.SpeedMod);
}
[Fact]
public void PlanMoveToStart_UsesWalkWhenRunDisallowed()
{
var plan = ServerControlledLocomotion.PlanMoveToStart(
moveToSpeed: 0.75f,
runRate: 2.0f,
canRun: false);
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
Assert.Equal(0.75f, plan.SpeedMod);
}
[Fact]
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.10f, 0.12f, 3.0f));
Assert.False(plan.IsMoving);
Assert.Equal(MotionCommand.Ready, plan.Motion);
Assert.Equal(1.0f, plan.SpeedMod);
}
[Fact]
public void PlanFromVelocity_WalksForSlowServerControlledMotion()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.0f, 0.80f, 0.0f));
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
Assert.InRange(plan.SpeedMod, 0.25f, 0.27f);
}
[Fact]
public void PlanFromVelocity_RunsAtRetailRunScale()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.0f, MotionInterpreter.RunAnimSpeed, 0.0f));
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(1.0f, plan.SpeedMod, precision: 4);
}
[Fact]
public void PlanFromVelocity_ClampsVeryFastSnapshots()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.0f, 30.0f, 0.0f));
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(ServerControlledLocomotion.MaxSpeedMod, plan.SpeedMod);
}
}

View file

@ -0,0 +1,95 @@
using System.Numerics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Vfx;
public sealed class ParticleHookSinkTests
{
private static EmitterDesc MakeDesc(uint id, bool attachLocal, int totalParticles = 0)
{
return new EmitterDesc
{
DatId = id,
Type = ParticleType.Still,
Flags = EmitterFlags.Billboard | (attachLocal ? EmitterFlags.AttachLocal : 0),
EmitterKind = ParticleEmitterKind.BirthratePerSec,
MaxParticles = 4,
InitialParticles = 1,
TotalParticles = totalParticles,
LifetimeMin = 0.05f, LifetimeMax = 0.05f, Lifespan = 0.05f,
StartSize = 1f, EndSize = 1f,
StartAlpha = 1f, EndAlpha = 1f,
Birthrate = 1000f, // effectively never re-emit
};
}
[Fact]
public void UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor()
{
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(0x32000010u, attachLocal: true));
var sys = new ParticleSystem(registry, new System.Random(42));
var sink = new ParticleHookSink(sys);
var hook = new CreateParticleHook
{
EmitterInfoId = 0x32000010u,
EmitterId = 0,
PartIndex = 0,
Offset = new Frame(),
};
// First spawn at world origin.
sink.OnHook(entityId: 0xCAFEu, entityWorldPosition: Vector3.Zero, hook);
sys.Tick(0.01f);
var live1 = System.Linq.Enumerable.Single(sys.EnumerateLive());
Assert.Equal(Vector3.Zero, live1.Emitter.Particles[live1.Index].Position);
// Move the parent to (5, 7, 0) — UpdateEntityAnchor must propagate.
sink.UpdateEntityAnchor(0xCAFEu, new Vector3(5, 7, 0), Quaternion.Identity);
sys.Tick(0.01f);
var live2 = System.Linq.Enumerable.Single(sys.EnumerateLive());
Assert.Equal(new Vector3(5, 7, 0), live2.Emitter.Particles[live2.Index].Position);
}
[Fact]
public void EmitterDied_PrunesPerEntityHandleTracking()
{
// M4: ConcurrentBag<int> couldn't drop entries when a particle
// emitter expired naturally, so per-entity tracking grew without
// bound. The sink now subscribes to ParticleSystem.EmitterDied
// and prunes both the (entity,key) map and the per-entity set.
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(0x32000020u, attachLocal: false, totalParticles: 1));
var sys = new ParticleSystem(registry, new System.Random(42));
var sink = new ParticleHookSink(sys);
var hook = new CreateParticleHook
{
EmitterInfoId = 0x32000020u,
EmitterId = 0xABCDu, // logical key
PartIndex = 0,
Offset = new Frame(),
};
sink.OnHook(0xCAFEu, Vector3.Zero, hook);
Assert.Equal(1, sys.ActiveEmitterCount);
// TotalParticles=1 cap hit immediately by the InitialParticles spawn,
// so the emitter Finishes once its single particle expires (0.05s
// lifetime). After this, EmitterDied has fired and tracking is pruned.
for (int i = 0; i < 5; i++) sys.Tick(0.05f);
Assert.Equal(0, sys.ActiveEmitterCount);
// A fresh spawn for the same (entity, key) succeeds and is the only
// live emitter — i.e., the previous handle was pruned cleanly.
sink.OnHook(0xCAFEu, Vector3.Zero, hook);
Assert.Equal(1, sys.ActiveEmitterCount);
sink.StopAllForEntity(0xCAFEu, fadeOut: false);
sys.Tick(0.01f);
Assert.Equal(0, sys.ActiveEmitterCount);
}
}

View file

@ -34,6 +34,43 @@ public sealed class ParticleSystemTests
};
}
private static EmitterDesc MakeInitialParticleDesc(
ParticleType type,
Vector3 a,
Vector3 b,
Vector3 c)
{
return new EmitterDesc
{
DatId = 0x3200AA01u,
Type = type,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 10f,
LifetimeMax = 10f,
Lifespan = 10f,
LifespanRand = 0f,
OffsetDir = Vector3.UnitZ,
MinOffset = 0f,
MaxOffset = 0f,
InitialVelocity = Vector3.Zero,
Gravity = Vector3.Zero,
A = a,
MinA = 1f,
MaxA = 1f,
B = b,
MinB = 1f,
MaxB = 1f,
C = c,
MinC = 1f,
MaxC = 1f,
StartSize = 0.5f,
EndSize = 0.5f,
StartAlpha = 1f,
EndAlpha = 1f,
};
}
[Fact]
public void SpawnEmitter_ReturnsPositiveHandle_AndTracksEmitter()
{
@ -60,7 +97,7 @@ public sealed class ParticleSystemTests
public void Tick_ParticlesDieAtLifetime()
{
var sys = MakeSystem();
sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero);
int handle = sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero);
// Use many short ticks so we can observe the death curve.
// At 20/sec with 0.5s lifetime and a stable emission pool, the
@ -69,11 +106,10 @@ public sealed class ParticleSystemTests
int steadyState = sys.ActiveParticleCount;
Assert.InRange(steadyState, 7, 13);
// Now advance further with no spawns (stop emitter); all should die.
sys.SpawnEmitter(MakeDesc(emitRate: 0f, maxParticles: 1), Vector3.Zero); // noop
// Continue time; particles age past lifetime.
// Now advance further with no new spawns; all should die.
sys.StopEmitter(handle, fadeOut: true);
for (int i = 0; i < 30; i++) sys.Tick(0.05f); // 1.5s more than lifetime
Assert.True(sys.ActiveParticleCount <= steadyState);
Assert.Equal(0, sys.ActiveParticleCount);
}
[Fact]
@ -100,7 +136,7 @@ public sealed class ParticleSystemTests
var desc = new EmitterDesc
{
DatId = 0x32000002u,
Type = ParticleType.Parabolic,
Type = ParticleType.ParabolicLVGA,
EmitRate = 10f,
MaxParticles = 100,
LifetimeMin = 2f, LifetimeMax = 2f,
@ -192,7 +228,7 @@ public sealed class ParticleSystemTests
}
[Fact]
public void MaxParticles_CapEnforced_OverwriteOldest()
public void MaxParticles_CapEnforced()
{
var sys = MakeSystem();
// Low cap, high rate, long life → rapidly hit cap.
@ -219,4 +255,239 @@ public sealed class ParticleSystemTests
reg.Register(desc);
Assert.Same(desc, reg.Get(0x32001234u));
}
[Fact]
public void LocalVelocity_TransformsABySpawnRotation()
{
var sys = MakeSystem();
var desc = MakeInitialParticleDesc(
ParticleType.LocalVelocity,
Vector3.UnitX,
Vector3.Zero,
Vector3.Zero);
sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f));
sys.Tick(1f);
var live = sys.EnumerateLive().Single();
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, -0.0001f, 0.0001f);
Assert.InRange(pos.Y, 0.9999f, 1.0001f);
}
[Fact]
public void GlobalVelocity_DoesNotTransformABySpawnRotation()
{
var sys = MakeSystem();
var desc = MakeInitialParticleDesc(
ParticleType.GlobalVelocity,
Vector3.UnitX,
Vector3.Zero,
Vector3.Zero);
sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f));
sys.Tick(1f);
var live = sys.EnumerateLive().Single();
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 0.9999f, 1.0001f);
Assert.InRange(pos.Y, -0.0001f, 0.0001f);
}
[Fact]
public void ParabolicLVLA_TransformsLocalAcceleration()
{
var sys = MakeSystem();
var desc = MakeInitialParticleDesc(
ParticleType.ParabolicLVLA,
Vector3.Zero,
Vector3.UnitX,
Vector3.Zero);
sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f));
sys.Tick(1f);
var live = sys.EnumerateLive().Single();
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, -0.0001f, 0.0001f);
Assert.InRange(pos.Y, 0.4999f, 0.5001f);
}
[Fact]
public void ParabolicLVGA_KeepsGlobalAcceleration()
{
var sys = MakeSystem();
var desc = MakeInitialParticleDesc(
ParticleType.ParabolicLVGA,
Vector3.Zero,
Vector3.UnitX,
Vector3.Zero);
sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f));
sys.Tick(1f);
var live = sys.EnumerateLive().Single();
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 0.4999f, 0.5001f);
Assert.InRange(pos.Y, -0.0001f, 0.0001f);
}
[Fact]
public void EmitterDescRegistry_FromDat_PreservesRetailEnumValuesAndRates()
{
var dat = new DatReaderWriter.DBObjs.ParticleEmitter
{
EmitterType = DatReaderWriter.Enums.EmitterType.BirthratePerSec,
ParticleType = DatReaderWriter.Enums.ParticleType.Swarm,
GfxObjId = 0x01000001u,
HwGfxObjId = 0x01000002u,
Birthrate = 0.25,
MaxParticles = 17,
InitialParticles = 3,
TotalParticles = 9,
TotalSeconds = 4,
Lifespan = 2,
LifespanRand = 0.5,
A = new Vector3(1, 0, 0),
MinA = 0.5f,
MaxA = 2f,
StartScale = 0.2f,
FinalScale = 0.8f,
StartTrans = 1f,
FinalTrans = 0f,
IsParentLocal = true,
};
var desc = EmitterDescRegistry.FromDat(0x32000099u, dat);
Assert.Equal(ParticleType.Swarm, desc.Type);
Assert.Equal(ParticleEmitterKind.BirthratePerSec, desc.EmitterKind);
Assert.Equal(4f, desc.EmitRate);
Assert.Equal(0x01000001u, desc.GfxObjId);
Assert.Equal(0x01000002u, desc.HwGfxObjId);
Assert.Equal(3, desc.InitialParticles);
Assert.Equal(9, desc.TotalParticles);
Assert.Equal(1.5f, desc.LifetimeMin);
Assert.Equal(2.5f, desc.LifetimeMax);
Assert.Equal(0f, desc.StartAlpha);
Assert.Equal(1f, desc.EndAlpha);
Assert.Equal(EmitterFlags.Billboard | EmitterFlags.FaceCamera | EmitterFlags.AttachLocal, desc.Flags);
Assert.True((desc.Flags & EmitterFlags.AttachLocal) != 0);
}
[Fact]
public void UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor()
{
// Retail ParticleEmitter::UpdateParticles 0x0051d2d4 reads the live
// parent frame each tick when is_parent_local=1. With the cameraOffset
// hack removed, AttachLocal correctness now depends on the owning
// subsystem updating AnchorPos every frame via UpdateEmitterAnchor.
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32AABBCCu,
Type = ParticleType.Still,
Flags = EmitterFlags.AttachLocal | EmitterFlags.Billboard,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f,
StartSize = 1f, EndSize = 1f,
StartAlpha = 1f, EndAlpha = 1f,
// Zero motion + zero offset so position == origin == AnchorPos.
};
int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0));
sys.Tick(0.01f);
var p1 = sys.EnumerateLive().Single().Emitter.Particles[0];
Assert.Equal(new Vector3(10, 0, 0), p1.Position);
// Move the live anchor; AttachLocal should track it on the next tick.
sys.UpdateEmitterAnchor(handle, new Vector3(50, 20, 5));
sys.Tick(0.01f);
var p2 = sys.EnumerateLive().Single().Emitter.Particles[0];
Assert.Equal(new Vector3(50, 20, 5), p2.Position);
}
[Fact]
public void UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin()
{
// is_parent_local=0 → particle uses its frozen EmissionOrigin; later
// anchor updates must NOT move it (retail's "frame snapshotted at
// spawn" semantics).
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32AABBCDu,
Type = ParticleType.Still,
Flags = EmitterFlags.Billboard, // NO AttachLocal
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f,
StartSize = 1f, EndSize = 1f,
StartAlpha = 1f, EndAlpha = 1f,
};
int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0));
sys.Tick(0.01f);
sys.UpdateEmitterAnchor(handle, new Vector3(99, 99, 99));
sys.Tick(0.01f);
var p = sys.EnumerateLive().Single().Emitter.Particles[0];
Assert.Equal(new Vector3(10, 0, 0), p.Position);
}
[Fact]
public void EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire()
{
var sys = MakeSystem();
var fired = new System.Collections.Generic.List<int>();
sys.EmitterDied += h => fired.Add(h);
int handle = sys.SpawnEmitter(MakeDesc(emitRate: 5f, lifetime: 0.2f, maxParticles: 4), Vector3.Zero);
sys.StopEmitter(handle, fadeOut: false); // kill emitter + all particles immediately
sys.Tick(0.01f);
Assert.Single(fired);
Assert.Equal(handle, fired[0]);
Assert.False(sys.IsEmitterAlive(handle));
}
[Fact]
public void Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed()
{
// Retail ParticleEmitterInfo::ShouldEmitParticle 0x00517420 checks
// (cur_time - last_emit_time) > birthrate. RecordParticleEmission
// 0x0051c870 then sets last_emit_time = cur_time, so retail's
// UpdateParticles fires AT MOST one EmitParticle per frame
// (the dispatch is `if (ShouldEmit) EmitParticle()`, not a loop).
// Lock that behavior in.
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32AAAA01u,
Type = ParticleType.Still,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
Birthrate = 0.05f, // 50ms minimum between emits
EmitRate = 0f, // disable the EmitRate fallback path
MaxParticles = 100,
LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f,
StartSize = 1f, EndSize = 1f,
StartAlpha = 1f, EndAlpha = 1f,
};
sys.SpawnEmitter(desc, Vector3.Zero);
// Single 1-second tick. Retail-faithful behavior: exactly one
// particle emits, regardless of how many birthrate intervals fit in dt.
sys.Tick(1.0f);
Assert.Equal(1, sys.ActiveParticleCount);
// Subsequent small ticks each emit once if birthrate has elapsed.
sys.Tick(0.06f); // > 0.05s since last emit
Assert.Equal(2, sys.ActiveParticleCount);
// A tick smaller than birthrate adds nothing.
sys.Tick(0.01f);
Assert.Equal(2, sys.ActiveParticleCount);
}
}

View file

@ -207,4 +207,28 @@ public sealed class PhysicsScriptRunnerTests
runner.Tick(0.5f); // total 0.6 > 0.5 pause
Assert.Single(sink.Calls);
}
[Fact]
public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance()
{
var script = BuildScript(
(0.0, new CallPESHook { PES = 0xAA, Pause = 30f }),
(0.0, CreateHook(123)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
runner.Tick(0.1f);
Assert.Single(sink.Calls);
Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(29.8f);
Assert.Single(sink.Calls);
runner.Tick(0.3f);
Assert.Equal(2, sink.Calls.Count);
}
}

View file

@ -0,0 +1,9 @@
using Xunit;
namespace AcDream.Core.Tests.World;
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class DerethDateTimeCollection
{
public const string Name = "DerethDateTime global offset";
}

View file

@ -3,6 +3,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
[Collection(DerethDateTimeCollection.Name)]
public sealed class DerethDateTimeTests
{
// ACE calendar anchor: tick 0 = Morningthaw 1, 10 P.Y. at Morntide-and-Half

View file

@ -72,6 +72,29 @@ public sealed class SkyDescLoaderTests
Assert.Equal(FogMode.Linear, kf.FogMode);
}
[Fact]
public void LoadFromRegion_CapturesSkyObjectPesId()
{
var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255);
var dg = region.SkyInfo!.DayGroups[0];
dg.SkyObjects.Add(new SkyObject
{
BeginTime = 0f,
EndTime = 1f,
DefaultGfxObjectId = 0x01004C44u,
DefaultPesObjectId = 0x3300042Cu,
Properties = 0x05,
});
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var obj = Assert.Single(loaded!.DayGroups[0].SkyObjects);
Assert.Equal(0x01004C44u, obj.GfxObjId);
Assert.Equal(0x3300042Cu, obj.PesObjectId);
Assert.True(obj.IsPostScene);
}
[Fact]
public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude()
{

View file

@ -4,6 +4,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
[Collection(DerethDateTimeCollection.Name)]
public sealed class SkyStateTests
{
[Fact]

View file

@ -4,6 +4,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
[Collection(DerethDateTimeCollection.Name)]
public sealed class WorldTimeDebugTests
{
[Fact]
@ -28,7 +29,8 @@ public sealed class WorldTimeDebugTests
// fraction 1/16: solve (t + 7/16*D) mod D = 1/16*D
// → t = (1/16 - 7/16) * D mod D = -6/16 * D mod D = 10/16 * D.
double targetFraction = 1.0 / 16.0; // Darktide-and-Half
double syncTick = (targetFraction - (7.0 / 16.0) + 1.0) * DerethDateTime.DayTicks;
double syncTick = targetFraction * DerethDateTime.DayTicks - DerethDateTime.OriginOffsetTicks;
while (syncTick < 0) syncTick += DerethDateTime.DayTicks;
var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(syncTick);