Compare commits
No commits in common. "559b79dc9879073e0e92f2a45265a88e5bc2e765" and "e4bc6de7babeaf2bf96e2aff96f28b82be145fb0" have entirely different histories.
559b79dc98
...
e4bc6de7ba
72 changed files with 1090 additions and 7497 deletions
|
|
@ -178,25 +178,24 @@ missing is the plugin-API surface.
|
|||
---
|
||||
|
||||
|
||||
## #2 — Lightning visual mismatch (sky PES path disproved)
|
||||
## #2 — Lightning visual not wired (dat-baked PES triggers)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-04-25
|
||||
**Component:** weather / sky / vfx
|
||||
|
||||
**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.
|
||||
**Description:** Retail's Rainy DayGroup in the Dereth Region dat contains 12+ `SkyObject` entries with non-zero `PesObjectId` and narrow visibility windows (5–70 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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**Files:**
|
||||
- `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
|
||||
- `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)`)
|
||||
|
||||
**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)
|
||||
|
|
@ -282,9 +281,7 @@ 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 / 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:
|
||||
**Root cause:** PES (Particle Effect Schedule) particles attached to SkyObjects via the `CelestialPosition.pes_id` field. Retail header at `acclient.h` line 35451 (verbatim):
|
||||
|
||||
```c
|
||||
struct CelestialPosition {
|
||||
|
|
@ -305,24 +302,21 @@ struct CelestialPosition {
|
|||
| 7 | 0x02000BA6 | 0x33000453 | 0.03–0.19 | early morning |
|
||||
| 17 | 0x02000589 | **0x3300042C** | **0.27–0.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 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`.
|
||||
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.
|
||||
|
||||
**Implementation outline:**
|
||||
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.
|
||||
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).
|
||||
|
||||
**Decomp pointers:**
|
||||
- `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.
|
||||
- `CPhysicsObj::InitPartArrayObject` decomp ~280484 — dispatches type 7 to Setup loader.
|
||||
- `CPartArray::CreateSetup` decomp ~287490 — Setup → Parts → optional PES wiring.
|
||||
|
||||
**Files:**
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@
|
|||
| 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
|
||||
|
|
@ -112,7 +111,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:**
|
||||
- **✓ 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.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.
|
||||
- **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.
|
||||
|
|
@ -306,49 +305,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,376 +0,0 @@
|
|||
# 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.03–0.19 | 0x02000BA6 | **0x33000453** | early morning rain |
|
||||
| 8 | 0.91–0.98 | 0x02000BA6 | **0x33000453** | late evening rain |
|
||||
| 11 | 0.025–0.030 | 0x02000588 | **0x33000428** | dawn flash |
|
||||
| 12 | 0.190–0.200 | 0x02000588 | **0x33000428** | morning end |
|
||||
| 13 | 0.030–0.190 | 0x02000589 | **0x3300042C** | morning |
|
||||
| 14 | 0.905–0.910 | 0x02000588 | **0x33000428** | dusk start |
|
||||
| 15 | 0.980–0.990 | 0x02000588 | **0x33000428** | dusk end |
|
||||
| 16 | 0.910–0.980 | 0x02000589 | **0x3300042C** | evening |
|
||||
| **17** | **0.270–0.910** | 0x02000589 | **0x3300042C** | **most of daytime — pick this for first visual test** |
|
||||
| 18 | 0.400–0.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.
|
||||
|
|
@ -1,557 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
# 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
|
|
@ -152,16 +152,7 @@ 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,
|
||||
// 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)
|
||||
HashSet<uint>? visibleCellIds = null)
|
||||
{
|
||||
_shader.Use();
|
||||
|
||||
|
|
@ -174,7 +165,7 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
// directly — no per-draw uniform uploads needed.
|
||||
|
||||
// ── Collect and group instances ───────────────────────────────────────
|
||||
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds);
|
||||
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds);
|
||||
|
||||
// ── Build and upload the instance buffer ──────────────────────────────
|
||||
// Count total instances.
|
||||
|
|
@ -351,27 +342,16 @@ 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>? animatedEntityIds)
|
||||
HashSet<uint>? visibleCellIds)
|
||||
{
|
||||
foreach (var grp in _groups.Values)
|
||||
grp.Entries.Clear();
|
||||
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
// 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))
|
||||
if (frustum is not null &&
|
||||
entry.LandblockId != neverCullLandblockId &&
|
||||
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
|
||||
continue;
|
||||
|
||||
foreach (var entity in entry.Entities)
|
||||
|
|
@ -379,14 +359,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -2,69 +2,64 @@ 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>
|
||||
/// Instanced renderer for retail particle emitters.
|
||||
/// 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>
|
||||
/// </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];
|
||||
|
||||
private float[] _instanceScratch = new float[256 * 16];
|
||||
|
||||
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
|
||||
public ParticleRenderer(GL gl, string shadersDir)
|
||||
{
|
||||
_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"));
|
||||
|
||||
float[] quadVerts =
|
||||
// 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[]
|
||||
{
|
||||
// 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 = { 0, 1, 2, 0, 2, 3 };
|
||||
uint[] quadIdx = new uint[] { 0, 1, 2, 0, 2, 3 };
|
||||
|
||||
_quadVao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(_quadVao);
|
||||
|
|
@ -72,14 +67,8 @@ 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);
|
||||
|
|
@ -88,347 +77,135 @@ 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 * 16 * sizeof(float)), (void*)0, BufferUsageARB.DynamicDraw);
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 8 * 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, 16 * sizeof(float), (void*)0);
|
||||
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)0);
|
||||
_gl.VertexAttribDivisor(2, 1);
|
||||
_gl.EnableVertexAttribArray(3);
|
||||
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(4 * sizeof(float)));
|
||||
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 8 * 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);
|
||||
}
|
||||
|
||||
public void Draw(
|
||||
ParticleSystem particles,
|
||||
ICamera camera,
|
||||
Vector3 cameraWorldPos,
|
||||
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
|
||||
/// <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)
|
||||
{
|
||||
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));
|
||||
if (particles is null || camera is null) return;
|
||||
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
|
||||
_shader.SetInt("uParticleTexture", 0);
|
||||
_shader.SetVec3("uCameraRight", GetCameraRight(camera));
|
||||
_shader.SetVec3("uCameraUp", GetCameraUp(camera));
|
||||
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.DepthMask(false);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
|
||||
var run = new List<ParticleInstance>(64);
|
||||
for (int i = 0; i < draws.Count;)
|
||||
// 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 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);
|
||||
var list = (em.Desc.Flags & EmitterFlags.Additive) != 0 ? addGroup : alphaGroup;
|
||||
if (list.Count == 0 || !ReferenceEquals(list[^1], em))
|
||||
list.Add(em);
|
||||
}
|
||||
|
||||
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
||||
_gl.BindVertexArray(0);
|
||||
_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.DepthMask(true);
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
private List<ParticleDraw> BuildDrawList(
|
||||
ParticleSystem particles,
|
||||
Vector3 cameraWorldPos,
|
||||
ParticleRenderPass renderPass,
|
||||
Vector3 cameraRight,
|
||||
Vector3 cameraUp)
|
||||
private void DrawEmitter(ParticleEmitter em, Vector3 cameraWorldPos)
|
||||
{
|
||||
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
|
||||
foreach (var (em, idx) in particles.EnumerateLive())
|
||||
{
|
||||
if (em.RenderPass != renderPass)
|
||||
continue;
|
||||
int liveCount = 0;
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
if (em.Particles[i].Alive) liveCount++;
|
||||
if (liveCount == 0) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq)));
|
||||
}
|
||||
|
||||
return draws;
|
||||
}
|
||||
|
||||
private void DrawInstances(List<ParticleInstance> instances)
|
||||
{
|
||||
if (instances.Count == 0)
|
||||
return;
|
||||
|
||||
int needed = instances.Count * 16;
|
||||
// Ensure instance buffer is big enough.
|
||||
int needed = liveCount * 8;
|
||||
if (_instanceScratch.Length < needed)
|
||||
_instanceScratch = new float[needed + 256 * 16];
|
||||
_instanceScratch = new float[needed + 256 * 8];
|
||||
|
||||
for (int i = 0; i < instances.Count; i++)
|
||||
// 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 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;
|
||||
ref var p = ref em.Particles[i];
|
||||
if (!p.Alive) continue;
|
||||
|
||||
_instanceScratch[o + 4] = p.AxisX.X;
|
||||
_instanceScratch[o + 5] = p.AxisX.Y;
|
||||
_instanceScratch[o + 6] = p.AxisX.Z;
|
||||
_instanceScratch[o + 7] = 0f;
|
||||
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;
|
||||
|
||||
_instanceScratch[o + 8] = p.AxisY.X;
|
||||
_instanceScratch[o + 9] = p.AxisY.Y;
|
||||
_instanceScratch[o + 10] = p.AxisY.Z;
|
||||
_instanceScratch[o + 11] = 0f;
|
||||
// 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;
|
||||
|
||||
_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;
|
||||
idx++;
|
||||
}
|
||||
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
fixed (void* bp = _instanceScratch)
|
||||
{
|
||||
_gl.BufferData(
|
||||
BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(instances.Count * 16 * sizeof(float)),
|
||||
bp,
|
||||
BufferUsageARB.DynamicDraw);
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(liveCount * 8 * sizeof(float)),
|
||||
bp, BufferUsageARB.DynamicDraw);
|
||||
}
|
||||
|
||||
_gl.BindVertexArray(_quadVao);
|
||||
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count);
|
||||
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6,
|
||||
DrawElementsType.UnsignedInt, (void*)0, (uint)liveCount);
|
||||
}
|
||||
|
||||
private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc)
|
||||
private static Vector3 GetCameraRight(ICamera camera)
|
||||
{
|
||||
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;
|
||||
Matrix4x4.Invert(camera.View, out var inv);
|
||||
return Vector3.Normalize(new Vector3(inv.M11, inv.M12, inv.M13));
|
||||
}
|
||||
|
||||
private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId)
|
||||
private static Vector3 GetCameraUp(ICamera camera)
|
||||
{
|
||||
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;
|
||||
Matrix4x4.Invert(camera.View, out var inv);
|
||||
return Vector3.Normalize(new Vector3(inv.M21, inv.M22, inv.M23));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -439,26 +216,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,23 +4,15 @@ in vec2 vTex;
|
|||
in vec4 vColor;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uParticleTexture;
|
||||
uniform bool uUseTexture;
|
||||
// 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.
|
||||
|
||||
void main() {
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,21 +4,26 @@
|
|||
layout(location = 0) in vec2 aQuad;
|
||||
layout(location = 1) in vec2 aTex;
|
||||
|
||||
// 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;
|
||||
// Per-instance: world-space center + size
|
||||
layout(location = 2) in vec4 aPosAndSize;
|
||||
layout(location = 3) in vec4 aColor;
|
||||
|
||||
uniform mat4 uViewProjection;
|
||||
uniform vec3 uCameraRight;
|
||||
uniform vec3 uCameraUp;
|
||||
|
||||
out vec2 vTex;
|
||||
out vec4 vColor;
|
||||
|
||||
void main() {
|
||||
vec3 world = aCenter.xyz
|
||||
+ aAxisX.xyz * aQuad.x
|
||||
+ aAxisY.xyz * aQuad.y;
|
||||
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);
|
||||
|
||||
vTex = aTex;
|
||||
vColor = aColor;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,46 @@
|
|||
#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, 0 = full fog color
|
||||
in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far)
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uDiffuse;
|
||||
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
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
|
@ -27,21 +58,79 @@ 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 1050–1820m. 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));
|
||||
|
||||
float a = sampled.a * (1.0 - uTransparency) * uSurfOpacity;
|
||||
// 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;
|
||||
if (a < 0.01) discard;
|
||||
fragColor = vec4(rgb, a);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ 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.
|
||||
|
|
@ -88,7 +87,7 @@ void main() {
|
|||
float diff = max(dot(worldNormal, uSunDir), 0.0);
|
||||
vec3 lit = vec3(uEmissive) // material.Emissive
|
||||
+ uAmbientColor // material.Ambient(1) × light.Ambient
|
||||
+ (uSunColor * uDiffuseFactor) * diff;
|
||||
+ uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L
|
||||
vTint = clamp(lit, 0.0, 1.0);
|
||||
|
||||
// Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ 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();
|
||||
|
|
@ -62,13 +61,12 @@ 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, SamplerCache samplers)
|
||||
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures)
|
||||
{
|
||||
_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>
|
||||
|
|
@ -108,10 +106,8 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe,
|
||||
bool environOverrideActive = false)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
|
||||
postScenePass: false, environOverrideActive: environOverrideActive);
|
||||
SkyKeyframe keyframe)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false);
|
||||
|
||||
/// <summary>
|
||||
/// Draw the POST-SCENE sky objects (the foreground rain mesh
|
||||
|
|
@ -138,10 +134,8 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe,
|
||||
bool environOverrideActive = false)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
|
||||
postScenePass: true, environOverrideActive: environOverrideActive);
|
||||
SkyKeyframe keyframe)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true);
|
||||
|
||||
/// <summary>
|
||||
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
|
||||
|
|
@ -157,8 +151,7 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe,
|
||||
bool postScenePass,
|
||||
bool environOverrideActive)
|
||||
bool postScenePass)
|
||||
{
|
||||
if (group is null || group.SkyObjects.Count == 0) return;
|
||||
|
||||
|
|
@ -234,11 +227,6 @@ 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;
|
||||
|
|
@ -255,18 +243,20 @@ 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;
|
||||
// Retail GameSky::UseTime routes max_bright through
|
||||
// CPhysicsObj::SetDiffusion, so it replaces material diffuse,
|
||||
// not emissive/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).
|
||||
if (rep.MaxBright > 0f)
|
||||
replaceDiffuse = rep.MaxBright;
|
||||
replaceLuminosity = float.IsNaN(replaceLuminosity)
|
||||
? rep.MaxBright
|
||||
: MathF.Min(replaceLuminosity, rep.MaxBright);
|
||||
}
|
||||
if (gfxObjId == 0) continue;
|
||||
|
||||
|
|
@ -287,24 +277,18 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
|
||||
// int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
|
||||
//
|
||||
// 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)
|
||||
// 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)
|
||||
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
|
||||
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
|
|
@ -359,17 +343,20 @@ 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);
|
||||
|
||||
// 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 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);
|
||||
|
||||
// Retail D3DPolyRender::SetSurface at 0x59c882 calls
|
||||
// SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
|
||||
|
|
@ -377,12 +364,9 @@ 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/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);
|
||||
// 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);
|
||||
|
||||
uint tex = _textures.GetOrUpload(sub.SurfaceId);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
|
|
@ -409,17 +393,14 @@ 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;
|
||||
_gl.BindSampler(0, needsRepeat ? _samplers.Wrap : _samplers.Clamp);
|
||||
int wrapMode = needsRepeat
|
||||
? (int)TextureWrapMode.Repeat
|
||||
: (int)TextureWrapMode.ClampToEdge;
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, wrapMode);
|
||||
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles,
|
||||
|
|
@ -430,12 +411,6 @@ 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);
|
||||
|
|
@ -664,7 +639,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} SurfaceTranslucency={surface.Translucency:F4}");
|
||||
$"SurfLuminosity={surface.Luminosity:F4} SurfTranslucency={surface.Translucency:F4}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -717,10 +692,8 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
SurfaceId = sm.SurfaceId,
|
||||
IsAdditive = isAdditive,
|
||||
SurfLuminosity = sm.Luminosity,
|
||||
SurfDiffuse = sm.Diffuse,
|
||||
NeedsUvRepeat = sm.NeedsUvRepeat,
|
||||
SurfOpacity = sm.SurfOpacity,
|
||||
DisableFog = sm.DisableFog,
|
||||
SurfTranslucency = sm.SurfTranslucency,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -760,7 +733,6 @@ 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 —
|
||||
|
|
@ -772,11 +744,17 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
/// </summary>
|
||||
public bool NeedsUvRepeat;
|
||||
/// <summary>
|
||||
/// Final surface opacity from <see cref="GfxObjSubMesh.SurfOpacity"/>.
|
||||
/// Translucent surfaces use <c>1 - Surface.Translucency</c>; other
|
||||
/// surfaces stay at 1.0.
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public float SurfOpacity;
|
||||
public bool DisableFog;
|
||||
public float SurfTranslucency;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,9 +178,8 @@ public sealed unsafe class TextureCache : IDisposable
|
|||
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
|
||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
||||
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||
var rs = _dats.Get<RenderSurface>((uint)surfaceTexture.Textures[0]);
|
||||
if (rs is null)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
// Start with the texture's default palette, then apply overlays.
|
||||
|
|
|
|||
|
|
@ -156,20 +156,22 @@ public static class GameEventWiring
|
|||
dispatcher.Register(GameEventType.VictimNotification, e =>
|
||||
{
|
||||
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
|
||||
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
|
||||
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);
|
||||
});
|
||||
dispatcher.Register(GameEventType.DefenderNotification, e =>
|
||||
{
|
||||
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
|
||||
if (p is not null) combat.OnDefenderNotification(
|
||||
p.Value.AttackerName, 0u, p.Value.DamageType,
|
||||
p.Value.AttackerName, p.Value.AttackerGuid, 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, (float)p.Value.HealthPercent);
|
||||
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent);
|
||||
});
|
||||
dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
|
||||
{
|
||||
|
|
@ -186,15 +188,12 @@ 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) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info);
|
||||
if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid);
|
||||
});
|
||||
|
||||
// ── Spells ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -3,79 +3,60 @@ using System.Buffers.Binary;
|
|||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound combat attack GameActions.
|
||||
///
|
||||
/// Retail/ACE use distinct payloads for melee and missile:
|
||||
/// Outbound <c>0x0008 AttackTargetRequest</c> GameAction.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (inside the <c>0xF7B1</c> GameAction envelope):
|
||||
/// <code>
|
||||
/// u32 0xF7B1 // GameAction envelope opcode
|
||||
/// u32 gameActionSequence // client sequence
|
||||
/// u32 0x0008 // TargetedMeleeAttack
|
||||
/// u32 targetGuid
|
||||
/// 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 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>
|
||||
///
|
||||
/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10,
|
||||
/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE
|
||||
/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and
|
||||
/// holtburger protocol game_action.rs.
|
||||
/// <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>
|
||||
/// </summary>
|
||||
public static class AttackTargetRequest
|
||||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
public const uint TargetedMeleeAttackOpcode = 0x0008u;
|
||||
public const uint TargetedMissileAttackOpcode = 0x000Au;
|
||||
public const uint CancelAttackOpcode = 0x01B7u;
|
||||
public const uint SubOpcode = 0x0008u;
|
||||
|
||||
/// <summary>Build the wire body for a targeted melee attack.</summary>
|
||||
public static byte[] BuildMelee(
|
||||
/// <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(
|
||||
uint gameActionSequence,
|
||||
uint targetGuid,
|
||||
uint attackHeight,
|
||||
float powerLevel)
|
||||
float powerLevel,
|
||||
float accuracyLevel,
|
||||
uint attackHeight)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
byte[] body = new byte[28];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMeleeAttackOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
|
||||
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(16), powerLevel);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel);
|
||||
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);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,17 +22,9 @@ 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 = 0x01,
|
||||
Melee = 0x02,
|
||||
Missile = 0x04,
|
||||
Magic = 0x08,
|
||||
|
||||
ValidCombat = NonCombat | Melee | Missile | Magic,
|
||||
CombatCombat = Melee | Missile | Magic,
|
||||
Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5,
|
||||
}
|
||||
|
||||
/// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary>
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Most other fields (extended weenie header, object description, motion tables,
|
||||
/// All other fields (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 read through the fixed
|
||||
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
|
||||
/// tails.
|
||||
/// 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -51,8 +51,6 @@ 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
|
||||
|
|
@ -80,9 +78,9 @@ public static class CreateObject
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(
|
||||
uint Guid,
|
||||
|
|
@ -94,7 +92,6 @@ public static class CreateObject
|
|||
uint? BasePaletteId,
|
||||
float? ObjScale,
|
||||
string? Name,
|
||||
uint? ItemType,
|
||||
ServerMotionState? MotionState,
|
||||
uint? MotionTableId,
|
||||
ushort InstanceSequence = 0,
|
||||
|
|
@ -139,59 +136,7 @@ public static class CreateObject
|
|||
ushort? SideStepCommand = null,
|
||||
float? SideStepSpeed = null,
|
||||
ushort? TurnCommand = 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);
|
||||
float? TurnSpeed = null);
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
||||
|
|
@ -445,39 +390,27 @@ public static class CreateObject
|
|||
pos += 9 * 2;
|
||||
AlignTo4(ref pos);
|
||||
|
||||
// --- WeenieHeader: read the fixed prefix fields we need. ---
|
||||
// ACE WorldObject_Networking.SerializeCreateObject writes:
|
||||
// weenieFlags, Name, WeenieClassId(PackedDword),
|
||||
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
|
||||
// ObjectDescriptionFlags, align.
|
||||
// --- WeenieHeader: read just the Name field (second after flags). ---
|
||||
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, itemType, motionState, motionTableId,
|
||||
textureChanges, subPalettes, basePaletteId, objScale, name, 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, null, motionState, motionTableId);
|
||||
textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -595,9 +528,6 @@ 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
|
||||
|
|
@ -700,62 +630,14 @@ 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,
|
||||
movementType,
|
||||
moveToParameters,
|
||||
moveToSpeed,
|
||||
moveToRunRate);
|
||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -147,34 +147,56 @@ public static class GameEvents
|
|||
|
||||
// ── Combat notifications ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>0x01AC VictimNotification - death message for the victim.</summary>
|
||||
public readonly record struct VictimNotification(string DeathMessage);
|
||||
/// <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);
|
||||
|
||||
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
|
||||
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);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01AD KillerNotification - death message for the killer.</summary>
|
||||
public readonly record struct KillerNotification(string DeathMessage);
|
||||
/// <summary>0x01AD KillerNotification — "you killed X".</summary>
|
||||
public readonly record struct KillerNotification(string VictimName, uint VictimGuid);
|
||||
|
||||
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
|
||||
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);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
|
||||
/// <summary>0x01B1 AttackerNotification — "you hit X for Y%".</summary>
|
||||
public readonly record struct AttackerNotification(
|
||||
string DefenderName,
|
||||
uint DamageType,
|
||||
double HealthPercent,
|
||||
uint Damage,
|
||||
uint Critical,
|
||||
ulong AttackConditions);
|
||||
float DamagePercent);
|
||||
|
||||
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
|
|
@ -182,26 +204,23 @@ public static class GameEvents
|
|||
try
|
||||
{
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
|
||||
/// <summary>0x01B2 DefenderNotification — "X hit you for Y".</summary>
|
||||
public readonly record struct DefenderNotification(
|
||||
string AttackerName,
|
||||
uint AttackerGuid,
|
||||
uint DamageType,
|
||||
double HealthPercent,
|
||||
uint Damage,
|
||||
uint HitQuadrant,
|
||||
uint Critical,
|
||||
ulong AttackConditions);
|
||||
uint Critical);
|
||||
|
||||
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
|
|
@ -209,42 +228,40 @@ public static class GameEvents
|
|||
try
|
||||
{
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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>0x01B8 CombatCommenceAttack - empty payload.</summary>
|
||||
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
|
||||
|
||||
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
|
||||
/// <summary>0x01A7 AttackDone — (attackSequence, weenieError).</summary>
|
||||
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
|
||||
|
||||
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
|
||||
if (payload.Length < 8) return null;
|
||||
return new AttackDone(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
}
|
||||
|
||||
// ── Spell enchantments ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -127,10 +127,6 @@ 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)
|
||||
|
|
@ -139,7 +135,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, MovementType: movementType));
|
||||
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
uint flags = packed & 0x7Fu;
|
||||
|
|
@ -162,13 +158,13 @@ public static class UpdateMotion
|
|||
|
||||
if ((flags & 0x1u) != 0)
|
||||
{
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
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, MovementType: movementType));
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
}
|
||||
|
|
@ -225,108 +221,14 @@ 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,
|
||||
movementType,
|
||||
moveToParameters,
|
||||
moveToSpeed,
|
||||
moveToRunRate,
|
||||
moveToPath));
|
||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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;
|
||||
|
|
@ -54,23 +53,12 @@ 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.
|
||||
|
|
@ -646,17 +634,10 @@ 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
|
||||
|
|
@ -928,48 +909,6 @@ 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 /
|
||||
|
|
|
|||
|
|
@ -1,308 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -7,17 +7,14 @@ namespace AcDream.Core.Combat;
|
|||
// Full research: docs/research/deepdives/r02-combat-system.md
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Flags]
|
||||
public enum CombatMode
|
||||
{
|
||||
Undef = 0,
|
||||
NonCombat = 0x01,
|
||||
Melee = 0x02,
|
||||
Missile = 0x04,
|
||||
Magic = 0x08,
|
||||
|
||||
ValidCombat = NonCombat | Melee | Missile | Magic,
|
||||
CombatCombat = Melee | Missile | Magic,
|
||||
NonCombat = 1,
|
||||
Melee = 2,
|
||||
Missile = 3,
|
||||
Magic = 4,
|
||||
Peaceful = 5,
|
||||
}
|
||||
|
||||
public enum AttackHeight
|
||||
|
|
@ -27,51 +24,6 @@ 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>.
|
||||
|
|
@ -79,26 +31,20 @@ public static class CombatInputPlanner
|
|||
[Flags]
|
||||
public enum AttackType : uint
|
||||
{
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
[Flags]
|
||||
|
|
|
|||
|
|
@ -39,8 +39,6 @@ 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;
|
||||
|
||||
|
|
@ -59,12 +57,6 @@ 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
|
||||
|
|
@ -102,15 +94,6 @@ 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)
|
||||
|
|
@ -157,8 +140,5 @@ 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,14 +200,21 @@ public static class GfxObjMesh
|
|||
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
|
||||
var translucency = TranslucencyKind.Opaque;
|
||||
var luminosity = 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;
|
||||
// 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;
|
||||
if (dats is not null)
|
||||
{
|
||||
var surface = dats.Get<Surface>(surfaceId);
|
||||
|
|
@ -215,16 +222,13 @@ 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.
|
||||
surfOpacity = TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(
|
||||
surface.Type,
|
||||
surface.Translucency);
|
||||
disableFog = TranslucencyKindExtensions.DisablesFixedFunctionFog(surface.Type);
|
||||
if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0)
|
||||
surfTranslucency = surface.Translucency;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,10 +256,8 @@ public static class GfxObjMesh
|
|||
{
|
||||
Translucency = translucency,
|
||||
Luminosity = luminosity,
|
||||
Diffuse = diffuse,
|
||||
NeedsUvRepeat = needsUvRepeat,
|
||||
SurfOpacity = surfOpacity,
|
||||
DisableFog = disableFog,
|
||||
SurfTranslucency = surfTranslucency,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -13,40 +13,67 @@ public sealed record GfxObjSubMesh(
|
|||
{
|
||||
/// <summary>
|
||||
/// How this sub-mesh should be composited into the frame.
|
||||
/// Populated from Surface.Type flags at upload time.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public TranslucencyKind Translucency { get; init; } = TranslucencyKind.Opaque;
|
||||
|
||||
/// <summary>
|
||||
/// Surface.Luminosity. Retail uses this as material emissive.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public float Luminosity { get; init; } = 0f;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool NeedsUvRepeat { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <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.
|
||||
/// </summary>
|
||||
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;
|
||||
public float SurfTranslucency { get; init; } = 1f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,25 +106,4 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -330,33 +330,6 @@ 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 ───
|
||||
|
|
|
|||
|
|
@ -84,24 +84,6 @@ 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,20 +72,12 @@ public static class MotionCommand
|
|||
/// regular SetCycle transition.
|
||||
/// </summary>
|
||||
public const uint FallDown = 0x10000050u;
|
||||
/// <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>0x10000057 — Dead.</summary>
|
||||
public const uint Dead = 0x10000057u;
|
||||
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
|
||||
public const uint CrouchLowerBound = 0x41000011u;
|
||||
/// <summary>0x41000015 - exclusive upper bound of crouch/sit/sleep range.</summary>
|
||||
public const uint CrouchUpperExclusive = 0x41000015u;
|
||||
/// <summary>0x41000014 — upper bound of crouch/sit/sleep range.</summary>
|
||||
public const uint CrouchUpperBound = 0x41000014u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -827,7 +819,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 == 0x40000011 (Dead):
|
||||
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead):
|
||||
/// return 0x48
|
||||
/// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
|
||||
/// return 0x48
|
||||
|
|
@ -858,7 +850,7 @@ public sealed class MotionInterpreter
|
|||
return false;
|
||||
|
||||
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
|
||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive)
|
||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound)
|
||||
return false;
|
||||
|
||||
// Need Gravity flag + Contact + OnWalkable for ground-based motion.
|
||||
|
|
|
|||
|
|
@ -1,304 +0,0 @@
|
|||
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| > 20°, snaps
|
||||
/// orientation when within tolerance, and tests arrival via
|
||||
/// <c>dist <= min_distance</c> (chase) or
|
||||
/// <c>dist >= 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 <= DistanceToObject</c> vs retail's <c>dist <= 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> > 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, isClipMap),
|
||||
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap),
|
||||
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap),
|
||||
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1),
|
||||
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2),
|
||||
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3),
|
||||
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, bool isClipMap)
|
||||
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format)
|
||||
{
|
||||
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
|
|
@ -256,8 +256,6 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,73 @@
|
|||
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 retail <c>ParticleEmitterInfo</c> dat records
|
||||
/// (<c>0x32xxxxxx</c>) into acdream runtime descriptors.
|
||||
/// 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>
|
||||
/// </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)
|
||||
{
|
||||
}
|
||||
|
||||
public EmitterDescRegistry(DatCollection dats)
|
||||
: this(id => SafeGet(dats, id))
|
||||
{
|
||||
}
|
||||
|
||||
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
Register(BuildFallback());
|
||||
// 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 void Register(EmitterDesc desc)
|
||||
|
|
@ -43,159 +78,10 @@ public sealed class EmitterDescRegistry
|
|||
|
||||
public EmitterDesc Get(uint emitterId)
|
||||
{
|
||||
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;
|
||||
|
||||
if (_byId.TryGetValue(emitterId, out var desc)) return desc;
|
||||
if (_byId.TryGetValue(0xFFFFFFFFu, 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
|
|
@ -63,30 +62,10 @@ 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)
|
||||
|
|
@ -125,54 +104,6 @@ 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,
|
||||
|
|
@ -184,35 +115,15 @@ 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 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;
|
||||
var anchor = worldPos + offset;
|
||||
|
||||
int handle = _system.SpawnEmitterById(
|
||||
emitterId: emitterInfoId,
|
||||
anchor: anchor,
|
||||
rot: rotation,
|
||||
rot: Quaternion.Identity,
|
||||
attachedObjectId: entityId,
|
||||
attachedPartIndex: partIndex,
|
||||
renderPass: renderPass);
|
||||
attachedPartIndex: partIndex);
|
||||
|
||||
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);
|
||||
_handlesByKey[(entityId, logicalId)] = handle;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,18 +5,33 @@ using System.Numerics;
|
|||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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>
|
||||
/// </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 float _time;
|
||||
private int _activeParticleCount;
|
||||
|
||||
|
|
@ -34,8 +49,7 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
Vector3 anchor,
|
||||
Quaternion? rot = null,
|
||||
uint attachedObjectId = 0,
|
||||
int attachedPartIndex = -1,
|
||||
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
|
||||
int attachedPartIndex = -1)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(desc);
|
||||
|
||||
|
|
@ -47,45 +61,43 @@ 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,
|
||||
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
|
||||
int attachedPartIndex = -1)
|
||||
{
|
||||
var desc = _registry.Get(emitterId);
|
||||
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass);
|
||||
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex);
|
||||
}
|
||||
|
||||
public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f)
|
||||
{
|
||||
// Full PhysicsScript scheduling lives in PhysicsScriptRunner.
|
||||
// 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.
|
||||
}
|
||||
|
||||
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++)
|
||||
|
|
@ -93,454 +105,259 @@ 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);
|
||||
int live = CountAlive(em);
|
||||
em.ActiveCount = live;
|
||||
_activeParticleCount += live;
|
||||
AdvanceEmitter(em, dt);
|
||||
_activeParticleCount += CountAlive(em);
|
||||
|
||||
if (em.Desc.TotalDuration > 0f && (_time - em.StartedAt) > em.Desc.TotalDuration)
|
||||
em.Finished = true;
|
||||
bool durationDone = em.Desc.TotalDuration > 0f
|
||||
&& (_time - em.StartedAt) > em.Desc.TotalDuration;
|
||||
if (durationDone) em.Finished = true;
|
||||
|
||||
if (em.Desc.TotalParticles > 0 && em.TotalEmitted >= em.Desc.TotalParticles)
|
||||
em.Finished = true;
|
||||
|
||||
if (em.Finished && live == 0)
|
||||
// Drop emitter entirely when it has no live particles AND is
|
||||
// marked finished (duration elapsed, StopEmitter, etc).
|
||||
if (em.Finished && CountAlive(em) == 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 void AdvanceEmitter(ParticleEmitter em)
|
||||
// ── Private: emission + integration ──────────────────────────────────────
|
||||
|
||||
private void AdvanceEmitter(ParticleEmitter em, float dt)
|
||||
{
|
||||
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 = _time - p.SpawnedAt;
|
||||
if (p.Lifetime <= 0f || p.Age >= p.Lifetime)
|
||||
p.Age += dt;
|
||||
if (p.Age >= p.Lifetime)
|
||||
{
|
||||
p.Alive = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
p.Position = ComputePosition(em, p);
|
||||
Integrate(ref p, em, dt);
|
||||
|
||||
float tLife = Math.Clamp(p.Age / p.Lifetime, 0f, 1f);
|
||||
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.Size = Lerp(em.Desc.StartSize, em.Desc.EndSize, tLife);
|
||||
float alpha = Lerp(em.Desc.StartAlpha, em.Desc.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 bool ShouldEmitParticle(ParticleEmitter em)
|
||||
private void SpawnOne(ParticleEmitter em)
|
||||
{
|
||||
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
|
||||
// Find a free slot; overwrite the oldest if pool is full.
|
||||
int slot = -1;
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
{
|
||||
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 (!em.Particles[i].Alive) { slot = i; break; }
|
||||
}
|
||||
if (slot < 0)
|
||||
return false;
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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)
|
||||
// Velocity = initial vector ± jitter in all three axes.
|
||||
Vector3 v = em.Desc.InitialVelocity;
|
||||
if (em.Desc.VelocityJitter > 0f)
|
||||
{
|
||||
localA = em.Desc.InitialVelocity;
|
||||
if (em.Desc.VelocityJitter > 0f)
|
||||
{
|
||||
localA += new Vector3(
|
||||
RandomCentered(em.Desc.VelocityJitter),
|
||||
RandomCentered(em.Desc.VelocityJitter),
|
||||
RandomCentered(em.Desc.VelocityJitter));
|
||||
}
|
||||
v += new Vector3(
|
||||
RandomCentered(em.Desc.VelocityJitter),
|
||||
RandomCentered(em.Desc.VelocityJitter),
|
||||
RandomCentered(em.Desc.VelocityJitter));
|
||||
}
|
||||
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;
|
||||
particle.Velocity = v;
|
||||
particle.Size = em.Desc.StartSize;
|
||||
particle.Rotation = em.Desc.StartRotation;
|
||||
particle.ColorArgb = em.Desc.StartColorArgb;
|
||||
}
|
||||
|
||||
private Vector3 ComputePosition(ParticleEmitter em, Particle p)
|
||||
// ── 13 retail motion integrators (r04 §3) ────────────────────────────────
|
||||
|
||||
private void Integrate(ref Particle p, ParticleEmitter em, float dt)
|
||||
{
|
||||
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:
|
||||
particle.A = ToSpawnWorld(em, localA);
|
||||
particle.B = ToSpawnWorld(em, localB);
|
||||
break;
|
||||
|
||||
case ParticleType.ParabolicLVGAGR:
|
||||
particle.A = ToSpawnWorld(em, localA);
|
||||
particle.C = localC;
|
||||
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;
|
||||
break;
|
||||
|
||||
case ParticleType.Swarm:
|
||||
particle.A = ToSpawnWorld(em, localA);
|
||||
// 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;
|
||||
}
|
||||
break;
|
||||
|
||||
case ParticleType.Explode:
|
||||
particle.A = localA;
|
||||
particle.B = localB;
|
||||
particle.C = RandomExplodeDirection(localC);
|
||||
// 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;
|
||||
}
|
||||
break;
|
||||
|
||||
case ParticleType.Implode:
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
break;
|
||||
|
||||
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;
|
||||
default:
|
||||
p.Position += p.Velocity * dt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// ── Utility ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 sg = (byte)((startArgb >> 8) & 0xFF);
|
||||
byte sb = (byte)( startArgb & 0xFF);
|
||||
byte ea = (byte)((endArgb >> 24) & 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,15 @@ public sealed class PhysicsScriptRunner
|
|||
_active.RemoveAt(i);
|
||||
}
|
||||
|
||||
AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0);
|
||||
_active.Add(new ActiveScript
|
||||
{
|
||||
Script = script,
|
||||
ScriptId = scriptId,
|
||||
EntityId = entityId,
|
||||
AnchorWorld = anchorWorldPos,
|
||||
StartTimeAbs = _now,
|
||||
NextHookIndex = 0,
|
||||
});
|
||||
|
||||
if (DiagEnabled)
|
||||
{
|
||||
|
|
@ -151,24 +159,6 @@ 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"/>
|
||||
|
|
@ -243,18 +233,18 @@ public sealed class PhysicsScriptRunner
|
|||
if (hook is CallPESHook call)
|
||||
{
|
||||
// CallPESHook.PES = sub-script id; Pause = delay before the
|
||||
// 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)
|
||||
// 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)
|
||||
{
|
||||
if (DiagEnabled)
|
||||
Console.WriteLine($"[pes] CallPES: script 0x{call.PES:X8} not found / empty");
|
||||
return;
|
||||
var sub = _active[^1];
|
||||
sub.StartTimeAbs = _now + call.Pause;
|
||||
_active[^1] = sub;
|
||||
}
|
||||
|
||||
AddActiveScript(subScript, call.PES, a.EntityId, a.AnchorWorld, call.Pause);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,123 +4,90 @@ 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>
|
||||
/// Retail particle motion integrators from <c>ParticleType</c> in
|
||||
/// <c>acclient.h</c>. Values are the retail dat values.
|
||||
/// 13 retail particle motion integrators. See r04 §1.
|
||||
/// Parabolic variants apply gravity with different orientation/decay rules.
|
||||
/// </summary>
|
||||
public enum ParticleType
|
||||
{
|
||||
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,
|
||||
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,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum EmitterFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Additive = 0x01,
|
||||
Billboard = 0x02,
|
||||
None = 0,
|
||||
Additive = 0x01, // blend mode: SrcAlpha / One (vs default SrcAlpha / InvSrcAlpha)
|
||||
Billboard = 0x02,
|
||||
FaceCamera = 0x04,
|
||||
AttachLocal = 0x08,
|
||||
AttachLocal= 0x08, // particles follow parent anchor frame
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-emitter configuration from the retail <c>ParticleEmitterInfo</c>
|
||||
/// dat object.
|
||||
/// Per-emitter configuration from the <c>ParticleEmitterInfo</c> dat.
|
||||
/// See r04 §1 + DatReaderWriter.ParticleEmitterInfo.
|
||||
/// </summary>
|
||||
public sealed class EmitterDesc
|
||||
{
|
||||
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; }
|
||||
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; }
|
||||
|
||||
// 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; }
|
||||
// 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
|
||||
|
||||
// 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; }
|
||||
// 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; }
|
||||
|
||||
// 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;
|
||||
// Initial kinematics
|
||||
public Vector3 InitialVelocity { get; init; }
|
||||
public float VelocityJitter { get; init; }
|
||||
public Vector3 Gravity { get; init; } = new(0, 0, -9.8f);
|
||||
|
||||
// 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; }
|
||||
// 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; }
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
|
|
@ -131,43 +98,34 @@ public sealed class PhysicsScript
|
|||
public sealed record PhysicsScriptHook(
|
||||
float StartTime,
|
||||
PhysicsScriptHookType Type,
|
||||
uint RefDataId,
|
||||
int PartIndex,
|
||||
uint RefDataId, // EmitterInfo / Sound / PartTransform
|
||||
int PartIndex, // attach to this part
|
||||
Vector3 Offset,
|
||||
bool IsParentLocal);
|
||||
|
||||
public enum PhysicsScriptHookType
|
||||
{
|
||||
CreateParticle = 18,
|
||||
DestroyParticle = 19,
|
||||
PlaySound = 1,
|
||||
AnimationDone = 2,
|
||||
CreateParticle = 18, // matches retail animation-hook type
|
||||
DestroyParticle= 19,
|
||||
PlaySound = 1,
|
||||
AnimationDone = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual runtime particle. Owned by the <c>ParticleSystem</c>.
|
||||
/// Individual runtime particle. Owned by the <c>ParticleSystem</c>;
|
||||
/// advanced per-frame.
|
||||
/// </summary>
|
||||
public struct Particle
|
||||
{
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -176,20 +134,16 @@ 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; }
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -197,25 +151,20 @@ 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,
|
||||
ParticleRenderPass renderPass = ParticleRenderPass.Scene);
|
||||
/// <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>Fire a full PhysicsScript at a target.</summary>
|
||||
/// <summary>Fire a full PhysicsScript at a target (the retail PlayScript dispatch).</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.</summary>
|
||||
/// <summary>Stop an emitter early (e.g. cast interrupted).</summary>
|
||||
void StopEmitter(int handle, bool fadeOut);
|
||||
|
||||
/// <summary>Current active particle count.</summary>
|
||||
/// <summary>Current active particle count (for HUD stats).</summary>
|
||||
int ActiveParticleCount { get; }
|
||||
int ActiveEmitterCount { get; }
|
||||
int ActiveEmitterCount { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ public sealed class SkyObjectData
|
|||
public float TexVelocityX;
|
||||
public float TexVelocityY;
|
||||
public uint GfxObjId;
|
||||
public uint PesObjectId;
|
||||
public uint Properties;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -532,7 +531,6 @@ public static class SkyDescLoader
|
|||
TexVelocityX = s.TexVelocityX,
|
||||
TexVelocityY = s.TexVelocityY,
|
||||
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u,
|
||||
Properties = s.Properties,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -241,32 +241,28 @@ public sealed class GameEventWiringTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_KillerNotification_AppendsCombatLine()
|
||||
public void WireAll_KillerNotification_FiresKillLandedOnCombatState()
|
||||
{
|
||||
var (d, _, _, _, chat) = MakeAll();
|
||||
byte[] payload = MakeString16L("You killed the drudge!");
|
||||
// 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 env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
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);
|
||||
Assert.Equal("Drudge", gotVictimName);
|
||||
Assert.Equal(0x80001234u, gotVictimGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -57,13 +57,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -7,140 +8,105 @@ namespace AcDream.Core.Net.Tests.Messages;
|
|||
|
||||
public sealed class CombatEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes()
|
||||
private static byte[] MakeString16L(string s)
|
||||
{
|
||||
byte[] body = AttackTargetRequest.BuildMelee(
|
||||
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()
|
||||
{
|
||||
byte[] body = AttackTargetRequest.Build(
|
||||
gameActionSequence: 3,
|
||||
targetGuid: 0x12345678u,
|
||||
attackHeight: 2,
|
||||
powerLevel: 0.75f);
|
||||
powerLevel: 0.75f,
|
||||
accuracyLevel: 0.5f,
|
||||
attackHeight: 2);
|
||||
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(28, body.Length);
|
||||
Assert.Equal(AttackTargetRequest.GameActionEnvelope,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||
Assert.Equal(3u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode,
|
||||
Assert.Equal(AttackTargetRequest.SubOpcode,
|
||||
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(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)));
|
||||
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4);
|
||||
Assert.Equal(0.5f,
|
||||
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
|
||||
Assert.Equal(2u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttackTargetRequest_BuildCancel_HasNoPayload()
|
||||
public void ParseVictimNotification_RoundTrip()
|
||||
{
|
||||
byte[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5);
|
||||
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
|
||||
|
||||
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);
|
||||
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.ParseVictimNotification(payload);
|
||||
Assert.NotNull(parsed);
|
||||
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("Attacker", parsed!.Value.AttackerName);
|
||||
Assert.Equal(0xAAu, parsed.Value.AttackerGuid);
|
||||
Assert.Equal(42u, parsed.Value.Damage);
|
||||
Assert.Equal(1u, parsed.Value.Critical);
|
||||
Assert.Equal(6ul, parsed.Value.AttackConditions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDefenderNotification_HoltburgerFixture()
|
||||
public void ParseAttackerNotification_RoundTrip()
|
||||
{
|
||||
var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000");
|
||||
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 parsed = GameEvents.ParseDefenderNotification(env.Payload.Span);
|
||||
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.ParseAttackerNotification(payload);
|
||||
Assert.NotNull(parsed);
|
||||
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);
|
||||
Assert.Equal("Drudge", parsed!.Value.DefenderName);
|
||||
Assert.Equal(30u, parsed.Value.Damage);
|
||||
Assert.Equal(0.15f, parsed.Value.DamagePercent, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEvasionNotifications_HoltburgerFixtures()
|
||||
public void ParseEvasionAttackerNotification_RoundTrip()
|
||||
{
|
||||
var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000");
|
||||
var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000");
|
||||
|
||||
Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span));
|
||||
Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span));
|
||||
byte[] payload = MakeString16L("Thrower");
|
||||
Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCombatCommenceAttack_HoltburgerFixture()
|
||||
public void ParseAttackDone_RoundTrip()
|
||||
{
|
||||
var env = ParseFixture("B0F700000000000005000000B8010000");
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error
|
||||
|
||||
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;
|
||||
var parsed = GameEvents.ParseAttackDone(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(42u, parsed!.Value.AttackSequence);
|
||||
Assert.Equal(0u, parsed.Value.WeenieError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -185,8 +185,7 @@ public class UpdateMotionTests
|
|||
[Fact]
|
||||
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
|
||||
{
|
||||
// movementType != 0 means one of the Move* variants; a truncated
|
||||
// non-Invalid payload still returns the outer state.
|
||||
// movementType != 0 means one of the Move* variants we don't parse.
|
||||
// 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];
|
||||
|
|
@ -195,7 +194,7 @@ public class UpdateMotionTests
|
|||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||
p += 6;
|
||||
body[p++] = 7; // movementType = MoveToPosition (non-Invalid)
|
||||
body[p++] = 1; // movementType = MoveToObject (non-Invalid)
|
||||
body[p++] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
|
||||
|
||||
|
|
@ -203,152 +202,5 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -27,51 +27,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ using DatReaderWriter.Enums;
|
|||
namespace AcDream.Core.Tests.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the retail surface-state mapping used by the GL render split.
|
||||
/// Priority order is:
|
||||
/// Translucent+ClipMap override, Additive, InvAlpha, AlphaBlend, ClipMap, Opaque.
|
||||
/// Verifies that <see cref="TranslucencyKindExtensions.FromSurfaceType"/> maps
|
||||
/// SurfaceType flag combinations to the correct <see cref="TranslucencyKind"/>
|
||||
/// according to the documented priority order:
|
||||
/// Additive > InvAlpha > AlphaBlend (Alpha|Translucent) > ClipMap > Opaque
|
||||
/// </summary>
|
||||
public class TranslucencyKindTests
|
||||
{
|
||||
// ── Opaque cases ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Opaque_FromZeroFlags_ReturnsOpaque()
|
||||
=> Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType((SurfaceType)0));
|
||||
|
|
@ -22,6 +25,8 @@ 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));
|
||||
|
|
@ -31,6 +36,8 @@ 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));
|
||||
|
|
@ -49,14 +56,7 @@ public class TranslucencyKindTests
|
|||
=> Assert.Equal(TranslucencyKind.AlphaBlend,
|
||||
TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha | SurfaceType.Base1ClipMap));
|
||||
|
||||
[Fact]
|
||||
public void AlphaBlend_TranslucentClipMapAdditiveCloud_ReturnsAlphaBlend()
|
||||
=> Assert.Equal(TranslucencyKind.AlphaBlend,
|
||||
TranslucencyKindExtensions.FromSurfaceType(
|
||||
SurfaceType.Base1ClipMap
|
||||
| SurfaceType.Translucent
|
||||
| SurfaceType.Alpha
|
||||
| SurfaceType.Additive));
|
||||
// ── InvAlpha cases ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void InvAlpha_FromInvAlphaFlag_ReturnsInvAlpha()
|
||||
|
|
@ -67,40 +67,15 @@ 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_AdditiveBeatsNonTranslucentBlendFlags()
|
||||
public void Additive_AdditiveBeatsAllOther()
|
||||
=> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -223,46 +223,6 @@ 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()
|
||||
{
|
||||
|
|
@ -1353,45 +1313,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,10 +21,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -685,33 +685,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,43 +34,6 @@ 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()
|
||||
{
|
||||
|
|
@ -97,7 +60,7 @@ public sealed class ParticleSystemTests
|
|||
public void Tick_ParticlesDieAtLifetime()
|
||||
{
|
||||
var sys = MakeSystem();
|
||||
int handle = sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero);
|
||||
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
|
||||
|
|
@ -106,10 +69,11 @@ public sealed class ParticleSystemTests
|
|||
int steadyState = sys.ActiveParticleCount;
|
||||
Assert.InRange(steadyState, 7, 13);
|
||||
|
||||
// Now advance further with no new spawns; all should die.
|
||||
sys.StopEmitter(handle, fadeOut: true);
|
||||
// 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.
|
||||
for (int i = 0; i < 30; i++) sys.Tick(0.05f); // 1.5s more than lifetime
|
||||
Assert.Equal(0, sys.ActiveParticleCount);
|
||||
Assert.True(sys.ActiveParticleCount <= steadyState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -136,7 +100,7 @@ public sealed class ParticleSystemTests
|
|||
var desc = new EmitterDesc
|
||||
{
|
||||
DatId = 0x32000002u,
|
||||
Type = ParticleType.ParabolicLVGA,
|
||||
Type = ParticleType.Parabolic,
|
||||
EmitRate = 10f,
|
||||
MaxParticles = 100,
|
||||
LifetimeMin = 2f, LifetimeMax = 2f,
|
||||
|
|
@ -228,7 +192,7 @@ public sealed class ParticleSystemTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxParticles_CapEnforced()
|
||||
public void MaxParticles_CapEnforced_OverwriteOldest()
|
||||
{
|
||||
var sys = MakeSystem();
|
||||
// Low cap, high rate, long life → rapidly hit cap.
|
||||
|
|
@ -255,239 +219,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,28 +207,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class DerethDateTimeCollection
|
||||
{
|
||||
public const string Name = "DerethDateTime global offset";
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -72,29 +72,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ using Xunit;
|
|||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
[Collection(DerethDateTimeCollection.Name)]
|
||||
public sealed class SkyStateTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ using Xunit;
|
|||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
[Collection(DerethDateTimeCollection.Name)]
|
||||
public sealed class WorldTimeDebugTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
@ -29,8 +28,7 @@ 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 * DerethDateTime.DayTicks - DerethDateTime.OriginOffsetTicks;
|
||||
while (syncTick < 0) syncTick += DerethDateTime.DayTicks;
|
||||
double syncTick = (targetFraction - (7.0 / 16.0) + 1.0) * DerethDateTime.DayTicks;
|
||||
|
||||
var service = new WorldTimeService(SkyStateProvider.Default());
|
||||
service.SyncFromServer(syncTick);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue