diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6ab3a19d..9b67b514 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -178,24 +178,25 @@ missing is the plugin-API surface. --- -## #2 — Lightning visual not wired (dat-baked PES triggers) +## #2 — Lightning visual mismatch (sky PES path disproved) **Status:** OPEN **Severity:** MEDIUM **Filed:** 2026-04-25 **Component:** weather / sky / vfx -**Description:** Retail's Rainy DayGroup in the Dereth Region dat contains 12+ `SkyObject` entries with non-zero `PesObjectId` and narrow visibility windows (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. +**Description:** Lightning/storm sky visuals still do not match retail. A 2026-04-28 named-retail recheck disproved the prior assumption that `SkyObject.PesObjectId` drives sky-render flash particles: `SkyDesc::GetSky` copies the field into `CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects`, `GameSky::MakeObject`, and `GameSky::UseTime` never read it. -**Root cause / status:** Research complete. Implementation is: in `SkyRenderer.Render`, detect visibility-window entry on any SkyObject with `obj.PesObjectId != 0`, call `PhysicsScriptRunner.Play(pesObjectId, ownerId: sky-owner, anchorPos: camera)`, and route any `SetFlash` / `Sound` hooks from the script into `uFogParams.z` + audio. +**Root cause / status:** Open again. The sky-PES path is non-retail and must stay disabled for normal rendering. The remaining mismatch likely lives in the sky/weather mesh material path, the lightning/fog flash path, or another weather subsystem outside `GameSky`; do not reintroduce per-SkyObject PES playback without new decompile evidence. **Files:** -- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — add per-SkyObject PES dispatch inside the visibility loop -- `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` — already shipped (Phase 6a); exposes `Play(scriptId, entityId, anchorWorldPos)` -- `src/AcDream.Core/Lighting/SceneLightingUbo.cs` — `FogParams.Z` is the flash slot; needs a sink that bumps it and decays -- `src/AcDream.App/Rendering/Shaders/sky.frag` — flash bump already wired (`rgb += flash * vec3(1.5, 1.5, 1.8)`) +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky/weather mesh draw, material state, pre/post split +- `src/AcDream.App/Rendering/Shaders/sky.frag` — flash/fog/lightning coloration path +- `src/AcDream.Core/World/SkyDescLoader.cs` — keep `PesObjectId` parsed for diagnostics, not render playback **Research:** +- `docs/research/2026-04-28-pes-pseudocode.md` — C.1 correction: `CelestialPosition.pes_id` copied but ignored by GameSky +- `docs/research/2026-04-23-sky-pes-wiring.md` — earlier decompile trace reached the same no-sky-PES conclusion - `docs/research/2026-04-23-lightning-real.md` (decompile trace + dat discovery) - `docs/research/2026-04-23-physicsscript.md` (runtime semantics) - `docs/research/2026-04-23-lightning-crossfade.md` (crossfade mechanism) @@ -281,7 +282,9 @@ missing is the plugin-API surface. **Description:** Retail renders a dynamic colored "light play" effect in the sky during certain Rainy/Cloudy DayGroup time windows. The user describes it as aurora-borealis-style. acdream renders no comparable effect. -**Root cause:** PES (Particle Effect Schedule) particles attached to SkyObjects via the `CelestialPosition.pes_id` field. Retail header at `acclient.h` line 35451 (verbatim): +**Root cause / status:** Open again. The prior root cause was wrong: `CelestialPosition.pes_id` exists in the retail header and is populated by `SkyDesc::GetSky`, but named retail `GameSky` code does not read it during sky object creation, update, or draw. A 2026-04-28 C.1 experiment that played those PES ids produced colored blobs/wash that did not match retail's broad aurora-like rays, and the path is now debug-only behind `ACDREAM_ENABLE_SKY_PES=1`. + +Retail header at `acclient.h` line 35451 still documents the copied field: ```c struct CelestialPosition { @@ -302,21 +305,24 @@ struct CelestialPosition { | 7 | 0x02000BA6 | 0x33000453 | 0.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 dynamic visual half — emitting and animating the PES particles — is unimplemented and provides the actual aurora look. Phase E.3 already has data-only PES support per memory crib `project_session_2026_04_18.md`; this issue requires the runtime + visual half. +acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetupUploaded` walks `Setup.Parts` for `0x020xxx` IDs). The remaining dynamic visual half is not `SkyObject.PesObjectId`; likely suspects are sky/weather mesh material state, texture transform/blending, or a separate weather/lightning subsystem outside `GameSky`. **Implementation outline:** -1. PES dat decode (already partially in `AcDream.Core.World.PesData` per Phase E.3). -2. PES emitter runtime — schedule, spawn, advect, color-cycle, expire each particle. -3. `SkyRenderer` integration — when `MakeObject` sees `pes_id != 0`, spawn the PES at the SkyObject's celestial position. -4. PES vertex-sprite renderer — billboarded textured quads with additive blending and color cycling. Probably reuses the future general-purpose particle renderer (issue #L? — TBD). +1. Keep `SkyObject.PesObjectId` parsed for diagnostics only. +2. Compare retail/acdream material state for the active sky/weather GfxObj/Setup ids (`0x02000588`, `0x02000589`, `0x02000714`, `0x02000BA6`). +3. Trace the named retail sky/weather draw path for texture transforms, translucency, diffusion, luminosity, and any non-GameSky weather effect dispatch. +4. Only add a new runtime visual path once the decompile has an actual caller. **Decomp pointers:** -- `CPhysicsObj::InitPartArrayObject` decomp ~280484 — dispatches type 7 to Setup loader. -- `CPartArray::CreateSetup` decomp ~287490 — Setup → Parts → optional PES wiring. +- `SkyDesc::GetSky` named retail `0x00501ec0` — copies `SkyObject.default_pes_object` into `CelestialPosition.pes_id`. +- `GameSky::CreateDeletePhysicsObjects` named retail `0x005073c0` — creates/updates sky objects from `gfx_id`, does not read `pes_id`. +- `GameSky::MakeObject` named retail `0x00506ee0` — calls `CPhysicsObj::makeObject(gfx_id, 0, 0)`, no PES. +- `GameSky::UseTime` named retail `0x005075b0` — updates frame/luminosity/diffusion/translucency, no PES. **Files:** -- `src/AcDream.Core/World/SkyDescLoader.cs` — `SkyObjectData` needs to carry `PesObjectId` (currently dropped on the floor). -- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — needs a particle-emission step alongside the per-SkyObject mesh draw. +- `src/AcDream.Core/World/SkyDescLoader.cs` — carries `PesObjectId` for diagnostics. +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — likely material/texture-transform parity work. +- `src/AcDream.App/Rendering/GameWindow.cs` — sky-PES playback remains debug-only, disabled by default. **Acceptance:** When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time. diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9435f824..c4cb6ee8 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -56,6 +56,7 @@ | I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `AttackDone` / `KillLanded`; templates ported verbatim from holtburger `panels/chat.rs:221-308`. | Tests ✓ | | K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ | | L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ | +| C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -111,7 +112,7 @@ Plus polish that doesn't get its own phase number: **Goal:** close the visible gaps that make the world read as "old / broken" compared to retail. **Sub-pieces:** -- **C.1 — VFX / particle system.** `PhysicsScript` parser, per-entity `ParticleEmitter` state, billboarded-quad particle renderer that lives in the Phase 9.1/9.2 translucent pass. Delivers **portal swirls, chimney smoke, and fireplace flames** in one implementation. +- **✓ SHIPPED — C.1 — VFX / particle system + sky-pass refinements.** Retail-faithful `ParticleEmitterInfo` runtime + 13-type motion integrator port + `PhysicsScript` runner + instanced billboard renderer with material-derived blend + global back-to-front sort + AttachLocal live-parent follow. Sky-pass refinements: Translucent+ClipMap alpha-blend, raw-Additive fog-skip, per-keyframe SkyObjectReplace divide-by-100, sampler-object wrap selection (ported from WorldBuilder), gated post-scene Z-offset. Sky-PES disabled by default — named-retail decomp proves `GameSky` drops `pes_id`. **Portal swirls, chimney smoke, fireplace flames** still need a Phase C.1.5 follow-up to wire entity-attached emitters to retail effect IDs (the data layer is ready; only the wiring is deferred). Lands as merge `feat(vfx): Phase C.1 — PES particle renderer + post-review fixes` (`ec1bbb4`) + `refactor(sky): replace per-frame wrap-mode mutation with persistent samplers` (`3d21c13`). - **C.2 — Dynamic point lights.** Fireplaces and lamps need local lighting; small upgrade to the mesh shader to accumulate N (e.g., 4) nearest point lights per draw. Uniform-buffer or UBO-friendly layout. - **C.3 — Palette range tuning.** Small per-range offset/length tweaks to match retail skin/hair/eye colors. Mostly diff and verify work, no architecture change. - **C.4 — Double-sided translucent polys.** Edge case left by Phase 9.2: neg-side translucent polys are culled because cull is always BACK. Fix by tracking per-sub-mesh `CullMode` and flipping GL state per draw (or drawing twice with opposite cull). Minor. @@ -305,6 +306,49 @@ with retail's MMB-hold mouse-look. --- +### Phase L.1 — Animation system completion + +**Status:** IN PROGRESS on `feature/animation-system-complete`. + +**Goal:** complete the retail-faithful animation surface beyond the +locomotion/jump K-fix series: combat swings, spell casting, emotes, death, +item-use, NPC/monster special actions, remote observer parity, and the +remaining floating-point polish around style transitions, modifiers, action +queues, speed scaling, and PosFrame root motion. + +**Plan of record:** `docs/plans/animation-system-audit.md`. + +**Sub-pieces:** +- **L.1a — Audit & inventory.** Map retail named-decomp evidence, ACE + cross-references, existing acdream hook points, and current gaps for each + animation category. Output: `docs/plans/animation-system-audit.md`. +- **L.1b — Command router + motion-state cleanup.** Extract tested + `SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants, + and split death `Sanctuary` action from persistent `Dead` substate. +- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath + style transitions, attack swings by stance/power/height, hit reactions, + evades/blocks/parries, and death handoff. +- **L.1d — Spell casting wiring.** Cast command classification, windup, + release, fizzle/interruption, recoil, and school/effect distinctions. +- **L.1e — Emotes + postures.** Outbound slash emotes, inbound + command-list emotes, and persistent sit/lie/kneel/sleep states. +- **L.1f — NPC/monster + item-use coverage.** Scripted gestures, monster + special actions, potion/food/scroll/recall cycles, and remote parity. +- **L.1g — Polish + conformance.** Style-transition chain, durable + modifiers/action queues, root-motion handling, speed scaling, and broad + synthetic MotionTable tests. + +**Acceptance:** +- `dotnet build` and `dotnet test` green at each commit. +- Test count grows by at least 30 with one representative cycle/action test + per major animation category. +- Every AC-specific behavior cites named retail decomp or ACE/holtburger + cross-reference evidence in code comments, tests, or commit notes. +- User visual sign-off for local and remote attack, spell, emote, death, and + item-use animation parity before marking shipped. + +--- + ### Phase J — Long-tail (deferred / low-priority) Not detailed here; each gets its own brainstorm when it becomes relevant. diff --git a/docs/plans/2026-04-27-phase-c1-pes-particles.md b/docs/plans/2026-04-27-phase-c1-pes-particles.md new file mode 100644 index 00000000..e7229634 --- /dev/null +++ b/docs/plans/2026-04-27-phase-c1-pes-particles.md @@ -0,0 +1,376 @@ +# Phase C.1 — PES particle rendering (sky aurora + portals + smoke) + +**Status:** specced, not started. +**Filed:** 2026-04-27 (handoff from sky/weather session, branch merged at f7c9e88). +**Worktree:** to be created at `.worktrees/phase-c1-particles` on branch `feature/phase-c1-particles`. + +**2026-04-28 correction:** named-retail decompile disproves the sky-PES +premise in this spec. `SkyDesc::GetSky` copies `default_pes_object` into +`CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects` +(`0x005073c0`), `GameSky::MakeObject` (`0x00506ee0`), and +`GameSky::UseTime` (`0x005075b0`) never read it. C.1 remains valid as the +generic PhysicsScript/particle renderer for real hooks, portals, smoke, etc., +but per-SkyObject PES playback is debug-only and disabled by default. + +--- + +## What you're building + +A retail-faithful **PES (Particle Effect Schedule)** rendering system. PES is +retail's name for "scripted vertex-sprite emitter". The dat carries a PES file +per effect ID (e.g. `0x33000453`) describing how particles spawn, advect, +color-cycle, and expire. Retail attaches a PES to sky objects, motion-hook +events, and portal swirls. Without it, several visible-in-retail effects look +flat or absent in acdream: + +- The dynamic "aurora-like light play" the user sees in retail's Rainy/Cloudy + sky is **not** a separate aurora system. It's PES particles attached to + SkyObjects via the `pes_id` field of `CelestialPosition` (verbatim retail + header at `acclient.h:35451`). See ISSUES.md #28 + #29. +- Portal swirls (rotating black disks today — see roadmap "Open visual + defects" → "Portals render as a rotating black disk"). +- Chimney smoke + fireplace flames (`References/ACViewer/Physics/Particles/` + for the visual model). +- Spell cast effects (some are PES, some are setup-mesh-based). + +Phase **E.3** already shipped the **data layer**: `ParticleSystem`, +`EmitterDescLoader`, `ParticleHookSink`, `PhysicsScriptRunner`, `VfxModel` +in `src/AcDream.Core/Vfx/`. **C.1** is the visual half — the GL renderer plus +SkyRenderer integration. + +--- + +## Phase 0 — worktree setup + +```bash +git -C C:/Users/erikn/source/repos/acdream worktree add .worktrees/phase-c1-particles -b feature/phase-c1-particles main +cd C:/Users/erikn/source/repos/acdream/.worktrees/phase-c1-particles +dotnet build && dotnet test +``` + +Expected: green baseline before touching code. If red, stop and investigate +(do not assume merge artifact). + +--- + +## Step 0 — GREP NAMED FIRST + +Per `CLAUDE.md`'s "Development workflow" rule, before any AC-specific +implementation step, grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` +by `class::method` name. For PES specifically, search: + +```bash +grep -nE "PhysicsScript::|PhysicsObj::PlayScript|PartArray::CreateSetup" \ + docs/research/named-retail/acclient_2013_pseudo_c.txt | head -40 + +grep -nE "ParticleEmitter::|EmitterInfo::|EmitterDesc::|PESystem" \ + docs/research/named-retail/acclient_2013_pseudo_c.txt | head -40 + +grep -nE "CreateParticleEmitter|DestroyParticleEmitter|RemoveParticleEmitter" \ + docs/research/named-retail/acclient_2013_pseudo_c.txt | head -20 +``` + +Then check `docs/research/named-retail/acclient.h` for verbatim retail structs: +- `EmitterInfo` (per-emitter description) +- `EmitterDesc` (per-particle description: lifetime, color curve, motion) +- `ParticleEmitter` (runtime instance) +- `PhysicsScript` (parsed `0x34xxxxxx` PES file: list of "spawn this emitter at this time" entries) + +Once you have the named functions/structs, write a brief +`docs/research/2026-04-XX-pes-pseudocode.md` translating the C decomp into +clean pseudocode before porting (per CLAUDE.md "WRITE PSEUDOCODE" step). The +sky/weather session repeatedly proved this catches misinterpretations before +they become bugs. + +--- + +## What we already have (Phase E.3 data-layer) + +Read these files first — they encode the dat schema and runtime model: + +- `src/AcDream.Core/Vfx/EmitterDescLoader.cs` — loads `EmitterInfo` / + `EmitterDesc` from the dat. Confirm field set matches retail's + `acclient.h` struct definitions. +- `src/AcDream.Core/Vfx/ParticleSystem.cs` — runtime stepper. **Verify** + the 13 motion-type integrators match retail's + `PhysicsScript::MotionType` enum exactly. The roadmap claims they do. +- `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` — schedules emitters per + the parsed PES. This is the timeline driver. +- `src/AcDream.Core/Vfx/ParticleHookSink.cs` — receives `CreateParticle` + hook calls from MotionInterpreter / AnimationSequencer (Phase E.1). +- `src/AcDream.Core/Vfx/VfxModel.cs` — the per-entity particle state. + +Tests for these are in `tests/AcDream.Core.Tests/Vfx/`. Run them first to +confirm the data-layer baseline still passes after any refactor. + +**What is NOT yet there** (and is C.1's job): + +- A GL renderer that consumes `ParticleSystem.LiveParticles` and draws + billboarded quads. +- SkyRenderer integration so `CelestialPosition.pes_id` actually spawns a + PES — currently `SkyDescLoader` drops the field on the floor (see + `SkyObjectData` at `src/AcDream.Core/World/SkyDescLoader.cs:28-54`). +- Per-entity emitter spawn for non-sky cases (chimneys, portals, spell + effects). Probably a wiring exercise once the renderer exists. + +--- + +## References (priority order) + +Per `CLAUDE.md`'s "Reference hierarchy by domain" — particle systems are +client-side visual, so the priority is: + +1. **`docs/research/named-retail/acclient_2013_pseudo_c.txt`** — the actual + retail client, fully named. Beats every other reference. +2. **`docs/research/named-retail/acclient.h`** — verbatim retail struct + definitions for `EmitterInfo`, `EmitterDesc`, `PhysicsScript`, + `ParticleEmitter`, `CelestialPosition`. +3. **`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`** + — exact Silk.NET stack match, has the GL-side billboarded-quad batcher + we want to port. Check `ParticleEmitter` and rendering glue around it. +4. **`references/ACViewer/ACViewer/Physics/Particles/`** — MonoGame, has + the visual algorithms. Use as cross-check on color cycle + motion math. +5. **`references/ACE/Source/`** — server-side, mostly irrelevant for client + particles, but ACE's `EmitterDesc` structs can confirm field types. +6. **`references/AC2D/`** — older C++ AC client. Smaller scope, useful + cross-check. +7. **`references/Chorizite.ACProtocol/`** — clean-room protocol library; + PES is dat-side not protocol-side, so probably not relevant. + +When two references disagree: decomp wins. Always. + +--- + +## What we already learned (data points the next session shouldn't re-derive) + +### CelestialPosition struct (verbatim retail header `acclient.h:35451`) + +```c +struct CelestialPosition { + IDClass<_tagDataID,32,0> gfx_id; + IDClass<_tagDataID,32,0> pes_id; // ← particle scheduler ID + float heading; + float rotation; + AC1Legacy::Vector3 tex_velocity; + float transparent; + float luminosity; + float max_bright; + unsigned int properties; +}; +``` + +### Dispatch path (named retail decomp) + +- `GameSky::CreateDeletePhysicsObjects` at offset `0x005073c0` (decomp + ~269036) iterates `sky_obj_pos` and calls `MakeObject(gfx_id, ...)`. The + current acdream port mirrors this for `gfx_id`. **The `pes_id` field is + silently dropped.** Wire it. +- `CPhysicsObj::InitPartArrayObject` at decomp ~280484 dispatches by type + prefix — type 6 → direct GfxObj, type 7 → `CPartArray::CreateSetup`. + This is the existing path C.1 needs to extend with PES-emitter spawn. +- `CPartArray::CreateSetup` at decomp ~287490 → `SetSetupID` → + `InitDefaults` (loads animations + scripts + physics-script-table). + Note: the **physics-script-table** mentioned here is the PES dispatcher. + +### Properties bit semantics (decomp 268704+ = `GameSky::Draw`) + +- bit `0x01` — post-scene placement (`after_sky_cell`). Acdream now + honours this (commit `034a684`). Foreground rain & particle emitters + with bit 0x01 set should render in the post-scene weather pass. +- bit `0x02` — hidden when fog override is active. +- bit `0x04` — only render if `LScape::weather_enabled`. Most PES-bearing + rows have this set. +- bit `0x08` — purpose unknown; seen on every PES-bearing entry. Worth + decoding in C.1 for completeness. Likely "this object owns a PES" or + "this is a transient effect". + +### Specific PES IDs in Dereth (probe-confirmed) + +`tools/StarsProbe/Program.cs` already enumerates these. For Rainy DG3: + +| OI | Active window | Gfx | **PES** | Notes | +|----|--------------|-----|---------|-------| +| 5 | always | 0x02000714 | **0x330007DB** | low-rate background | +| 7 | 0.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. diff --git a/docs/plans/animation-system-audit.md b/docs/plans/animation-system-audit.md new file mode 100644 index 00000000..5d6cc544 --- /dev/null +++ b/docs/plans/animation-system-audit.md @@ -0,0 +1,557 @@ +# Animation System Audit + +Phase A audit for `feature/animation-system-complete`. + +Date: 2026-04-28. + +## Summary + +The animation core is much stronger than the feature surface around it. +`AnimationSequencer` already handles cyclic state changes, transition links, +negative-speed retail remaps, mid-cycle speed changes, frame hooks, and +PosFrame root-motion accumulation. `GameWindow.OnLiveMotionUpdated` already +routes `InterpretedMotionState.Commands[]` through `PlayAction`, which means +server-broadcast NPC/monster/emote/action overlays are likely to animate when +the server emits motion commands. + +The remaining gap is mostly orchestration: + +- local combat/spell/item-use commands build wire packets but do not yet drive + the local visible action immediately; +- several combat/spell/emote packet surfaces need conformance fixes before + animation triggers can be trusted: combat mode enum values, split + melee/missile attack builders, `CombatCommenceAttack`, `AttackDone`, + damage/death notification parsers, `MagicSchool` enum order, and outbound + emote/soul-emote builders; +- combat/spell/item-use game events populate state/chat but do not yet map to + animation overlays for attacker/defender/caster; +- style changes are handled as simple `SetCycle(style, motion)` swaps, not the + full ACE `MotionTable.GetObjectSequence` multi-link style transition chain; +- held posture/emote commands need a small command resolver and tests around + one-shot-vs-persistent routing; +- death needs explicit `Sanctuary` action -> `Dead/Fallen` persistence rather + than relying on chat/health side effects. + +## Evidence Sources + +Named retail decomp: + +- `CMotionTable::is_allowed` at `0x005226C0` +- `CMotionTable::get_link` at `0x00522710` +- `CSequence::update_internal` at `0x005255D0` +- `CMotionInterp::adjust_motion` at `0x00528010` +- `CMotionInterp::charge_jump` at `0x005281C0` +- `CMotionInterp::get_jump_v_z` at `0x00527AA0` +- `CMotionInterp::jump` at `0x00528780` +- `CMotionInterp::apply_current_movement` at `0x00528870` +- `CMotionInterp::HitGround` at `0x00528AC0` +- `CMotionInterp::LeaveGround` at `0x00528B00` +- `CMotionInterp::DoMotion` at `0x00528D20` +- `CMotionInterp::DoInterpretedMotion` at `0x00528360` +- `ClientCombatSystem::HandleCommenceAttackEvent` at `0x0056AD20` +- `ClientCombatSystem::SetCombatMode` at `0x0056BE30` +- `ClientCombatSystem::StartAttackRequest` at `0x0056C040` +- `ClientCombatSystem::EndAttackRequest` at `0x0056C0E0` +- `ClientCombatSystem::StartPowerBarBuild` at `0x0056ADB0` +- `ClientCombatSystem::GetPowerBarLevel` at `0x0056ADE0` +- `ClientCombatSystem::ExecuteAttack` at `0x0056BB70` +- `ClientCombatSystem::HandleDefenderNotificationEvent` at `0x0056C920` +- `ClientCombatSystem::HandleEvasionDefenderNotificationEvent` at `0x0056C620` +- `ClientCombatSystem::HandlePlayerDeathEvent` at `0x0056C320` +- `ClientCombatSystem::HandleAttackerNotificationEvent` at `0x0056B420` +- `ClientCombatSystem::HandleAttackDoneEvent` at `0x0056C500` +- `CM_Combat::Event_ChangeCombatMode` at `0x006A9A70` +- `CM_Combat::Event_TargetedMeleeAttack` at `0x006A9C10` +- `CM_Combat::Event_TargetedMissileAttack` at `0x006A9D60` +- `AttackHook::Execute` at `0x00526B70` +- `gmSpellcastingUI::Cast` at `0x004C6050` +- `ClientMagicSystem::CastSpell` at `0x00568040` +- `ClientMagicSystem::FreeHandsAndCastSpell` at `0x00566EF0` +- `ClientMagicSystem::GetAppropriateSpellFormula` at `0x00567D50` +- `CM_Magic::Event_CastUntargetedSpell` at `0x006A3150` +- `CM_Magic::Event_CastTargetedSpell` at `0x006A3040` +- `ItemHolder::UseObject` at `0x00588A80` +- `CM_Inventory::Event_UseEvent` at `0x006AC3B0` +- `CM_Inventory::Event_UseWithTargetEvent` at `0x006AC480` +- `CM_Item::DispatchUI_UseDone` at `0x006A8510` +- `CommandInterpreter::PlayerIsDead` at `0x006B3D70` +- `SmartBox::HandlePlayScriptID` at `0x00452020` +- `CM_Physics::DispatchSB_PlayScriptID` at `0x006ACC40` +- `CM_Physics::DispatchSB_PlayScriptType` at `0x006AC6E0` +- `ClientCommunicationSystem::DoEmote` at `0x00578AD0` +- `ClientCommunicationSystem::Pose` at `0x00580480` +- `ClientCommunicationSystem::Handle_Communication__HearEmote` at + `0x0057CBE0` +- `ClientCommunicationSystem::Handle_Communication__HearSoulEmote` at + `0x0057D020` +- `ChatPoseTable::InqChatPoseCommand` at `0x00570AD0` +- `ChatEmoteData::Pack` at `0x004FCE80` + +Cross-reference material: + +- `docs/research/deepdives/r03-motion-animation.md` +- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Server\Physics\Animation\MotionTable.cs` +- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Server\Physics\Animation\MotionInterp.cs` +- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Entity\Enum\MotionCommand.cs` +- `C:\Users\erikn\source\repos\acdream\references\holtburger\apps\holtburger-cli\src\pages\game\combat.rs` +- `C:\Users\erikn\source\repos\acdream\references\holtburger\crates\holtburger-core\src\client\messages.rs` +- `C:\Users\erikn\source\repos\acdream\references\holtburger\crates\holtburger-protocol\src\messages\movement\types.rs` + +The clean worktree intentionally does not contain `references/`; it was read +read-only from the original checkout path above. + +## Current Code Surface + +Core animation: + +- `src/AcDream.Core/Physics/AnimationSequencer.cs` + - `SetCycle(style, motion, speedMod, skipTransitionLink)` handles cyclic + state changes and transition links. + - `PlayAction(motionCommand, speedMod)` handles Action, Modifier, and + ChatEmote one-shots through Links/Modifiers lookup. + - `Advance(dt)` emits pending hooks and accumulates PosFrame deltas. + - Missing: full style-transition chain, durable modifier list, action queue + accounting, and a public command-resolution facade that callers can test + without `GameWindow`. +- `src/AcDream.Core/Physics/MotionInterpreter.cs` + - Handles locomotion, jump, leave-ground/hit-ground, and basic contact + guards. + - Missing: full retail `MotionState`, action list, modifier list, hold-key + run application, combat-state guards, and `move_to_interpreted_state`. +- `src/AcDream.Core/Physics/MotionCommandResolver.cs` + - Reconstructs full 32-bit commands from 16-bit wire values. + +App integration: + +- `src/AcDream.App/Input/PlayerMovementController.cs` + - Local walk/run/strafe/turn/jump driver. It does not own combat/spell/item + action animation. +- `src/AcDream.App/Rendering/GameWindow.cs` + - `OnLiveMotionUpdated` is the main inbound motion/action router. + - `OnLiveVectorUpdated` seeds airborne jump arcs and Falling cycles. + - `OnLivePositionUpdated` snaps positions and lands airborne remotes. + - `TickAnimations` advances sequencers and drains hooks. + - `UpdatePlayerAnimation` drives the local movement cycle. + - Missing: typed animation coordinator for combat/spell/use/death/emote + events; too much command mapping still lives inline. + +Wire/state: + +- `src/AcDream.Core.Net/Messages/AttackTargetRequest.cs`: outbound attack + request exists, but currently combines melee and missile into one layout; + retail/ACE/holtburger use distinct `0x0008` melee and `0x000A` missile + payloads. +- `src/AcDream.Core.Net/Messages/CastSpellRequest.cs`: outbound spell request + exists. +- `src/AcDream.Core.Net/Messages/CharacterActions.cs`: combat mode request + exists, but the combat-mode enum must be corrected to retail values + `NonCombat=1`, `Melee=2`, `Missile=4`, `Magic=8`. +- `src/AcDream.Core.Net/Messages/InteractRequests.cs`: use/use-with-target + request exists. +- `src/AcDream.Core.Net/GameEventWiring.cs`: combat, spell, item, chat events + route into state classes. +- Missing: public `WorldSession.SendAttack/SendCast/SendUse/ChangeCombatMode` + wrappers and animation-side subscriptions. + +## Retail Command Catalogue To Use + +From ACE `MotionCommand.cs` + `r03-motion-animation.md`: + +- Locomotion substates: Ready `0x41000003`, WalkForward `0x45000005`, + WalkBackward `0x45000006`, RunForward `0x44000007`, TurnRight + `0x6500000D`, TurnLeft `0x6500000E`, SideStepRight `0x6500000F`, + SideStepLeft `0x65000010`, Falling `0x40000015`. +- Held/posture substates: Crouch `0x41000012`, Sitting `0x41000013`, + Sleeping `0x41000014`, Dead `0x40000011`, Fallen `0x40000008`. +- Item/use substates: Reload `0x40000016`, Unload `0x40000017`, Pickup + `0x40000018`, StoreInBackpack `0x40000019`, Eat `0x4000001A`, Drink + `0x4000001B`, Reading `0x4000001C`. +- Spell substates/actions: CastSpell `0x400000D3`, MagicBlast + `0x4000002B`, MagicSelfHead `0x4000002C`, MagicSelfHeart `0x4000002D`, + MagicBonus..MagicPenalty `0x4000002E..0x40000034`, MagicTransfer + `0x40000035`, MagicEnchantItem `0x40000037`, MagicPortal `0x40000038`, + MagicPray `0x40000039`, MagicPowerUp01..10 `0x1000006F..0x10000078`, + MagicPowerUp01Purple..10Purple `0x1000012B..0x10000134`. +- Combat actions: Sanctuary `0x10000057`, ThrustMed/Low/High + `0x10000058..0x1000005A`, SlashHigh/Med/Low `0x1000005B..0x1000005D`, + BackhandHigh/Med/Low `0x1000005E..0x10000060`, Shoot `0x10000061`, + AttackHigh/Med/Low1..6 `0x10000062..0x1000006A` and + `0x10000186..0x1000018E`, MissileAttack1..3 `0x100000D0..0x100000D2`, + SpecialAttack1..3 `0x100000CD..0x100000CF`, dual-wield/offhand ranges + `0x10000173..0x1000019A`. +- ChatEmote actions: Wave `0x13000087`, BowDeep `0x1300007D`, Laugh + `0x13000080`, Point `0x13000084`, Salute `0x1300008A`, Kneel + `0x13000092`, HaveASeat `0x13000152`, DrudgeDance `0x13000151`, plus + the full `0x1200/0x1300` ranges in `r03`. +- Persistent emote states: `0x430000EA..0x430000FD`, SnowAngelState + `0x43000118`, CurtseyState `0x4300011A`, AFKState `0x4300011B`, + MeditateState `0x4300011C`, SitState `0x4300013A`, + SitCrossleggedState `0x4300013B`, SitBackState `0x4300013C`, + PossumState `0x43000142`, HaveASeatState `0x43000145`. ACE's enum is a + useful alias catalog but has a shifted range for some late chat-emote states; + named-retail values win when hard-coding constants. + +## Category Audit + +### 1. Own Player Movement + +Status: mostly working. + +Evidence: retail `CMotionInterp` jump/grounding symbols listed above; ACE +`MotionInterp.cs` for `adjust_motion`, `apply_current_movement`, `HitGround`, +and `LeaveGround`. + +acdream locations: `PlayerMovementController`, `MotionInterpreter`, +`UpdatePlayerAnimation`, `OnLiveVectorUpdated`, `OnLivePositionUpdated`. + +Gaps: + +- held postures exist as retail commands but are not driven by a general + posture/action API; +- `MotionInterpreter` does not yet own full `MotionState`, so non-locomotion + commands cannot be uniformly tested there; +- mounted/swimming need dat/retail verification before any implementation. + +Tests to add: + +- posture state `SetCycle` tests for Crouch/Sitting/Sleeping; +- `motion_allows_jump` conformance for item/spell/aim/posture ranges; +- local action does not stomp Falling while airborne. + +### 2. Other Players' Movement + +Status: partially working after the K-fix series. + +Evidence: `UpdateMotion` handling in `OnLiveMotionUpdated`; retail +`CMotionInterp::DoInterpretedMotion` and `apply_current_movement`; ACE +`MotionInterp.apply_current_movement`. + +acdream locations: `RemoteMotion`, `OnLiveMotionUpdated`, +`OnLiveVectorUpdated`, `OnLivePositionUpdated`, `TickAnimations`. + +Gaps: + +- remote action overlays only happen when the server includes + `InterpretedMotionState.Commands[]`; combat/spell game events do not yet + synthesize overlays when the wire omits motion commands; +- no test fixture exercises `OnLiveMotionUpdated` command-list routing outside + `GameWindow`; +- root-motion deltas are accumulated but not applied to remote body transforms. + +Tests to add: + +- command-list `Wave` -> `PlayAction` routing through a new coordinator; +- airborne remote ignores mid-arc locomotion cycle swaps but still updates + interpreted movement; +- landing swaps Falling back to current interpreted command. + +### 3. NPC Movement + +Status: likely works for UpdateMotion-driven locomotion and simple gestures; +not verified. + +Evidence: retail MotionTable/InterpretedMotionState path; ACE +`MotionTable.GetObjectSequence` and `MotionInterp.move_to_interpreted_state`. + +acdream locations: `CreateObject.ParseServerMotionState`, +`OnLiveMotionUpdated`, `TickAnimations`. + +Gaps: + +- no NPC-specific live test checklist; +- no retained action/modifier list, so repeated scripted gestures are + fire-and-forget overlays only; +- no head-look/threat-pose state beyond whatever arrives as motion commands. + +Tests to add: + +- synthetic NPC `UpdateMotion` with `Commands=[Wave, Ready]` plays one-shot + then returns to Ready; +- style-default fallback for creature motion tables. + +### 4. Monster Movement + +Status: locomotion probably works when `MotionTableId` and UpdateMotion are +present; special attacks are unknown. + +Evidence: ACE MotionTable supports monster actions such as HeadThrow, +FistSlam, BreatheFlame, SpinAttack, Bite, SpecialAttack1..3. + +acdream locations: same as NPC movement; `AnimationHookRouter` for VFX/audio +side effects. + +Gaps: + +- attack action overlays for monsters depend on server motion command lists; +- no mapping from combat events to visible monster attack/hit reactions; +- no exotic creature spot-checks. + +Tests to add: + +- `PlayAction(BreatheFlame)` resolves from Links/Modifiers when synthetic data + provides it; +- Attack hooks fire exactly once for a synthetic monster action. + +### 5. Combat Actions + +Status: wire codecs and combat state exist; visual action orchestration is +missing for local and event-driven paths. + +Evidence: + +- retail `ClientCombatSystem::StartPowerBarBuild`, + `ClientCombatSystem::GetPowerBarLevel`, `ClientCombatSystem::ExecuteAttack`, + `HandleCommenceAttackEvent`, `HandleAttackerNotificationEvent`, + `HandleAttackDoneEvent`; +- ACE `MotionTable.GetAttackFrames` scans Attack hooks and is the canonical + hit-frame source; +- holtburger combat UI tracks `AttackCommenced`, `AttackDone`, victim, + attacker, defender, evasion, and killed feedback as runtime state. + +acdream locations: + +- `AttackTargetRequest` exists but no `WorldSession.SendAttack` wrapper was + found; +- `CombatState` emits `DamageTaken`, `DamageDealtAccepted`, evasion, + `AttackDone`, and `KillLanded`; +- `GameEventWiring` registers combat event parsers; +- `AnimationSequencer.PlayAction` can play the swing once the command is known. + +Gaps: + +- combat-mode enum values are currently non-retail for missile/magic; +- melee/missile attack request builders need to be split to retail layouts: + `0x0008 targetGuid, attackHeight, power` and + `0x000A targetGuid, attackHeight, accuracy`; +- `CombatCommenceAttack (0x01B8)` is enumerated but not parsed/wired; +- `AttackDone (0x01A7)` and attacker/defender/death notification parsers need + ACE/holtburger fixtures before downstream animation can trust them; +- `CombatState` has no `CurrentMode`, no attack sequence active flag, no + selected target, and no power-bar state; +- no local predictive swing on attack request; +- hit reactions (Twitch/Stagger/Tipped/FallDown) are not mapped from defender + notifications; +- style changes for draw/sheath do not run the full style-transition chain. + +Tests to add: + +- parse/wire `CombatCommenceAttack`; +- `CombatAnimationCoordinator` maps height/power/style to attack command; +- defender hit quadrant maps to a stable flinch command; +- `AttackHook` dispatch is one-shot. + +### 6. Spell Casting + +Status: outbound cast packets and spellbook/enchantment state exist; visible +cast-stage animation is missing. + +Evidence: + +- retail `ClientMagicSystem::CastSpell` and `FreeHandsAndCastSpell`; +- `gmSpellcastingUI::Cast` calls `ClientMagicSystem::CastSpell`; +- outbound cast actions are `0x0048` untargeted (`spellId`) and `0x004A` + targeted (`targetGuid`, `spellId`); +- retail/ACE school order is `War=1`, `Life=2`, `Item=3`, `Creature=4`, + `Void=5`; +- MotionCommand spell catalogue above; +- `GameEventWiring` wires spellbook/enchantment updates but not casting + animation. + +acdream locations: + +- `CastSpellRequest` targeted/untargeted builders; +- `Spellbook`, `SpellTable`, `GameEventWiring` spell handlers; +- `AnimationHookRouter` already routes hooks to audio/VFX sinks. + +Gaps: + +- no cast coordinator reading server `UpdateMotion`, spellcasting chat, + `PlayScript.Fizzle`, `UseDone`, and errors into one local cast timeline; +- no fizzle/interruption animation mapping from `PlayScript.Fizzle = 0x51` + (ACE sends speed `0.5`) and `WeenieError`; +- no recoil/release state; +- no local immediate cast animation on request. +- `MagicSchool` enum currently needs conformance against the retail/ACE order. + +Tests to add: + +- spell school/effect classifier maps to MagicBlast/MagicSelf/MagicPortal; +- fizzle error maps to a one-shot action or recovery state once retail + command is confirmed; +- cast request triggers local action overlay without waiting for enchantment. + +### 7. Emotes + +Status: inbound text parsers and chat display exist; motion command-list +emotes likely animate if server emits them. Slash-command-to-emote wire and +text-event-to-animation are missing. + +Evidence: + +- retail `ClientCommunicationSystem::DoEmote`, `HelpEmote`, + `DoEmoteList`, `InitializeEmoteInputActionHash`; +- retail `ClientCommunicationSystem::Pose` looks up a token in + `ChatPoseTable`, issues the motion command locally, then sends SoulEmote; +- `ChatEmoteData::Pack`; +- ACE MotionCommand ChatEmote range. + +acdream locations: + +- `EmoteText` and `SoulEmote` top-level parsers; +- `ChatLog.OnEmote` / `OnSoulEmote`; +- `GameWindow.OnLiveMotionUpdated` command-list `PlayAction` route. + +Gaps: + +- no outbound `Communication_Emote (0x01DF)` or + `Communication_SoulEmote (0x01E1)` GameAction builder found; +- `MoveToState` currently writes zero command-list entries, so the client + cannot yet send pose/emote commands in the retail motion-state path; +- `ChatInputParser` has no `/em`, `/emote`, `/me`, `/sit`, `/kneel`, + `/sleep`, or `/lie` parsing; +- `EmoteText`/`SoulEmote` text events do not carry an emote id, so they + should not be used as the primary animation source unless retail proves a + deterministic text -> command mapping; +- held postures need `SetCycle`, not `PlayAction`. + +Tests to add: + +- `MotionCommandResolver` reconstructs representative ChatEmotes; +- command-list Wave routes to `PlayAction`; +- persistent Sit/Meditate routes to `SetCycle`. + +### 8. Death Animations + +Status: death chat and killer notifications exist; pose transition is missing. + +Evidence: + +- retail `CommandInterpreter::PlayerIsDead` checks forward command + `0x40000011`; +- MotionCommand `Sanctuary = 0x10000057` is an action and must not be used as + the persistent death state; +- MotionCommand `Dead = 0x40000011` and `Fallen = 0x40000008` are persistent + states; +- `PlayerKilled` top-level message and `KillerNotification (0x01AD)` are + parsed/wired. + +acdream locations: + +- `PlayerKilled`, `ChatLog.OnPlayerKilled`; +- `CombatState.OnKillerNotification`; +- `MotionCommand.Dead` currently incorrectly comments `0x10000057` in + `MotionInterpreter`; this should be split into `Sanctuary` action and + `Dead` substate before death work. + +Gaps: + +- no explicit death animation coordinator; +- no hit-direction-aware fall; +- no dead-pose persistence or respawn reset. + +Tests to add: + +- death event plays Sanctuary then persists Dead/Fallen; +- movement is blocked while Dead/Fallen; +- respawn/reset returns to Ready. + +### 9. Item-Use Animations + +Status: outbound use builders exist; local visible use animations are missing. + +Evidence: + +- retail `ItemHolder::UseObject`; +- MotionCommand item/use states: Pickup, StoreInBackpack, Eat, Drink, + Reading, HouseRecall, LifestoneRecall. + +acdream locations: + +- `InteractRequests.BuildUse` / `BuildUseWithTarget`; +- `ItemRepository`, appraise/use-done event enum. + +Gaps: + +- no `WorldSession.SendUse` wrapper found; +- `UseDone (0x01C7)` is enumerated but not parsed/wired; +- `0xF754 PlayScriptId` is wired, but target anchoring and speed handling need + audit; `0xF755 PlayScriptType` is not wired; +- no item-class-to-motion mapping. + +Tests to add: + +- potion use maps to Drink; +- food maps to Eat; +- scroll/book maps to Reading; +- recall spell/item maps to recall command once retail source is confirmed. + +### 10. Mounting / Dismounting + +Status: not implemented; likely not relevant to retail AC character movement. + +Evidence: `r03` lists `Graze` as a monster-only/mount-like stance, but no +player mount feature has been verified in retail references in this audit. + +Action: defer until a server/content feature requires it. Do not invent +mounting behavior. + +### 11. Floating-Point / Polish + +Status: partially implemented. + +Evidence: + +- `AnimationSequencer.MultiplyCyclicFramerate` exists and is tested; +- `LocalAnimationSpeed` exists in `MovementResult`; +- PosFrame deltas are accumulated in `AnimationSequencer`. + +Gaps: + +- root-motion deltas are not composed into entity/body transforms; +- remote animation speed scaling is tied to ForwardSpeed/SidestepSpeed/TurnSpeed + only when UpdateMotion carries them; +- style-transition and modifier physics combination are incomplete. + +Tests to add: + +- same-motion/different-speed rescale remains green; +- root-motion delta is consumed by an integration coordinator; +- modifiers combine velocity/omega instead of replacing base cycle physics. + +## Implementation Order + +1. Extract an `AnimationCommandRouter` in Core/App-adjacent code that owns + `SetCycle` vs `PlayAction` routing for full 32-bit commands. Move the + command-list logic out of `GameWindow.OnLiveMotionUpdated` into tests. +2. Add missing MotionCommand constants and fix the `Dead`/`Sanctuary` + distinction. +3. Fix combat wire conformance first: combat-mode enum, split attack builders, + `CombatCommenceAttack`, `AttackDone`, damage/evasion/death notification + parsers, and fixtures from ACE/holtburger. +4. Wire `CombatState.CurrentMode` and `WorldSession.SendAttack/ChangeCombatMode`; + trigger local and remote swing overlays through the router. +5. Add spell-cast event/state wiring: `WorldSession.SendCast`, school enum + conformance, `UpdateMotion` cast actions, spellcasting chat, + `PlayScript.Fizzle`, `UseDone`, and errors. +6. Add outbound emote/soul-emote builders, MoveToState command-list emission, + chat parser aliases, and posture routing. +7. Add item-use wrappers, `UseDone`, script target anchoring, and + item-class-to-motion mapping. +8. Add death coordinator and respawn reset. +9. Port full ACE style-transition/modifier/action queue semantics into a + `MotionTableWalker` or equivalent, replacing `SetCycle` special cases only + after the category tests cover current behavior. +10. Apply/consume root motion where retail expects it; leave purely decorative + PosFrames un-applied when decomp/ACE proves they should not move the body. + +## Visual Sign-Off Points + +The agent can build, test, and live-launch autonomously, but these require +user visual confirmation before claiming complete: + +- local attack swing + defender flinch; +- local spell windup -> release/fizzle; +- local `/wave` and persistent sit/lie/kneel/sleep; +- local death pose and respawn recovery; +- potion drink/eat/read animations; +- remote observer view for all of the above. diff --git a/docs/research/2026-04-28-combat-animation-planner.md b/docs/research/2026-04-28-combat-animation-planner.md new file mode 100644 index 00000000..f557659c --- /dev/null +++ b/docs/research/2026-04-28-combat-animation-planner.md @@ -0,0 +1,113 @@ +# Combat Animation Planner Pseudocode + +## Sources + +- Retail `ClientCombatSystem::ExecuteAttack` (`0x0056BB70`): sends + targeted melee or missile attack intent and records pending response state. + It does not choose a local swing animation. +- Retail `ClientCombatSystem::HandleCommenceAttackEvent` (`0x0056AD20`): + starts/updates power-bar and busy UI state. The event carries no + `MotionCommand`. +- Retail command-name table around `0x00803F34`: combat commands include + `Twitch1..4`, `StaggerBackward`, `StaggerForward`, `ThrustMed`, + `SlashHigh`, `Shoot`, `AttackHigh1`, and later offhand/multistrike + commands. +- ACE `Player_Melee.DoSwingMotion` and `GetSwingAnimation`: server chooses + a swing from `CombatManeuverTable.GetMotion(...)` and broadcasts the + selected `MotionCommand` with `UpdateMotion`. +- ACE `CombatManeuverTable.GetMotion`: indexes `(stance, attack height, + attack type)` to one or more motion commands; power level chooses between + multiple entries. + +## Retail Rule + +Combat GameEvents are state/UI notifications. Motion state is the animation +authority. + +## Pseudocode + +```text +PlanForEvent(event): + return None + +PlanFromWireCommand(wireCommand, speed): + fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand) + return PlanFromFullCommand(fullCommand, speed) + +PlanFromFullCommand(fullCommand, speed): + kind = ClassifyMotionCommand(fullCommand) + if kind is None: + return None + + routeKind = AnimationCommandRouter.Classify(fullCommand) + return Plan(kind, routeKind, fullCommand, speed) + +ClassifyMotionCommand(fullCommand): + if command is a combat stance: + return CombatStance + if command is a thrust/slash/backhand/offhand/multistrike motion: + return MeleeSwing + if command is Shoot, MissileAttack*, or Reload: + return MissileAttack + if command is AttackHigh/Med/Low 1..6: + return CreatureAttack + if command is CastSpell, UseMagicStaff, or UseMagicWand: + return SpellCast + if command is Twitch*, Stagger*, FallDown, or Sanctuary: + return HitReaction + if command is Dead: + return Death + return None +``` + +## Maneuver Selection Pseudocode + +```text +SelectMotion(table, stance, attackHeight, attackType, powerLevel, + isThrustSlashWeapon): + candidates = [] + for maneuver in table.CombatManeuvers: + if maneuver.Style == stance + and maneuver.AttackHeight == attackHeight + and maneuver.AttackType == attackType: + candidates.append(maneuver.Motion) + + if candidates is empty: + return None + + subdivision = isThrustSlashWeapon ? 0.66 : 0.33 + + if candidates.Count > 1 and powerLevel < subdivision: + motion = candidates[1] + else: + motion = candidates[0] + + return motion +``` + +This matches ACE `CombatManeuverTable.GetMotion` plus +`Player_Melee.GetSwingAnimation`. The `prevMotion` parameter is present in +ACE's table API but the current ACE implementation does not use it; the +power threshold chooses between multiple entries. + +## Named Retail Motion IDs + +`DatReaderWriter.Enums.MotionCommand` is shifted by three entries starting +at `AllegianceHometownRecall`. Named retail command tables are: + +- `command_ids` table lines 1017626-1017658: + `0x016E..0x0197 -> 0x1000016E..0x10000197`. +- command-name table lines 1068272-1068313: + `OffhandSlashHigh = 0x10000170`, `AttackLow6 = 0x1000018B`, + `PunchFastLow = 0x1000018E`, etc. + +`MotionCommandResolver` therefore overrides that range after building the +DRW reflection table, otherwise offhand and late unarmed attack actions +resolve as UI/mappable commands and never reach `PlayAction`. + +## Implementation Note + +The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable` +and `DatReaderWriter.Types.CombatManeuver` directly. acdream already +references `Chorizite.DatReaderWriter`; the missing live-state piece is a +named `CombatTable` data-id on player/creature state. diff --git a/docs/research/2026-04-28-pes-pseudocode.md b/docs/research/2026-04-28-pes-pseudocode.md new file mode 100644 index 00000000..ecf7f876 --- /dev/null +++ b/docs/research/2026-04-28-pes-pseudocode.md @@ -0,0 +1,345 @@ +# Phase C.1 PES particle pseudocode + +Retail sources: + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` + - `ParticleEmitterInfo::{GetRandom*,InitEnd,ShouldEmitParticle,UnPack}` + at `0x005170d0..0x005179f0` + - `ParticleManager::{CreateParticleEmitter,DestroyParticleEmitter,StopParticleEmitter}` + at `0x0051b6c0..0x0051b7a0` + - `Particle::{Update,Init}` and `ParticleEmitter::{EmitParticle,UpdateParticles}` + at `0x0051b863..0x0051d400` + - `PhysicsScript::{UnPack}` at `0x005218b0` + - `CallPESHook::Execute`, `CreateParticleHook::Execute`, + `DestroyParticleHook::Execute`, `StopParticleHook::Execute` at + `0x00529eb0..0x0052a070` + - `GameSky::{Draw,CreateDeletePhysicsObjects}` at + `0x00506ff0..0x005075d0` +- `docs/research/named-retail/acclient.h` + - `EmitterType`, `ParticleType` + - `ParticleEmitterInfo`, `Particle`, `ParticleEmitter` + - `CreateParticleHook`, `CreateBlockingParticleHook`, + `DestroyParticleHook`, `StopParticleHook`, `CallPESHook` + - `CelestialPosition` with `pes_id` +- Cross-checks: + - `references/ACViewer/ACViewer/Physics/Particles/*` + - `references/ACE/Source/ACE.DatLoader/Entity/ParticleEmitterInfo.cs` + - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs` + +## ParticleEmitterInfo + +```text +UnPack(reader): + read id/header + read unknown + read emitter_type + read particle_type + read gfxobj_id + read hw_gfxobj_id + read birthrate + read max_particles + read initial_particles + read total_particles + read total_seconds + read lifespan + read lifespan_rand + read offset_dir, min_offset, max_offset + read A, min_a, max_a + read B, min_b, max_b + read C, min_c, max_c + read start_scale, final_scale, scale_rand + read start_trans, final_trans, trans_rand + read is_parent_local + +InitEnd(): + sorting_sphere.center = (0, 0, 0) + sorting_sphere.radius = max(max_offset, max_a * lifespan) + +RandomScale(base): + value = base + RollDice(-1, 1) * scale_rand + return clamp(value, 0.1, 10.0) + +RandomTrans(base): + value = base + RollDice(-1, 1) * trans_rand + return clamp(value, 0.0, 1.0) + +RandomLifespan(): + value = lifespan + RollDice(-1, 1) * lifespan_rand + return max(value, 0.0) + +RandomVector(dir, min, max): + return dir * Random(min, max) + +RandomOffset(): + v = random vector in [-1, 1]^3 + v = v - project(v, offset_dir) + if length(v) is near zero: + v = perpendicular fallback + v = normalize(v) + return v * Random(min_offset, max_offset) + +ShouldEmitParticle(emitter): + if total_particles != 0 and emitter.total_emitted >= total_particles: + return false + if emitter.num_particles >= max_particles: + return false + if emitter_type == BirthratePerSec: + return Timer.cur_time - emitter.last_emit_time > birthrate + if emitter_type == BirthratePerMeter: + delta = emitter.last_emit_offset - emitter.current_parent_offset + return dot(delta, delta) > birthrate * birthrate + return false +``` + +Notes: + +- Retail stores `birthrate` as seconds between emissions for + `BirthratePerSec`, not particles per second. +- Retail clamps start/final scale to `[0.1, 10]` and translucency to + `[0, 1]`. +- The named decomp shows final scale/trans add their own base values. + ACE/ACViewer have a few copy-paste mistakes in these helpers; the decomp + wins. + +## ParticleManager and emitter lifetime + +```text +CreateParticleEmitter(parent, emitter_info_id, part_index, offset, requested_id): + if requested_id != 0: + remove existing emitter with requested_id + info = Dat.Get(ParticleEmitterInfo, emitter_info_id) + emitter = makeParticleEmitter() + emitter.SetInfo(info) + emitter.SetParenting(parent, part_index, offset) + emitter.InitEnd() + emitter.id = requested_id if requested_id != 0 else next_emitter_id++ + particle_table.add(emitter.id, emitter) + return emitter.id + +DestroyParticleEmitter(id): + remove emitter id from particle_table + +StopParticleEmitter(id): + emitter.stopped = true + +UpdateParticles(): + for each emitter: + keep = emitter.UpdateParticles() + if !keep: + remove emitter +``` + +`ParticleEmitter::EmitParticle` finds a free/recyclable slot, samples all +random fields from the `ParticleEmitterInfo`, initializes a `Particle`, adds +the particle part, and records `total_emitted`, `last_emit_time`, and +`last_emit_offset`. + +`ParticleEmitter::UpdateParticles`: + +```text +if drawable/parent is valid: + for each live particle: + parent_frame = parent-local ? current parent frame : particle.start_frame + particle.Update(parent_frame, now, persistent) + if particle.lifetime >= particle.lifespan: + kill particle + + while !stopped and info.ShouldEmitParticle(this): + EmitParticle() + + if total_seconds != 0 and now - creation_time > total_seconds: + stopped = true + if total_particles != 0 and total_emitted >= total_particles: + stopped = true + +return num_particles != 0 || !stopped +``` + +## Particle integrators + +Every particle computes position from age/lifetime, not by accumulating +Euler steps. `parent.origin` below is the parent frame origin chosen by +`is_parent_local`. + +```text +age = now - birthtime + +Still: + pos = parent.origin + offset + +LocalVelocity, GlobalVelocity: + pos = parent.origin + offset + age * A + +ParabolicLVGA, ParabolicLVLA, ParabolicGVGA: + pos = parent.origin + offset + age * A + 0.5 * age^2 * B + +ParabolicLVGAGR, ParabolicLVLALR, ParabolicGVGAGR: + frame = parent + frame.origin += offset + age * A + 0.5 * age^2 * B + frame.rotate_by(age * C) + pos = frame.origin + +Swarm: + pos = parent.origin + offset + age * A + pos.x += cos(age * B.x) * C.x + pos.y += sin(age * B.y) * C.y + pos.z += cos(age * B.z) * C.z + +Explode: + pos.x = parent.origin.x + offset.x + (age * B.x + C.x * A.x) * age + pos.y = parent.origin.y + offset.y + (age * B.y + C.y * A.x) * age + pos.z = parent.origin.z + offset.z + (age * B.z + C.z * A.x + A.z) * age + +Implode: + pos = parent.origin + offset + cos(A.x * age) * C + age^2 * B +``` + +`Particle::Init` resolves vector spaces once at spawn: + +```text +offset = transform_local_vector(random_offset, start_frame) + +LocalVelocity, ParabolicLVGA: + A = local_to_global(A) + +ParabolicLVLA: + A = local_to_global(A) + B = local_to_global(B) + +ParabolicLVGAGR: + A = local_to_global(A) + C = C + +Swarm: + A = local_to_global(A) + +Explode: + A = A + B = B + C = normalized random direction scaled by the local C axes + +Implode: + A = A + B = B + offset *= C component-wise + C = offset + +ParabolicLVLALR: + A = local_to_global(A) + B = local_to_global(B) + C = local_to_global(C) + +ParabolicGVGA, GlobalVelocity: + A/B/C remain global as applicable + +ParabolicGVGAGR: + A and B remain global + C = C +``` + +After motion: + +```text +t = clamp(age / lifespan, 0, 1) +scale = lerp(start_scale, final_scale, t) +trans = lerp(start_trans, final_trans, t) +opacity = 1 - trans +``` + +`StartTrans` / `FinalTrans` are transparency values, not source alpha. +Retail sends the interpolated value to `PhysicsPart::SetTranslucency`; the +render path uses its complement as opacity. WorldBuilder's particle renderer +cross-check does the same (`opacity = 1 - currentTrans`). + +## PhysicsScript and hooks + +`PhysicsScript::UnPack` reads ordered `(start_time, hook)` entries and sorts +them by start time. The runner keeps active script instances keyed by +`(script_id, entity_id)` and fires all hooks whose `start_time <= elapsed`. + +Hook execution: + +```text +CreateParticleHook: + parent.create_particle_emitter(emitter_info_id, part_index, offset, emitter_id) + +CreateBlockingParticleHook: + same particle creation path, plus sequencer blocking semantics + +DestroyParticleHook: + parent.destroy_particle_emitter(emitter_id) + +StopParticleHook: + parent.stop_particle_emitter(emitter_id) + +CallPESHook: + parent.CallPES(pes_id, pause) +``` + +The C.1 implementation keeps hook dispatch in Core and renders the resulting +particles in App. Nested `CallPESHook` stays in `PhysicsScriptRunner`, while +`ParticleHookSink` converts create/destroy/stop hooks into runtime emitter +handles. + +## Sky integration + +`CelestialPosition` has both `gfx_id` and `pes_id`. Retail sky object +creation copies `properties` and draws two sky cells. A named-retail recheck +on 2026-04-28 corrected the original C.1 assumption: + +```text +SkyDesc::GetSky (0x00501ec0): + copy SkyObject.gfx_id into CelestialPosition.gfx_id + copy SkyObject.default_pes_object into CelestialPosition.pes_id + copy properties / rotate / arc angle / tex velocity + +GameSky.CreateDeletePhysicsObjects (0x005073c0): + for each visible CelestialPosition: + post_scene = (properties & 0x01) != 0 + make/update sky gfx object from gfx_id in before/after cell + do not read pes_id + +GameSky.MakeObject (0x00506ee0): + CPhysicsObj::makeObject(gfx_id, 0, 0) + set texture velocity + +GameSky.UseTime (0x005075b0): + CreateDeletePhysicsObjects() + CalcFrame() + set_frame / luminosity / diffusion / translucency + do not read pes_id + +GameSky.Draw(post_scene): + if post_scene == false: + draw before_sky_cell + else: + draw after_sky_cell +``` + +The sky renderer must preserve the existing `0x01` pre/post split for sky +meshes. `SkyObject.default_pes_object` is parsed and retained for diagnostics, +but it is not a retail render-path particle source. In acdream the experimental +sky-PES path is therefore gated behind `ACDREAM_ENABLE_SKY_PES=1` and disabled +for normal visual comparison. + +## GL rendering + +WorldBuilder's `ParticleBatcher` confirms the GL-side policy: + +```text +collect live billboard instances +sort back-to-front by camera distance for alpha blending +depth test enabled +depth writes disabled +cull disabled +blend SrcAlpha/OneMinusSrcAlpha for alpha +blend SrcAlpha/One for additive +stream dynamic instance VBO +draw instanced unit quads +``` + +C.1 keeps that policy and splits draw calls by particle render pass: + +- pre-scene sky particles after the pre-scene sky meshes +- scene particles after opaque world/static objects +- post-scene sky particles after post-scene sky/weather meshes diff --git a/docs/research/2026-04-28-remote-moveto-pseudocode.md b/docs/research/2026-04-28-remote-moveto-pseudocode.md new file mode 100644 index 00000000..19ec7c9a --- /dev/null +++ b/docs/research/2026-04-28-remote-moveto-pseudocode.md @@ -0,0 +1,285 @@ +# Phase L.1c — Remote MoveTo body-driver pseudocode + +**Date**: 2026-04-28 +**Goal**: Port the minimum viable subset of retail `MoveToManager` so the body +position of server-controlled chasing creatures (movementType 6/7) tracks the +server-supplied destination smoothly, instead of freezing at zero velocity +between sparse `UpdatePosition` snaps. + +## Problem (root cause from systematic-debugging Phase 1) + +The 882a07c stabilizer holds `rm.Body.Velocity = 0` while `ServerMoveToActive` +is true, on the principle "do not let `apply_current_movement` free-run with +incomplete MoveTo state." The state IS incomplete: our parser at +[`UpdateMotion.cs:280-290`](../../src/AcDream.Core.Net/Messages/UpdateMotion.cs) +keeps only `speed`/`runRate`/flags from the 7-DWORD `MovementParameters` +block and the `runRate` trailer, **discarding** `Origin (destination)`, +`targetGuid` (type 6 only), `distance_to_object`, `min_distance`, +`fail_distance`, `walk_run_threshhold`, and `desired_heading`. + +Symptoms (live log + user observation 2026-04-28): +- **Disappearing**: body frozen at `Velocity=0` while RunForward animation + plays; next UpdatePosition teleports body to actual server pose. If the + teleport target is outside the visible window, observer sees disappear/reappear. +- **Jitter**: when a stale UP-derived velocity exists, body extrapolates along + the OLD heading; meanwhile the server is steering the creature on a curve. + Each new UP snap-corrects → visible stutter. + +The fresh MoveTo packet stream (~1 Hz, seq 0x01FE→0x0204 in the live log) IS +sending fresh target positions and headings each tick — we're throwing them +away. + +## Retail behavior (named decomp + ACE port) + +Sources: +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — citations below +- `docs/research/named-retail/acclient.h` — struct definitions +- `references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs` +- `references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs` + +### Wire layout (`MovementParameters::UnPackNet` @ `0x0052ac50`, type 6/7) + +``` +[uint targetGuid] // type 6 only (MoveToObject) +Origin: uint cellId // then 3 floats local x/y/z + float x, y, z // destination position +MovementParameters (28 bytes, exact retail order): + uint flags // bitfield (see below) + float distance_to_object // arrival far-bound (ACE default 0.6) + float min_distance // arrival near-bound + float fail_distance // abort when starting→current >= this + float speed // base speed multiplier + float walk_run_threshhold // (sic, two h's) — wire default 15.0 + float desired_heading // final orientation (radians or degrees) +float runRate // CMotionInterp::my_run_rate copy +``` + +### MovementParameters bit-flags (declaration order, acclient.h:31423-31443) + +| Bit | Mask | Name | Meaning | +|----:|---------|------|---------| +| 0 | 0x00001 | can_walk | gait permission | +| 1 | 0x00002 | can_run | gait permission (we already use this for `MoveToCanRun`) | +| 2 | 0x00004 | can_sidestep | enables strafe path | +| 3 | 0x00008 | can_walk_backwards | gait permission | +| 4 | 0x00010 | can_charge | force HoldKey_Run | +| 5 | 0x00020 | fail_walk | fail if only walk possible | +| 6 | 0x00040 | use_final_heading | append final TurnToHeading after arrival | +| 7 | 0x00080 | sticky | MoveToObject only — StickTo on completion | +| 8 | 0x00100 | move_away | flee target | +| 9 | 0x00200 | move_towards | chase target (chase creatures set this) | +| 10 | 0x00400 | use_spheres | use cylinder distance vs straight-line | +| 11 | 0x00800 | set_hold_key | apply HoldKeyToApply | +| ... | ... | ... | (autonomous, modify_*_state, cancel_moveto, stop_completely, disable_jump) | + +### MoveToManager::HandleMoveToPosition (per-tick, `0x00529d80` lines 307187-307440) + +``` +if physics.motions_pending: + cancel any aux turn cmd (let the queued motion complete) +else: + targetWorld = currentTargetPosition // last server-supplied destination + desiredHeading = atan2(targetWorld - body.position) + get_desired_heading(currentCmd) + headingDelta = normalize(desiredHeading - body.heading) + if |headingDelta| <= 20°: // retail tolerance + // ACE adds set_heading(target, true) here (server-tic-rate fudge) + cancel any aux turn cmd + else: + edi = (headingDelta < 180°) ? TurnLeft : TurnRight + if edi != auxCommand: + _DoMotion(edi) // -> CMotionInterp + auxCommand = edi +dist = GetCurrentDistance() +if CheckProgressMade(dist): + if !movingAway and dist <= min_distance: // arrived + popHeadNode(); _StopMotion(currentCmd); _StopMotion(auxCommand); BeginNextNode() + if movingAway and dist >= distance_to_object: + popHeadNode(); ... + if !movingAway and Position.distance(starting, current) >= fail_distance: + CancelMoveTo(0x3d) // YouChargedTooFar +``` + +### Key insight: MoveToManager does NOT touch the body directly + +Every motion start/stop is dispatched through `CMotionInterp::DoInterpretedMotion` +(via `_DoMotion`/`_StopMotion`). The body's actual position evolves via the +ordinary physics tick (`PhysicsBody::UpdatePhysicsInternal`). MoveToManager is +purely a *planner* sitting above CMotionInterp, deciding *which command* (and +which auxiliary turn) the body should be running at any given tick. + +## Acdream port — minimum viable subset + +The server re-emits MoveTo packets ~1 Hz with fresh destinations, so we can +skip: +- `MoveToObject_Internal` target-tracking (`HandleUpdateTarget`) — server does it +- Sticky / `PositionManager::StickTo` +- `CheckProgressMade` stall detection — server cancels the move +- `fail_distance` / `WeenieError.YouChargedTooFar` — server-side concern +- `WeenieObj::OnMoveComplete` callback +- Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as + a fresh single-step plan) + +We DO need: +1. **Parser**: extract the discarded fields into `ServerMotionState`. +2. **Per-tick steer**: compute heading-to-destination, turn body orientation + toward it (snap when within ±20° per ACE's tic-rate fudge), then *allow* + `apply_current_movement` to run — which sets `Body.Velocity` from the + active RunForward cycle, oriented along the now-correct heading. +3. **Arrival**: when `dist <= distance_to_object`, switch animation to Ready + and clear `ServerMoveToActive`. Server's next MoveTo packet will resume. + +## Pseudocode — acdream port + +### Parser change (`UpdateMotion.TryParseMoveToPayload`) + +``` +TryParseMoveToPayload(body, pos, mt, out parsed): + if mt == 6: + if rem < 4: return false + parsed.TargetGuid = ReadU32; pos += 4 + + if rem < 16: return false + parsed.OriginCellId = ReadU32; pos += 4 + parsed.OriginX = ReadF32; pos += 4 + parsed.OriginY = ReadF32; pos += 4 + parsed.OriginZ = ReadF32; pos += 4 + + if rem < 28: return false + parsed.Flags = ReadU32; pos += 4 + parsed.DistanceToObject = ReadF32; pos += 4 + parsed.MinDistance = ReadF32; pos += 4 + parsed.FailDistance = ReadF32; pos += 4 + parsed.Speed = ReadF32; pos += 4 + parsed.WalkRunThreshold = ReadF32; pos += 4 + parsed.DesiredHeading = ReadF32; pos += 4 + + if rem < 4: return false + parsed.RunRate = ReadF32 + return true +``` + +### Per-tick driver (new `RemoteMoveToDriver` in `AcDream.Core.Physics`) + +``` +DriveOneTick(rm, dt): + if not rm.HasMoveToDestination: return ApplyDefault + + targetWorld = rm.MoveToDestinationWorld // pre-converted at packet time + bodyPos = rm.Body.Position + + // Distance check first — arrival short-circuits before any heading work + dist = horizontalDistance(targetWorld, bodyPos) + if dist <= rm.MoveToMinDistance + 0.05 (epsilon for float wobble): + rm.HasMoveToDestination = false + // animation cycle moves to Ready via the existing + // ApplyServerControlledVelocityCycle path on next zero-velocity sample + rm.Body.Velocity = Vector3.Zero + return Arrived + + // Heading compute (XY plane; Z untouched — server owns Z) + deltaXY = (targetWorld.XY - bodyPos.XY).Normalized + desiredHeading = atan2(deltaXY) // radians + currentHeading = QuaternionToYaw(rm.Body.Orientation) + headingDelta = wrapPi(desiredHeading - currentHeading) + + // Snap orientation toward target — match ACE's set_heading(target, true) + // when within tolerance, otherwise rotate at retail-faithful turn rate. + const float tolerance = 20° (in radians) + if |headingDelta| <= tolerance: + rm.Body.Orientation = QuaternionFromYaw(desiredHeading) + else: + // retail TurnSpeed default ≈ π/2 rad/s for monsters; clamp by dt + float maxStep = TurnRateRadPerSec * dt + float step = clamp(headingDelta, -maxStep, +maxStep) + rm.Body.Orientation = QuaternionFromYaw(currentHeading + step) + + // Allow apply_current_movement to set Velocity from RunForward cycle. + // The cycle was already seeded by PlanMoveToStart at packet receipt + // and is being played by the AnimationSequencer. CMotionInterp's + // apply_current_movement reads InterpretedState.ForwardCommand and + // sets Body.Velocity = (forward axis of orientation) * RunAnimSpeed * speedMod. + return DriveActive // caller now invokes apply_current_movement +``` + +### Integration in `GameWindow.OnUpdateMotion` (movementType 6/7 branch) + +``` +on receipt of MoveTo packet: + // existing code already seeds the animation cycle via PlanMoveToStart + // NEW: store world-converted destination + thresholds on rmState + lbX = (originCellId >> 24) & 0xFF + lbY = (originCellId >> 16) & 0xFF + origin = ((lbX - liveCenterX) * 192, (lbY - liveCenterY) * 192, 0) + rmState.MoveToDestinationWorld = (originX, originY, originZ) + origin + rmState.MoveToMinDistance = parsed.MinDistance + rmState.MoveToDistanceToObject = parsed.DistanceToObject + rmState.HasMoveToDestination = true + // ServerMoveToActive remains set; existing +``` + +### Integration in per-tick remote update (`GameWindow.cs` ~line 5045) + +``` +// Replace the current Velocity = Zero hold with: +else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination) +{ + var driveResult = RemoteMoveToDriver.DriveOneTick(rm, dt); + if driveResult == Arrived: + // signal cycle update to Ready via existing path + ApplyServerControlledVelocityCycle(serverGuid, ae, rm, Vector3.Zero); + else: + rm.Body.TransientState |= Contact | OnWalkable | Active + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); +} +else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) +{ + // No destination yet (very early frame, packet hasn't fully landed) + rm.Body.Velocity = Vector3.Zero; +} +else +{ + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); +} +``` + +## Conformance test cases + +1. **Parser round-trip — type 7 (MoveToPosition)** + - Synthesize a 68-byte body with known origin + 7 params + runRate. + - Assert all 9 new fields decode correctly. + +2. **Parser round-trip — type 6 (MoveToObject)** + - Synthesize a 72-byte body with target guid + origin + params + runRate. + - Assert TargetGuid populated and shifts subsequent fields by 4 bytes. + +3. **DriveOneTick — heading snap within tolerance** + - body at (0,0,0) facing east, destination (10,0,0). + - DesiredHeading=0; current=0; |delta|=0 ≤ 20° → snap. + - assert orientation unchanged (already correct). + +4. **DriveOneTick — heading turn beyond tolerance** + - body at (0,0,0) facing east, destination (0,10,0). + - desiredHeading=π/2; current=0; |delta|=π/2 > 20°. + - dt=0.1s, TurnRate=π/2 → step = π/4 toward target. + - assert orientation rotated by π/4 (not full snap). + +5. **DriveOneTick — arrival** + - body at (0,0,0), destination (0.4,0,0), MinDistance=0.6. + - assert HasMoveToDestination cleared and Velocity zeroed. + +6. **Bit-flag mapping** (already partially tested via `MoveToCanRun`) + - assert flag 0x00200 (move_towards) is detected as `MoveTowards=true`. + +## Out of scope (future Phase L.1d if needed) + +- Sticky / StickTo for MoveToObject completion +- `use_final_heading` (post-arrival turn-to-heading) +- `fail_distance` early-cancel (server already does this; we just don't flag it) +- `CheckProgressMade` stall detector +- Strafe / move_away / move_towards-and-away combo (`towards_and_away` helper) +- Sphere-cylinder distance (`use_spheres` bit) +- `MoveToObject` target-guid resolution — currently we only honor the Origin, + which works because the server re-emits with refreshed Origin each tick. + If the target is moving fast and the server's emit cadence falls behind, + we'd see lag; a future enhancement is to look up the target entity by + guid and use its current world position when fresher than Origin. diff --git a/docs/research/2026-04-28-sky-cloud-material-trace.md b/docs/research/2026-04-28-sky-cloud-material-trace.md new file mode 100644 index 00000000..543495e6 --- /dev/null +++ b/docs/research/2026-04-28-sky-cloud-material-trace.md @@ -0,0 +1,97 @@ +# 2026-04-28 Sky Cloud Material Trace + +Context: Phase C.1 originally treated the Rainy/Cloudy sky visual as a +SkyObject PES problem. Retail named-decomp and dat inspection disprove that +for the broad cloud/ray layer. + +## Retail Trace + +- `LScape::draw` (`0x00506330`) calls `GameSky::Draw(0)` before terrain and + `GameSky::Draw(1)` after terrain. +- `SkyDesc::GetSky` copies `pes_id`, but `GameSky::CreateDeletePhysicsObjects` + compares/replaces only `gfx_id` and calls `GameSky::MakeObject(gfx_id, ...)`. + The sky object PES id is not part of retail `GameSky` rendering. +- `GameSky::UseTime` applies keyframe replace fields to instantiated sky + objects: + - `0x005076e1`: `CPhysicsObj::SetLuminosity(luminosity * 0.01)` + - `0x00507715`: `CPhysicsObj::SetDiffusion(max_bright * 0.01)` + - `0x00507747`: `CPhysicsObj::SetTranslucency(transparent * 0.01)` +- `CMaterial::SetTranslucencySimple` (`0x005396f0`) writes material alpha as + `1 - translucency`. +- `CMaterial::SetDiffuseSimple` (`0x00539750`) writes material diffuse RGB. + Therefore `SkyObjectReplace.MaxBright` is diffuse, not an emissive cap. +- `D3DPolyRender::SetSurface` (`0x0059c4d0`) disables fixed-function fog alpha + whenever the raw `SurfaceType.Additive` bit is set (`0x0059c882`), even when + the earlier `Translucent + ClipMap` branch forces normal alpha blending. + +## Dat Trace + +The broad Rainy/Cloudy layer is `GfxObj 0x01004C35`, not one of the tiny +`0x020xxxxx` setup anchors: + +- `0x01004C35`: huge sky mesh, bbox roughly `20175 x 20175 x 1180`, UVs tile + across the sheet. +- Surface `0x08000023`: `Base1ClipMap | Translucent | Alpha | Additive` + (`0x00010114`), `Translucency=0.25`, `Luminosity=0`, `Diffuse=1`. +- Texture `0x060037AF`: 256x256 A8R8G8B8 cloud/ray texture. + +The setup ids observed in Rainy groups (`0x02000588`, `0x02000589`, +`0x02000BA6`, `0x02000714`) are one-part dummy anchors with tiny `0x010001EC` +geometry and default scripts/PES for sounds/flashes. They are not the broad +cloud layer. + +## Port Consequences + +- Keep per-SkyObject PES rendering debug-only until another retail path proves + it is used. +- Render `0x08000023` as final alpha blend because retail's translucent/clipmap + branch overrides the raw additive blend. +- Still disable sky fog for that surface because retail keys fog-alpha disable + off the raw `Additive` bit. +- Route `MaxBright` to diffuse (`uDiffuseFactor`) and `Luminosity` to emissive. +- Use a final opacity multiplier for material/surface transparency before the + fragment alpha write; dynamic keyframe transparency remains `1 - value`. + +## WorldBuilder Cross-Check + +Cloned upstream `https://github.com/Chorizite/WorldBuilder.git` at commit +`167788be6fce65f5ebe79eef07a0b7d28bd7aa81`. Its +`Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` renders sky objects +camera-centered with depth off, but it is not a faithful retail oracle for sky +tint: `GameScene.cs` has the skybox render call commented out, the manager +always selects `DayGroups[0]`, and it uploads `SunlightColor = Vector3.Zero` +/ `AmbientColor = Vector3.One` for sky. `RegionInfo.cs` interpolates +DayGroup[0] lighting for terrain/world objects, not the active retail +DayGroup/weather sky. + +That explains why WorldBuilder cannot answer the missing green/purple Rainy +sky tint directly. The actionable lesson is narrower: do not fog-paint the +raw-additive cloud sheet itself. In acdream, non-additive sky layers now receive +the keyframe fog tint so the broad background wash appears behind clouds, while +surfaces with the raw Additive bit (notably `0x08000023`) keep fixed-function +fog disabled and preserve the pink cloud/ray detail. + +WorldBuilder's regular object path does collect `Setup.DefaultScript` +particle hooks (`ObjectMeshManager.CollectEmittersFromScript`) and instantiates +them via `ObjectRenderManagerBase`, but its skybox manager does not use that +setup/particle path for SkyObjects. Dat inspection also showed the canonical +Rainy default script target `0x3300042C` is a sound-loop chain (`SoundTweaked` ++ `CallPES`), not the broad green tint or cloud ray layer. + +Additional renderer lessons from upstream WorldBuilder: + +- Particle blend is material-derived. `ParticleEmitterInfo` does not carry an + additive flag; WorldBuilder reads `ObjectRenderData.Batches[0].IsAdditive` + from the particle GfxObj surface. acdream now leaves DAT emitters non-additive + by default and resolves particle blend from the selected particle surface. +- Particles must be globally sorted back-to-front before drawing. Sorting only + inside per-texture dictionaries can reorder translucent particles whenever + multiple textures/blend states are active. +- Particle quads come from the authored particle GfxObj bounds. Degenerate + extents fall back to `1.0`, and point-sprite degrade mode applies a `0.9` + base scale. +- Texture decoding must try highres `RenderSurface` records after portal lookup + and must zero alpha for black pixels on compressed clipmap textures. +- WorldBuilder tracks UV wrap and cull mode per object batch. acdream's sky path + already uses authored UV wrap, but shared object rendering still needs the + same metadata carried through a later C.4 pass. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 874aa941..73ccb1c5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -10,6 +10,8 @@ namespace AcDream.App.Rendering; public sealed class GameWindow : IDisposable { + private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene); + private readonly string _datDir; private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; @@ -26,6 +28,7 @@ public sealed class GameWindow : IDisposable private InstancedMeshRenderer? _staticMesh; private Shader? _meshShader; private TextureCache? _textureCache; + private SamplerCache? _samplerCache; private DebugLineRenderer? _debugLines; // K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder // wireframes are noisy outdoors and confuse first-time users into @@ -152,7 +155,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Audio.AudioHookSink? _audioSink; // Phase E.3 particles. - private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new(); + private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry; private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleHookSink? _particleSink; // Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754) @@ -160,6 +163,13 @@ public sealed class GameWindow : IDisposable // sounds, light toggles) at their StartTime offsets. private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; + // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but + // never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime. + // Keep the experimental path available for DAT archaeology only. + private readonly bool _enableSkyPesDebug = + string.Equals(Environment.GetEnvironmentVariable("ACDREAM_ENABLE_SKY_PES"), "1", StringComparison.Ordinal); + private readonly HashSet _activeSkyPes = new(); + private readonly HashSet _missingSkyPes = new(); // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when @@ -216,6 +226,71 @@ public sealed class GameWindow : IDisposable /// Last known server position — kept for diagnostics / HUD. public System.Numerics.Vector3 LastServerPos; /// + /// Latest server-authoritative velocity for NPC/monster smoothing. + /// Prefer the HasVelocity vector from UpdatePosition; when ACE omits + /// it for a server-controlled creature, derive it from consecutive + /// authoritative positions instead of guessing from player RUM state. + /// + public System.Numerics.Vector3 ServerVelocity; + public bool HasServerVelocity; + /// + /// True while a server MoveToObject/MoveToPosition packet is the + /// active locomotion source. Retail runs these through MoveToManager + /// and CMotionInterp; the per-tick remote driver consults this to + /// decide whether to feed body steering through + /// instead of + /// the InterpretedMotionState path. + /// + public bool ServerMoveToActive; + + /// + /// True once a MoveTo packet's full path payload (Origin + thresholds) + /// has been parsed and the world-converted destination is stored on + /// . Cleared on arrival or when + /// the next non-MoveTo UpdateMotion replaces the locomotion source. + /// Phase L.1c (2026-04-28). + /// + public bool HasMoveToDestination; + + /// + /// World-space destination from the most recent MoveTo packet's + /// Origin field, converted via the same landblock-grid + /// arithmetic OnLivePositionUpdated uses. + /// + public System.Numerics.Vector3 MoveToDestinationWorld; + + /// + /// min_distance from the MoveTo packet's MovementParameters. + /// Used by as + /// the chase-arrival threshold per retail + /// MoveToManager::HandleMoveToPosition. + /// + public float MoveToMinDistance; + + /// + /// distance_to_object from the MoveTo packet. Reserved for + /// the flee branch (move_away); chase uses + /// . + /// + public float MoveToDistanceToObject; + + /// + /// True if MovementParameters bit 9 (move_towards, mask + /// 0x200) is set on the active packet — i.e. this is a + /// chase. False = flee (move_away) or static target. + /// + public bool MoveToMoveTowards; + + /// + /// Seconds-since-epoch timestamp of the most recent MoveTo packet + /// for this entity. Used by the per-tick driver to give up + /// steering when no refresh has arrived for + /// + /// — typically because the entity left our streaming view and + /// the server stopped broadcasting its MoveTo updates. + /// + public double LastMoveToPacketTime; + /// /// Legacy field — no longer used for slerp (retail hard-snaps /// per FUN_00514b90 set_frame). Kept to avoid churn. /// @@ -522,6 +597,13 @@ public sealed class GameWindow : IDisposable /// keys the render list; this parallel dictionary keys by server guid. /// private readonly Dictionary _entitiesByServerGuid = new(); + private readonly Dictionary _liveEntityInfoByGuid = new(); + private uint? _selectedTargetGuid; + private readonly record struct LiveEntityInfo( + string? Name, + AcDream.Core.Items.ItemType ItemType); + private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; + private const double ServerControlledVelocityStaleSeconds = 0.60; private int _liveSpawnReceived; // diagnostics private int _liveSpawnHydrated; private int _liveDropReasonNoPos; @@ -785,12 +867,13 @@ public sealed class GameWindow : IDisposable _dats = new DatCollection(_datDir, DatAccessType.Read); _animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats); + _emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats); // Phase E.3 particles: always-on, no driver dependency. Registered // with the hook router so CreateParticle / DestroyParticle / // StopParticle hooks fired from motion tables produce visible // spawns. The Tick call is driven from OnRender. - _particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry); + _particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!); _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem); _hookRouter.Register(_particleSink); @@ -1200,6 +1283,12 @@ public sealed class GameWindow : IDisposable _surfaceCache = new Dictionary(); _textureCache = new TextureCache(_gl, _dats); + // Two persistent GL sampler objects (Repeat + ClampToEdge) so + // the sky pass can pick wrap mode per submesh without mutating + // shared per-texture wrap state. See SamplerCache + the + // WorldBuilder reference at + // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. + _samplerCache = new SamplerCache(_gl); _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache); // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -1209,13 +1298,13 @@ public sealed class GameWindow : IDisposable Path.Combine(shadersDir, "sky.vert"), Path.Combine(shadersDir, "sky.frag")); _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( - _gl, _dats, skyShader, _textureCache); + _gl, _dats, skyShader, _textureCache, _samplerCache); // Phase G.1 particle renderer — renders rain / snow / spell auras // spawned into the shared ParticleSystem as billboard quads. // Weather uses AttachLocal emitters so the rain volume follows // the player. - _particleRenderer = new ParticleRenderer(_gl, shadersDir); + _particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats); // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. // Parse runtime radius from environment (default 2 → 5×5 window). @@ -1298,6 +1387,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"live: connecting to {endpoint} as {user}"); _liveSession = new AcDream.Core.Net.WorldSession(endpoint); _liveSession.EntitySpawned += OnLiveEntitySpawned; + _liveSession.EntityDeleted += OnLiveEntityDeleted; _liveSession.MotionUpdated += OnLiveMotionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.VectorUpdated += OnLiveVectorUpdated; @@ -1649,20 +1739,7 @@ public sealed class GameWindow : IDisposable // For a respawn, drop the previous rendering state here before we // build the new one. `_entitiesByServerGuid` is the canonical map, // its value is the live WorldEntity we need to dispose. - if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity)) - { - _worldState.RemoveEntityByServerGuid(spawn.Guid); - _worldGameState.RemoveById(existingEntity.Id); - _animatedEntities.Remove(existingEntity.Id); - // Physics collision registry entry is keyed by local id too. - _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); - // Dead-reckon state is keyed by SERVER guid (not local id) so we - // clear using the same guid the new spawn will use. Leaving old - // SnapResidual / DeadReckonedPos in would make the next first - // UpdatePosition look like a 2m-residual soft-snap. - _remoteDeadReckon.Remove(spawn.Guid); - _remoteLastMove.Remove(spawn.Guid); - } + RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false); // Log every spawn that arrives so we can inventory what the server // sends (including the ones we can't render yet). The Name field @@ -1674,12 +1751,19 @@ public sealed class GameWindow : IDisposable : "no-pos"; string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; + string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype"; int animPartCount = spawn.AnimPartChanges?.Count ?? 0; int texChangeCount = spawn.TextureChanges?.Count ?? 0; int subPalCount = spawn.SubPalettes?.Count ?? 0; Console.WriteLine( $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + - $"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); + $"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); + + _liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo( + spawn.Name, + spawn.ItemType is { } rawItemType + ? (AcDream.Core.Items.ItemType)rawItemType + : AcDream.Core.Items.ItemType.None); // Target the statue specifically for full diagnostic dump: Name match // is cheap and gives us exactly one entity's worth of log regardless @@ -2028,9 +2112,64 @@ public sealed class GameWindow : IDisposable if (mtable is not null) { sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader); - uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle; - uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u; - sequencer.SetCycle(seqStyle, seqMotion); + uint seqStyle = stanceOverride is > 0 + ? (0x80000000u | (uint)stanceOverride.Value) + : (uint)mtable.DefaultStyle; + uint seqMotion; + if (commandOverride is > 0) + { + uint resolved = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(commandOverride.Value); + seqMotion = resolved != 0 + ? resolved + : (0x40000000u | (uint)commandOverride.Value); + } + else + { + seqMotion = AcDream.Core.Physics.MotionCommand.Ready; + } + + // Phase L.1c followup (2026-04-28): apply the same + // missing-cycle fallback the OnLiveMotionUpdated path + // uses. Without this, a monster spawned in combat + // stance with the wire's seqMotion absent from its + // MotionTable hits ClearCyclicTail() with no + // replacement enqueue, every body part snaps to its + // setup-default offset, and the visual collapses to + // "torso on the ground" — visible to acdream + // observers when another client is in combat with a + // monster, until the first OnLiveMotionUpdated UM + // applies the same fallback there. + uint spawnCycle = seqMotion; + if (!sequencer.HasCycle(seqStyle, spawnCycle)) + { + uint origCycle = spawnCycle; + // RunForward → WalkForward → Ready + if ((spawnCycle & 0xFFu) == 0x07 + && sequencer.HasCycle(seqStyle, 0x45000005u)) + { + spawnCycle = 0x45000005u; + } + else if (sequencer.HasCycle(seqStyle, 0x41000003u)) + { + spawnCycle = 0x41000003u; + } + else + { + spawnCycle = 0; + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + Console.WriteLine( + $"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " + + $"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " + + $"→ fallback=0x{spawnCycle:X8}"); + } + } + + if (spawnCycle != 0) + sequencer.SetCycle(seqStyle, spawnCycle); } } } @@ -2135,6 +2274,41 @@ public sealed class GameWindow : IDisposable } } + private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete) + { + if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true) + && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + Console.WriteLine( + $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); + } + } + + private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete) + { + if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) + return false; + + _worldState.RemoveEntityByServerGuid(serverGuid); + _worldGameState.RemoveById(existingEntity.Id); + _animatedEntities.Remove(existingEntity.Id); + _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); + + // Dead-reckon state is keyed by SERVER guid (not local id) so we + // clear using the same guid the next spawn/update would use. + _remoteDeadReckon.Remove(serverGuid); + _remoteLastMove.Remove(serverGuid); + _liveEntityInfoByGuid.Remove(serverGuid); + _entitiesByServerGuid.Remove(serverGuid); + if (_selectedTargetGuid == serverGuid) + _selectedTargetGuid = null; + + if (logDelete) + _lightingSink?.UnregisterOwner(existingEntity.Id); + + return true; + } + /// /// Phase 6.6: the server says an entity's motion has changed. Look up /// the AnimatedEntity for that guid, re-resolve the idle cycle with the @@ -2169,11 +2343,13 @@ public sealed class GameWindow : IDisposable && update.Guid != _playerServerGuid) { string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null"; - float spd = update.MotionState.ForwardSpeed ?? 0f; + float spd = update.MotionState.ForwardSpeed + ?? ((update.MotionState.MoveToSpeed ?? 0f) + * (update.MotionState.MoveToRunRate ?? 0f)); uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0; uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; Console.WriteLine( - $"UM guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " + + $"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " + $"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}"); } @@ -2214,10 +2390,27 @@ public sealed class GameWindow : IDisposable // command == null → retail stop signal → Ready // command.Value == 0 → explicit 0 (rare) → Ready // otherwise → resolve class byte and use full cmd + float speedMod = update.MotionState.ForwardSpeed ?? 1f; uint fullMotion; - if (!command.HasValue || command.Value == 0) + if ((!command.HasValue || command.Value == 0) + && update.MotionState.IsServerControlledMoveTo) + { + // Retail MoveToManager::BeginMoveForward calls + // MovementParameters::get_command (0x0052AA00), then + // _DoMotion -> adjust_motion. With CanRun and enough + // distance, WalkForward + HoldKey_Run becomes RunForward, + // and CMotionInterp::apply_run_to_command (0x00527BE0) + // multiplies speed by the packet's runRate. + var seed = AcDream.Core.Physics.ServerControlledLocomotion + .PlanMoveToStart( + update.MotionState.MoveToSpeed ?? 1f, + update.MotionState.MoveToRunRate ?? 1f, + update.MotionState.MoveToCanRun); + fullMotion = seed.Motion; + speedMod = seed.SpeedMod; + } + else if (!command.HasValue || command.Value == 0) { - // Stop — return to the style's default substate (Ready). fullMotion = 0x41000003u; } else @@ -2245,8 +2438,6 @@ public sealed class GameWindow : IDisposable // apply_run_to_command). Treating zero as "unspecified / 1.0" // produces "slow walk that never stops" — exactly what the // stop bug looked like. - float speedMod = update.MotionState.ForwardSpeed ?? 1f; - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" && update.Guid != _playerServerGuid) Console.WriteLine( @@ -2278,6 +2469,125 @@ public sealed class GameWindow : IDisposable } else { + var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion); + bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action + or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier + or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote; + bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck) + && rmCheck.Airborne; + + // Retail MotionTable::GetObjectSequence routes action-class + // ForwardCommand values (creature attacks, chat-emotes) through + // the Action branch, where the swing is appended before the + // current cyclic tail and currState.Substate remains Ready. + // Treating 0x10000051/52/53 as SetCycle commands made the + // immediate follow-up Ready packet abort the swing. + // Phase L.1c followup (2026-04-28): the next two state-update + // blocks are LIFTED out of the substate-only `else` branch so + // they run for BOTH overlay (Action/Modifier/ChatEmote) and + // substate (Walk/Run/Ready/etc) packets. Two separate research + // agents converged on the same root cause for the user- + // observed "creature just runs instead of attacking" symptom: + // + // 1. Attack swings arrive as mt=0 with + // ForwardCommand=AttackHigh1 (Action class). Retail's + // CMotionInterp::move_to_interpreted_state + // (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies + // forward_command from the wire into the body's + // InterpretedState UNCONDITIONALLY. With + // forward_command=AttackHigh1, get_state_velocity + // returns 0 because its gate is RunForward||WalkForward + // — body stops moving forward. + // + // 2. The acdream overlay branch was routing through + // PlayAction (animation overlay) but skipping ALL of: + // - ServerMoveToActive flag update + // - MoveToPath capture + // - InterpretedState.ForwardCommand assignment + // So during a swing UM, the body's InterpretedState + // stayed at RunForward from the prior MoveTo packet, + // ServerMoveToActive stayed true, and the per-tick + // remote driver kept steering + applying RunForward + // velocity through every frame. + // + // Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState + // here because the latter is a heuristic that ONLY handles + // WalkForward / RunForward / WalkBackward / SideStep / Turn + // / Ready (MotionInterpreter.cs:941-970). For an Action + // command (e.g. AttackHigh1 = 0x10000062) the switch falls + // through and InterpretedState is silently NOT updated — + // exactly the bug we are fixing. Direct field assignment + // matches retail's copy_movement_from bulk-copy + // (acclient_2013_pseudo_c.txt:293301-293311). + if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) + { + remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; + + // Bulk-copy the wire's resolved ForwardCommand + speed + // into InterpretedState UNCONDITIONALLY (overlay, + // substate, AND MoveTo packets). Matches retail's + // copy_movement_from semantics + // (acclient_2013_pseudo_c.txt:293301-293311) which does + // not filter by MovementType. + // + // For MoveTo packets, fullMotion is the RunForward seed + // from PlanMoveToStart, so this populates + // ForwardCommand=RunForward + ForwardSpeed=speed*runRate + // — what the OLD substate-only DoInterpretedMotion call + // (commit f794832 removed) used to set. Without it, + // apply_current_movement reads the default + // ForwardCommand=Ready and produces zero velocity, so + // chasing creatures only translate via UpdatePosition + // hard-snaps and at spawn appear posed at default + // (visible as "torso on the ground" until the first UP + // snap hits). + // + // For overlay (Action) packets this sets ForwardCommand + // to the Attack/Twitch/etc command, and + // get_state_velocity returns 0 because the gate is + // RunForward||WalkForward — body stops moving forward. + remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; + remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod; + + if (update.MotionState.IsServerControlledMoveTo + && update.MotionState.MoveToPath is { } path) + { + remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver + .OriginToWorld( + path.OriginCellId, + path.OriginX, + path.OriginY, + path.OriginZ, + _liveCenterX, + _liveCenterY); + remoteMot.MoveToMinDistance = path.MinDistance; + remoteMot.MoveToDistanceToObject = path.DistanceToObject; + remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards; + remoteMot.HasMoveToDestination = true; + remoteMot.LastMoveToPacketTime = + (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + } + else if (!update.MotionState.IsServerControlledMoveTo) + { + // Off MoveTo — clear stale destination so the per-tick + // driver doesn't keep steering. + remoteMot.HasMoveToDestination = false; + } + } + + if (forwardIsOverlay) + { + if (!remoteIsAirborne) + { + AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand( + ae.Sequencer, + fullStyle, + fullMotion, + speedMod <= 0f ? 1f : speedMod); + } + } + else + { // Pick which cycle to play on the sequencer. Priority: // 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk. // 2. Else sidestep cmd if active — legs strafe. @@ -2325,10 +2635,63 @@ public sealed class GameWindow : IDisposable // the post-resolve landing path restores the cycle to // whatever the interpreted state says when the body // lands. - bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck) - && rmCheck.Airborne; if (!remoteIsAirborne) - ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed); + { + // Fallback chain for missing cycles in the MotionTable. + // SetCycle unconditionally calls ClearCyclicTail() before + // looking up the cycle; if the cycle is absent, the body + // ends up with no cyclic tail at all and every part snaps + // to its setup-default offset — visible as "torso on the + // ground" because most creatures' setup-default puts all + // limbs at the torso origin. + // + // This is specifically a regression from commit 186a584 + // (Phase L.1c port): pre-fix, MoveTo packets fell through + // to fullMotion=Ready (which always exists in every + // MotionTable). Post-fix, MoveTo packets seed + // fullMotion=RunForward, but some creatures (especially + // when stance=HandCombat) lack a (combat, RunForward) + // cycle. Fall through RunForward → WalkForward → Ready + // until we find one the table actually contains. + // + // Note: this fallback is for the SEQUENCER (visible + // animation) only. InterpretedState.ForwardCommand still + // gets the wire's (or seeded) ForwardCommand verbatim + // so apply_current_movement produces correct velocity. + uint cycleToPlay = animCycle; + if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay)) + { + uint requested = cycleToPlay; + // RunForward (0x44000007) → WalkForward (0x45000005) + if ((cycleToPlay & 0xFFu) == 0x07 + && ae.Sequencer.HasCycle(fullStyle, 0x45000005u)) + { + cycleToPlay = 0x45000005u; + } + // WalkForward → Ready (0x41000003) + else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u)) + { + cycleToPlay = 0x41000003u; + } + // Ready missing too — leave the existing cycle alone + // by not calling SetCycle at all (avoids the + // ClearCyclicTail wipe). + else + { + cycleToPlay = 0; + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + Console.WriteLine( + $"UM cycle missing for guid=0x{update.Guid:X8} " + + $"style=0x{fullStyle:X8} requested=0x{requested:X8} " + + $"→ fallback=0x{cycleToPlay:X8}"); + } + } + if (cycleToPlay != 0) + ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed); + } // Retail runs the full MotionInterp state machine on every // remote. Route each wire command (forward, sidestep, turn) @@ -2342,12 +2705,17 @@ public sealed class GameWindow : IDisposable // FUN_00528f70 DoInterpretedMotion // FUN_00528960 get_state_velocity // FUN_00529210 apply_current_movement - if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) + // ServerMoveToActive flag, MoveToPath capture, and the + // InterpretedState.ForwardCommand bulk-copy are already + // handled by the LIFTED block above (so overlay-class swings + // also clear stale MoveTo state and update the body's + // forward command). This branch only handles sidestep / + // turn axes plus the ObservedOmega seed — none of which + // appear on overlay packets, so the existing logic is + // correct unchanged. (`remoteMot` is the same dictionary + // entry obtained at the top of the lifted block.) + if (remoteMot is not null) { - // Forward axis (Ready / WalkForward / RunForward / WalkBackward). - remoteMot.Motion.DoInterpretedMotion( - fullMotion, speedMod, modifyInterpretedState: true); - // Sidestep axis. if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) { @@ -2404,6 +2772,7 @@ public sealed class GameWindow : IDisposable } } } + } // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), // stamp the _remoteLastMove timestamp to "now". Without this, @@ -2435,57 +2804,20 @@ public sealed class GameWindow : IDisposable dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; } - // Route the Commands list — one-shot Actions, Modifiers, and - // ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These - // live in the motion table's Links / Modifiers dicts, not - // Cycles, and are played on top of the current cycle via - // PlayAction which resolves the right dict and interleaves the - // action frames before the cyclic tail. - // - // A typical NPC wave looks like: - // ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}] - // [{0x0003=Ready, ...}] - // Each item runs through PlayAction (for 0x10/0x20 mask) or the - // standard SetCycle path (for 0x40 SubState). We leave SubState - // commands to fall through to the next UpdateMotion; that's how - // retail handles transition sequences (Wave → Ready). + // Route command-list entries through the shared Core router. + // Retail/ACE send these as 16-bit MotionCommand lows in + // InterpretedMotionState.Commands[]; the router reconstructs the + // class byte and chooses PlayAction for actions/modifiers/emotes + // or SetCycle for persistent substates. if (update.MotionState.Commands is { Count: > 0 } cmds) { foreach (var item in cmds) { - // Restore the 32-bit MotionCommand from the wire's 16-bit - // truncation by OR-ing class bits. The class is encoded - // in the low byte's high nibble via command ranges: - // 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx) - // 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx) - // 0x0051-0x00A1 — Action class (0x10xx xxxx) - // - // The retail MotionCommand enum carries the class byte in - // bits 24-31. DatReaderWriter's enum values match. For - // broadcasts, servers emit only low 16 bits (ACE - // InterpretedMotionState.cs:139). We reconstruct via a - // range-based lookup. See MotionCommand.generated.cs. - uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command); - if (fullCmd == 0) continue; - - // Action class: play through the link dict then drop back - // to the current cycle. Modifier class: resolve from the - // Modifiers dict and combine on top. SubState: cycle - // change; route through SetCycle so the style-specific - // cycle fallback applies. - uint cls = fullCmd & 0xFF000000u; - if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0 - || cls == 0x12000000u || cls == 0x13000000u) - { - ae.Sequencer.PlayAction(fullCmd, item.Speed); - } - else if ((cls & 0x40000000u) != 0) - { - // Substate in the command list — typically the "and - // then return to Ready" item. Update the cycle. - ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed); - } - // else: Style / UI / Toggle class — not animation-driving. + AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand( + ae.Sequencer, + fullStyle, + item.Command, + item.Speed); } } return; @@ -2587,6 +2919,39 @@ public sealed class GameWindow : IDisposable } } + private static bool IsRemoteLocomotion(uint motion) + { + uint low = motion & 0xFFu; + return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10; + } + + private void ApplyServerControlledVelocityCycle( + uint serverGuid, + AnimatedEntity ae, + RemoteMotion rm, + System.Numerics.Vector3 velocity) + { + if (IsPlayerGuid(serverGuid)) return; + if (rm.Airborne) return; + if (ae.Sequencer is null) return; + // MoveTo packets already seeded the retail speed/runRate cycle. + // Keep UpdatePosition-derived velocity for render position only; + // using it to choose the cycle reverts fast chases back to slow + // velocity-estimated animation. + if (rm.ServerMoveToActive) return; + + var plan = AcDream.Core.Physics.ServerControlledLocomotion + .PlanFromVelocity(velocity); + uint currentMotion = ae.Sequencer.CurrentMotion; + if (!plan.IsMoving && !IsRemoteLocomotion(currentMotion)) + return; + + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); + } + private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { // Phase A.1: track the most recently updated entity's landblock so the @@ -2656,6 +3021,26 @@ public sealed class GameWindow : IDisposable // slerp doesn't visibly rotate from Identity to truth. rmState.Body.Orientation = rot; } + double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds; + System.Numerics.Vector3? serverVelocity = update.Velocity; + if (serverVelocity is null + && !IsPlayerGuid(update.Guid) + && rmState.LastServerPosTime > 0.0) + { + double elapsed = nowSec - rmState.LastServerPosTime; + if (elapsed > 0.001) + serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed; + } + if (serverVelocity is { } authoritativeVelocity) + { + rmState.ServerVelocity = authoritativeVelocity; + rmState.HasServerVelocity = true; + } + else if (!IsPlayerGuid(update.Guid)) + { + rmState.ServerVelocity = System.Numerics.Vector3.Zero; + rmState.HasServerVelocity = false; + } rmState.Body.Position = worldPos; // K-fix15 (2026-04-26): DON'T auto-clear airborne on UP. // ACE broadcasts UPs during the arc (peak / mid-fall / land) @@ -2695,7 +3080,7 @@ public sealed class GameWindow : IDisposable rmState.Body.Orientation = rot; rmState.TargetOrientation = rot; rmState.LastServerPos = worldPos; - rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds; + rmState.LastServerPosTime = nowSec; // Align the body's physics clock with our clock so update_object // doesn't sub-step a huge initial gap. rmState.Body.LastUpdateTime = rmState.LastServerPosTime; @@ -2720,6 +3105,7 @@ public sealed class GameWindow : IDisposable // carries no stop information for our ACE. if (svel.LengthSquared() < 0.04f) { + rmState.ServerMoveToActive = false; rmState.Motion.StopCompletely(); if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop) && aeForStop.Sequencer is not null) @@ -2732,6 +3118,21 @@ public sealed class GameWindow : IDisposable } } } + else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity) + { + rmState.Body.Velocity = rmState.ServerVelocity; + } + + if (!IsPlayerGuid(update.Guid) + && rmState.HasServerVelocity + && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) + { + ApplyServerControlledVelocityCycle( + update.Guid, + aeForVelocity, + rmState, + rmState.ServerVelocity); + } entity.Position = rmState.Body.Position; entity.Rotation = rmState.Body.Orientation; @@ -2846,6 +3247,110 @@ public sealed class GameWindow : IDisposable _scriptRunner.Play(scriptId, guid, camWorldPos); } + private void UpdateSkyPes( + float dayFraction, + AcDream.Core.World.DayGroupData? dayGroup, + System.Numerics.Vector3 cameraWorldPos, + bool suppressSky) + { + if (_scriptRunner is null || _particleSink is null) + return; + + var seen = new HashSet(); + if (!suppressSky && dayGroup is not null) + { + for (int i = 0; i < dayGroup.SkyObjects.Count; i++) + { + var obj = dayGroup.SkyObjects[i]; + if (obj.PesObjectId == 0 || !obj.IsVisible(dayFraction)) + continue; + + var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene); + seen.Add(key); + + if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) + continue; + + uint skyEntityId = SkyPesEntityId(key); + var renderPass = obj.IsPostScene + ? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene + : AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene; + _particleSink.SetEntityRenderPass(skyEntityId, renderPass); + var anchor = SkyPesAnchor(obj, cameraWorldPos); + var rotation = SkyPesRotation(obj, dayFraction); + // Refresh anchor + rotation every frame so AttachLocal + // (is_parent_local=1) particles track the camera. Retail + // ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the + // live parent frame each tick; for sky-PES the parent IS + // the camera. UpdateEntityAnchor is a no-op when no + // emitters yet exist (script just spawned this frame). + _particleSink.UpdateEntityAnchor(skyEntityId, anchor, rotation); + + if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) + continue; + + if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor)) + { + _activeSkyPes.Add(key); + } + else + { + _missingSkyPes.Add(key); + _particleSink.ClearEntityRenderPass(skyEntityId); + } + } + } + + foreach (var key in _activeSkyPes.ToArray()) + { + if (seen.Contains(key)) + continue; + + uint skyEntityId = SkyPesEntityId(key); + _scriptRunner.Stop(key.PesObjectId, skyEntityId); + _particleSink.StopAllForEntity(skyEntityId, fadeOut: true); + _activeSkyPes.Remove(key); + } + + foreach (var key in _missingSkyPes.ToArray()) + { + if (!seen.Contains(key)) + _missingSkyPes.Remove(key); + } + } + + private static uint SkyPesEntityId(SkyPesKey key) + { + // 0xF0000000 prefix marks synthetic sky-PES entityIds (no real + // server GUID lives in the 0xFxxxxxxx space). Reserve bit + // 0x08000000 for the pre/post-scene flag and the lower 27 bits + // for the object index — keeps the post-scene flag from sliding + // into the index range if a future DayGroup ever ships >65k sky + // objects (current Dereth max is 18, but the constraint is free). + uint postBit = key.PostScene ? 0x08000000u : 0u; + return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); + } + + private static System.Numerics.Vector3 SkyPesAnchor( + AcDream.Core.World.SkyObjectData obj, + System.Numerics.Vector3 cameraWorldPos) + { + if (obj.IsWeather && (obj.Properties & 0x08u) == 0u) + return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f); + + return cameraWorldPos; + } + + private static System.Numerics.Quaternion SkyPesRotation( + AcDream.Core.World.SkyObjectData obj, + float dayFraction) + { + float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f); + return System.Numerics.Quaternion.CreateFromAxisAngle( + System.Numerics.Vector3.UnitY, + -rotationRad); + } + /// /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. /// Routes fog presets into the weather system's sticky override @@ -4329,6 +4834,7 @@ public sealed class GameWindow : IDisposable // interpolated keyframe. var kf = WorldTime.CurrentSky; var atmo = Weather.Snapshot(in kf); + bool environOverrideActive = atmo.Override != AcDream.Core.World.EnvironOverride.None; var fogColor = atmo.FogColor; // Clear to fog color (horizon haze) so if sky meshes have alpha // gaps or don't cover the full view, the "missing" area reads as @@ -4379,15 +4885,6 @@ public sealed class GameWindow : IDisposable // and the SkyRenderer.RenderWeather pass both pick up snow // weather meshes for free.) - // Phase E.3: advance live particle emitters AFTER animation tick - // so emitters spawned by hooks fired this frame get integrated. - // Tick the PhysicsScript runner BEFORE the particle system so any - // CreateParticleHook fired this frame has its emitter alive when - // the particle system advances. - _scriptRunner?.Tick((float)deltaSeconds); - - _particleSystem?.Tick((float)deltaSeconds); - int visibleLandblocks = 0; int totalLandblocks = 0; @@ -4455,6 +4952,15 @@ public sealed class GameWindow : IDisposable var visibility = _cellVisibility.ComputeVisibility(camPos); bool cameraInsideCell = visibility?.CameraCell is not null; + // Phase C.1: tick retail PhysicsScript particle hooks. Named + // retail decomp confirms SkyObject.PesObjectId is copied by + // SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is + // debug-only and disabled for normal retail rendering. + if (_enableSkyPesDebug) + UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); + _scriptRunner?.Tick((float)deltaSeconds); + _particleSystem?.Tick((float)deltaSeconds); + // Phase G.1/G.2: feed the sun, tick LightManager, build + upload // the scene-lighting UBO once per frame. Every shader that // consumes binding=1 reads the same data for the rest of the @@ -4490,7 +4996,10 @@ public sealed class GameWindow : IDisposable if (!cameraInsideCell) { _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf); + _activeDayGroup, kf, environOverrideActive); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); } // K-fix1 (2026-04-26): suppress terrain + entity rendering @@ -4514,16 +5023,34 @@ public sealed class GameWindow : IDisposable if (cameraInsideCell) _gl!.Clear(ClearBufferMask.DepthBufferBit); + // L-fix1 (2026-04-28): pass the set of animated-entity ids so + // the renderer keeps remote players / NPCs / monsters + // visible even when their landblock rotates out of the + // frustum. Without this, other characters wink in/out as + // the camera turns. The set is rebuilt per-frame from + // _animatedEntities — it's small (<100 entities typically) + // so HashSet allocation is cheap. Static scenery still + // respects landblock-level cull. + HashSet? animatedIds = null; + if (_animatedEntities.Count > 0) + { + animatedIds = new HashSet(_animatedEntities.Count); + foreach (var k in _animatedEntities.Keys) + animatedIds.Add(k); + } + _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, - visibleCellIds: visibility?.VisibleCellIds); + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); // Phase G.1 / E.3: draw all live particles after opaque // scene geometry so alpha blending composites correctly. // Runs with depth test on (particles occluded by walls) // but depth write off (no self-occlusion sorting needed). if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos); + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene); // Bug A fix (post-#26 worktree, 2026-04-26): weather sky // meshes (Properties & 0x04, e.g. the 815m-tall rain @@ -4536,7 +5063,10 @@ public sealed class GameWindow : IDisposable if (!cameraInsideCell) { _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf); + _activeDayGroup, kf, environOverrideActive); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } // Debug: draw collision shapes as wireframe cylinders around the @@ -4880,7 +5410,114 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.Active; - rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) + { + double velocityAge = nowSec - rm.LastServerPosTime; + if (velocityAge > ServerControlledVelocityStaleSeconds) + { + rm.ServerVelocity = System.Numerics.Vector3.Zero; + rm.HasServerVelocity = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + ApplyServerControlledVelocityCycle( + serverGuid, + ae, + rm, + System.Numerics.Vector3.Zero); + } + else + { + rm.Body.Velocity = rm.ServerVelocity; + } + } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive + && rm.HasMoveToDestination) + { + // Phase L.1c port of retail MoveToManager per-tick + // steering (HandleMoveToPosition @ 0x00529d80). + // Steer body orientation toward the latest + // server-supplied destination, then let + // apply_current_movement set Velocity from the + // RunForward cycle through the now-correct heading. + + // Stale-destination guard (2026-04-28): if no + // MoveTo packet has refreshed the destination + // recently, the entity has likely left our + // streaming view or the server cancelled the + // move without us seeing the cancel UM. Continuing + // to steer toward a stale point produces the + // "monster runs in place after popping back into + // view" symptom. Clear and stand down. + double moveToAge = nowSec - rm.LastMoveToPacketTime; + if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds) + { + rm.HasMoveToDestination = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + var driveResult = AcDream.Core.Physics.RemoteMoveToDriver + .Drive( + rm.Body.Position, + rm.Body.Orientation, + rm.MoveToDestinationWorld, + rm.MoveToMinDistance, + rm.MoveToDistanceToObject, + (float)dt, + rm.MoveToMoveTowards, + out var steeredOrientation); + rm.Body.Orientation = steeredOrientation; + + if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver + .DriveResult.Arrived) + { + // Within arrival window — zero velocity until the + // next MoveTo packet refreshes the destination + // (or the server explicitly stops us with an + // interpreted-motion UM cmd=Ready). + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + // Steering active — apply_current_movement reads + // InterpretedState.ForwardCommand=RunForward (set + // when the MoveTo packet arrived) and emits + // velocity along +Y in body local space. Our + // updated orientation rotates that into the right + // world direction toward the target. + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + + // Clamp horizontal velocity so we don't overshoot + // the arrival threshold during the final tick of + // approach. Without this, a 4 m/s body advances + // ~6 cm/tick and visibly runs slightly through + // the target before the swing UM lands. + float arrivalThreshold = rm.MoveToMoveTowards + ? rm.MoveToDistanceToObject + : rm.MoveToMinDistance; + rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver + .ClampApproachVelocity( + rm.Body.Position, + rm.Body.Velocity, + rm.MoveToDestinationWorld, + arrivalThreshold, + (float)dt, + rm.MoveToMoveTowards); + } + } + } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) + { + // MoveTo flag set but we haven't seen a path payload + // yet (e.g. truncated packet, or a brand-new entity + // whose first cycle UM is still in flight). Hold + // velocity at zero — same conservative stance as the + // 882a07c stabilizer for incomplete state. + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } } else { @@ -5952,6 +6589,26 @@ public sealed class GameWindow : IDisposable _settingsPanel.IsVisible = !_settingsPanel.IsVisible; break; + case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster: + SelectClosestCombatTarget(showToast: true); + break; + + case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: + ToggleLiveCombatMode(); + break; + + case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack: + SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low); + break; + + case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack: + SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium); + break; + + case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack: + SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High); + break; + case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: if (_cameraController?.IsFlyMode == true) _cameraController.ToggleFly(); // exit fly, release cursor @@ -5969,6 +6626,123 @@ public sealed class GameWindow : IDisposable } } + private void ToggleLiveCombatMode() + { + if (_liveSession is null + || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + return; + + var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode); + _liveSession.SendChangeCombatMode(nextMode); + Combat.SetCombatMode(nextMode); + string text = $"Combat mode {nextMode}"; + Console.WriteLine($"combat: {text}"); + _debugVm?.AddToast(text); + } + + private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action) + { + if (_liveSession is null + || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + return; + + if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)) + { + _debugVm?.AddToast("Enter melee or missile combat first"); + Console.WriteLine("combat: attack ignored; not in melee/missile combat mode"); + return; + } + + uint? target = GetSelectedOrClosestCombatTarget(); + if (target is null) + { + _debugVm?.AddToast("No monster target"); + Console.WriteLine("combat: attack ignored; no creature target found"); + return; + } + + var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action); + const float FullBar = 1.0f; + if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile) + { + _liveSession.SendMissileAttack(target.Value, height, FullBar); + Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}"); + } + else + { + _liveSession.SendMeleeAttack(target.Value, height, FullBar); + Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}"); + } + } + + private uint? GetSelectedOrClosestCombatTarget() + { + if (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected)) + return selected; + + return SelectClosestCombatTarget(showToast: false); + } + + private uint? SelectClosestCombatTarget(bool showToast) + { + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) + return null; + + uint? bestGuid = null; + float bestDistanceSq = float.PositiveInfinity; + foreach (var (guid, entity) in _entitiesByServerGuid) + { + if (!IsLiveCreatureTarget(guid)) + continue; + + float distanceSq = System.Numerics.Vector3.DistanceSquared( + entity.Position, + playerEntity.Position); + if (distanceSq >= bestDistanceSq) + continue; + + bestDistanceSq = distanceSq; + bestGuid = guid; + } + + _selectedTargetGuid = bestGuid; + if (bestGuid is { } selected) + { + string label = DescribeLiveEntity(selected); + float distance = MathF.Sqrt(bestDistanceSq); + Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}"); + if (showToast) + _debugVm?.AddToast($"Target {label}"); + } + else if (showToast) + { + _debugVm?.AddToast("No monster target"); + Console.WriteLine("combat: no creature target found"); + } + + return bestGuid; + } + + private bool IsLiveCreatureTarget(uint guid) + { + if (guid == _playerServerGuid) + return false; + if (!_entitiesByServerGuid.ContainsKey(guid)) + return false; + if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info)) + return false; + + return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0; + } + + private string DescribeLiveEntity(uint guid) + { + if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) + && !string.IsNullOrWhiteSpace(info.Name)) + return info.Name!; + return $"0x{guid:X8}"; + } + /// /// K.1b: Tab handler extracted into a method so the dispatcher /// subscriber can call it. Same body as the previous Tab branch in @@ -6304,12 +7078,13 @@ public sealed class GameWindow : IDisposable _liveSession?.Dispose(); _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _staticMesh?.Dispose(); + _skyRenderer?.Dispose(); // depends on sampler cache; dispose first + _samplerCache?.Dispose(); _textureCache?.Dispose(); _meshShader?.Dispose(); _terrain?.Dispose(); _shader?.Dispose(); _sceneLightingUbo?.Dispose(); - _skyRenderer?.Dispose(); _particleRenderer?.Dispose(); _debugLines?.Dispose(); _textRenderer?.Dispose(); diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs index 18a67ae0..7f4ce293 100644 --- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -152,7 +152,16 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null, - HashSet? visibleCellIds = null) + HashSet? 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? animatedEntityIds = null) { _shader.Use(); @@ -165,7 +174,7 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable // directly — no per-draw uniform uploads needed. // ── Collect and group instances ─────────────────────────────────────── - CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds); + CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds); // ── Build and upload the instance buffer ────────────────────────────── // Count total instances. @@ -342,16 +351,27 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, FrustumPlanes? frustum, uint? neverCullLandblockId, - HashSet? visibleCellIds) + HashSet? visibleCellIds, + HashSet? animatedEntityIds) { foreach (var grp in _groups.Values) grp.Entries.Clear(); foreach (var entry in landblockEntries) { - if (frustum is not null && - entry.LandblockId != neverCullLandblockId && - !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) + // L-fix1 (2026-04-28): the landblock cull decision is now + // PER-LANDBLOCK boolean, not a continue. We still need to + // walk the entity list because animated entities (in + // animatedEntityIds) bypass the cull and render anyway. + bool landblockVisible = frustum is null + || entry.LandblockId == neverCullLandblockId + || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax); + + // Fast path: no animated entities globally → if landblock is + // culled, skip the whole entity list (preserves the original + // O(visible-landblocks) cost when the caller doesn't care + // about animated bypass). + if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0)) continue; foreach (var entity in entry.Entities) @@ -359,6 +379,14 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (entity.MeshRefs.Count == 0) continue; + // L-fix1: when the landblock is frustum-culled, only + // render entities flagged as animated. This keeps + // remote players / NPCs / monsters visible even when + // their landblock rotates out of the view frustum. + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + if (!landblockVisible && !isAnimated) + continue; + // Step 4: portal visibility filter. If we have a visible cell set, // skip interior entities whose parent cell isn't visible. // visibleCellIds == null means camera is outdoors → show all interiors. diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 71286944..61ef0bd5 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -2,64 +2,69 @@ using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Vfx; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// -/// 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. -/// -/// -/// 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. -/// -/// -/// -/// Emitters tagged with 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. -/// +/// Instanced renderer for retail particle emitters. /// 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 _particleGfxInfoByGfxObj = new(); - // Unit-quad vertex buffer (-0.5..+0.5 in XY). 4 verts, 6 indices. private readonly uint _quadVao; private readonly uint _quadVbo; private readonly uint _quadEbo; - - // Instance buffer — 8 floats per particle: posX,Y,Z, size, colorR,G,B,A. private readonly uint _instanceVbo; - private float[] _instanceScratch = new float[256 * 8]; - public ParticleRenderer(GL gl, string shadersDir) + private float[] _instanceScratch = new float[256 * 16]; + + public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _textures = textures; + _dats = dats; _shader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle.vert"), System.IO.Path.Combine(shadersDir, "particle.frag")); - // Unit quad around origin (XY plane, Z = 0). The vertex shader - // reads this, then offsets into world space using the - // per-instance (pos, size) values. - float[] quadVerts = new float[] + float[] quadVerts = { - // pos x,y uv -0.5f, -0.5f, 0f, 0f, 0.5f, -0.5f, 1f, 0f, 0.5f, 0.5f, 1f, 1f, -0.5f, 0.5f, 0f, 1f, }; - uint[] quadIdx = new uint[] { 0, 1, 2, 0, 2, 3 }; + uint[] quadIdx = { 0, 1, 2, 0, 2, 3 }; _quadVao = _gl.GenVertexArray(); _gl.BindVertexArray(_quadVao); @@ -67,8 +72,14 @@ public sealed unsafe class ParticleRenderer : IDisposable _quadVbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo); fixed (void* p = quadVerts) - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(quadVerts.Length * sizeof(float)), p, BufferUsageARB.StaticDraw); + { + _gl.BufferData( + BufferTargetARB.ArrayBuffer, + (nuint)(quadVerts.Length * sizeof(float)), + p, + BufferUsageARB.StaticDraw); + } + _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0); _gl.EnableVertexAttribArray(1); @@ -77,135 +88,347 @@ public sealed unsafe class ParticleRenderer : IDisposable _quadEbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo); fixed (void* p = quadIdx) - _gl.BufferData(BufferTargetARB.ElementArrayBuffer, - (nuint)(quadIdx.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); + { + _gl.BufferData( + BufferTargetARB.ElementArrayBuffer, + (nuint)(quadIdx.Length * sizeof(uint)), + p, + BufferUsageARB.StaticDraw); + } _instanceVbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); - _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 8 * sizeof(float)), - (void*)0, BufferUsageARB.DynamicDraw); + _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 16 * sizeof(float)), (void*)0, BufferUsageARB.DynamicDraw); - // Per-instance attributes: pos+size at loc 2, color at loc 3. _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)0); + _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)0); _gl.VertexAttribDivisor(2, 1); _gl.EnableVertexAttribArray(3); - _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)(4 * sizeof(float))); + _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(4 * sizeof(float))); _gl.VertexAttribDivisor(3, 1); + _gl.EnableVertexAttribArray(4); + _gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(8 * sizeof(float))); + _gl.VertexAttribDivisor(4, 1); + _gl.EnableVertexAttribArray(5); + _gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(12 * sizeof(float))); + _gl.VertexAttribDivisor(5, 1); _gl.BindVertexArray(0); } - /// - /// 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. - /// - public void Draw(ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos) + public void Draw( + ParticleSystem particles, + ICamera camera, + Vector3 cameraWorldPos, + ParticleRenderPass renderPass = ParticleRenderPass.Scene) { - if (particles is null || camera is null) return; + if (particles is null || camera is null) + return; + + Matrix4x4.Invert(camera.View, out var invView); + Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); + Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); + var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp); + if (draws.Count == 0) + return; + draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq)); _shader.Use(); _shader.SetMatrix4("uViewProjection", camera.View * camera.Projection); - _shader.SetVec3("uCameraRight", GetCameraRight(camera)); - _shader.SetVec3("uCameraUp", GetCameraUp(camera)); + _shader.SetInt("uParticleTexture", 0); + _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); _gl.Disable(EnableCap.CullFace); + _gl.ActiveTexture(TextureUnit.Texture0); - // Group emitters by additive vs alpha-blend so we flip blend state - // once per group rather than per-emitter. Simple two-pass split. - var alphaGroup = new List(32); - var addGroup = new List(32); - foreach (var (em, _) in particles.EnumerateLive()) + var run = new List(64); + for (int i = 0; i < draws.Count;) { - var list = (em.Desc.Flags & EmitterFlags.Additive) != 0 ? addGroup : alphaGroup; - if (list.Count == 0 || !ReferenceEquals(list[^1], em)) - list.Add(em); + var key = draws[i].Key; + run.Clear(); + do + { + run.Add(draws[i].Instance); + i++; + } + while (i < draws.Count && draws[i].Key == key); + + _gl.BlendFunc( + BlendingFactor.SrcAlpha, + key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); + _shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0); + _gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0); + DrawInstances(run); } - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - foreach (var em in alphaGroup) - DrawEmitter(em, cameraWorldPos); - - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); - foreach (var em in addGroup) - DrawEmitter(em, cameraWorldPos); - + _gl.BindTexture(TextureTarget.Texture2D, 0); + _gl.BindVertexArray(0); _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); - _gl.BindVertexArray(0); } - private void DrawEmitter(ParticleEmitter em, Vector3 cameraWorldPos) + private List BuildDrawList( + ParticleSystem particles, + Vector3 cameraWorldPos, + ParticleRenderPass renderPass, + Vector3 cameraRight, + Vector3 cameraUp) { - int liveCount = 0; - for (int i = 0; i < em.Particles.Length; i++) - if (em.Particles[i].Alive) liveCount++; - if (liveCount == 0) return; - - // Ensure instance buffer is big enough. - int needed = liveCount * 8; - if (_instanceScratch.Length < needed) - _instanceScratch = new float[needed + 256 * 8]; - - // Anchor adjustment for AttachLocal emitters — re-center the - // emission volume on the camera each frame so the rain/snow - // follows the viewer. The emitter's AnchorPos stays at the - // spawn point, but when writing out world-space particles we - // add (camera - emitterAnchor) so they track the camera. - bool attachLocal = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0; - Vector3 cameraOffset = attachLocal ? (cameraWorldPos - em.AnchorPos) : Vector3.Zero; - - int idx = 0; - for (int i = 0; i < em.Particles.Length; i++) + var draws = new List(Math.Max(64, particles.ActiveParticleCount)); + foreach (var (em, idx) in particles.EnumerateLive()) { - ref var p = ref em.Particles[i]; - if (!p.Alive) continue; + if (em.RenderPass != renderPass) + continue; - Vector3 pos = p.Position + cameraOffset; - _instanceScratch[idx * 8 + 0] = pos.X; - _instanceScratch[idx * 8 + 1] = pos.Y; - _instanceScratch[idx * 8 + 2] = pos.Z; - _instanceScratch[idx * 8 + 3] = p.Size; + ref var p = ref em.Particles[idx]; + // `p.Position` is already in world coordinates: AttachLocal + // emitters get their AnchorPos refreshed each frame by the + // owning subsystem (sky-PES driver, animation tick, etc.) which + // mirrors retail's live-parent-frame read at + // ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1. + Vector3 pos = p.Position; + float distSq = Vector3.DistanceSquared(pos, cameraWorldPos); + var gfxInfo = ResolveParticleGfxInfo(em.Desc); + uint texture = gfxInfo.TextureHandle; + bool useTexture = texture != 0; + bool additive = gfxInfo.HasMaterial + ? gfxInfo.Additive + : (em.Desc.Flags & EmitterFlags.Additive) != 0; + var key = new BatchKey(texture, useTexture, additive); + Vector3 axisX; + Vector3 axisY; + if (gfxInfo.IsBillboard) + { + pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size); + axisX = cameraRight * (gfxInfo.Size.X * p.Size); + axisY = cameraUp * (gfxInfo.Size.Y * p.Size); + } + else + { + Quaternion orientation = ParticleOrientation(em, p); + pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation); + axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size); + axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size); + } - // ARGB → RGBA floats. - float a = ((p.ColorArgb >> 24) & 0xFF) / 255f; - float r = ((p.ColorArgb >> 16) & 0xFF) / 255f; - float g = ((p.ColorArgb >> 8) & 0xFF) / 255f; - float b = ( p.ColorArgb & 0xFF) / 255f; - _instanceScratch[idx * 8 + 4] = r; - _instanceScratch[idx * 8 + 5] = g; - _instanceScratch[idx * 8 + 6] = b; - _instanceScratch[idx * 8 + 7] = a; + draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq))); + } - idx++; + return draws; + } + + private void DrawInstances(List instances) + { + if (instances.Count == 0) + return; + + int needed = instances.Count * 16; + if (_instanceScratch.Length < needed) + _instanceScratch = new float[needed + 256 * 16]; + + for (int i = 0; i < instances.Count; i++) + { + var p = instances[i]; + int o = i * 16; + _instanceScratch[o + 0] = p.Position.X; + _instanceScratch[o + 1] = p.Position.Y; + _instanceScratch[o + 2] = p.Position.Z; + _instanceScratch[o + 3] = 0f; + + _instanceScratch[o + 4] = p.AxisX.X; + _instanceScratch[o + 5] = p.AxisX.Y; + _instanceScratch[o + 6] = p.AxisX.Z; + _instanceScratch[o + 7] = 0f; + + _instanceScratch[o + 8] = p.AxisY.X; + _instanceScratch[o + 9] = p.AxisY.Y; + _instanceScratch[o + 10] = p.AxisY.Z; + _instanceScratch[o + 11] = 0f; + + _instanceScratch[o + 12] = ((p.ColorArgb >> 16) & 0xFF) / 255f; + _instanceScratch[o + 13] = ((p.ColorArgb >> 8) & 0xFF) / 255f; + _instanceScratch[o + 14] = (p.ColorArgb & 0xFF) / 255f; + _instanceScratch[o + 15] = ((p.ColorArgb >> 24) & 0xFF) / 255f; } _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); fixed (void* bp = _instanceScratch) { - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(liveCount * 8 * sizeof(float)), - bp, BufferUsageARB.DynamicDraw); + _gl.BufferData( + BufferTargetARB.ArrayBuffer, + (nuint)(instances.Count * 16 * sizeof(float)), + bp, + BufferUsageARB.DynamicDraw); } _gl.BindVertexArray(_quadVao); - _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, - DrawElementsType.UnsignedInt, (void*)0, (uint)liveCount); + _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count); } - private static Vector3 GetCameraRight(ICamera camera) + private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc) { - Matrix4x4.Invert(camera.View, out var inv); - return Vector3.Normalize(new Vector3(inv.M11, inv.M12, inv.M13)); + if (_textures is null) + return ParticleGfxInfo.Default; + + if (desc.TextureSurfaceId != 0) + return ParticleGfxInfo.Billboard( + _textures.GetOrUpload(desc.TextureSurfaceId), + Vector2.One, + Vector3.Zero, + additive: (desc.Flags & EmitterFlags.Additive) != 0, + hasMaterial: false); + + uint gfxObjId = desc.HwGfxObjId != 0 ? desc.HwGfxObjId : desc.GfxObjId; + if (gfxObjId == 0 || _dats is null) + return ParticleGfxInfo.Default; + + if (!_particleGfxInfoByGfxObj.TryGetValue(gfxObjId, out var info)) + { + info = ReadParticleGfxInfo(gfxObjId); + _particleGfxInfoByGfxObj[gfxObjId] = info; + } + + return info.TextureHandle != 0 ? info : ParticleGfxInfo.Default; } - private static Vector3 GetCameraUp(ICamera camera) + private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId) { - Matrix4x4.Invert(camera.View, out var inv); - return Vector3.Normalize(new Vector3(inv.M21, inv.M22, inv.M23)); + try + { + var gfx = _dats?.Get(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(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(gfx.DIDDegrade); + return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2; + } + catch + { + return false; + } + } + + private static float FallbackParticleExtent(float value) + => value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f; + + private static Quaternion ParticleOrientation(AcDream.Core.Vfx.ParticleEmitter em, Particle p) + { + Quaternion orientation = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0 + ? em.AnchorRot + : p.SpawnRotation; + + if (em.Desc.Type is AcDream.Core.Vfx.ParticleType.ParabolicLVGAGR + or AcDream.Core.Vfx.ParticleType.ParabolicLVLALR + or AcDream.Core.Vfx.ParticleType.ParabolicGVGAGR) + { + Vector3 angular = p.C * p.Age; + float radians = angular.Length(); + if (radians > 1e-6f) + orientation = Quaternion.Normalize(orientation * Quaternion.CreateFromAxisAngle(angular / radians, radians)); + } + + return orientation; } public void Dispose() @@ -216,4 +439,26 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.DeleteVertexArray(_quadVao); _shader.Dispose(); } + + private readonly record struct ParticleGfxInfo( + uint TextureHandle, + Vector2 Size, + Vector3 AxisX, + Vector3 AxisY, + Vector3 CenterOffset, + bool IsBillboard, + bool Additive, + bool HasMaterial) + { + public static ParticleGfxInfo Default { get; } = + Billboard(0u, Vector2.One, Vector3.Zero, additive: false, hasMaterial: false); + + public static ParticleGfxInfo Billboard( + uint textureHandle, + Vector2 size, + Vector3 centerOffset, + bool additive, + bool hasMaterial) => + new(textureHandle, size, Vector3.UnitX, Vector3.UnitY, centerOffset, true, additive, hasMaterial); + } } diff --git a/src/AcDream.App/Rendering/SamplerCache.cs b/src/AcDream.App/Rendering/SamplerCache.cs new file mode 100644 index 00000000..d65e7e85 --- /dev/null +++ b/src/AcDream.App/Rendering/SamplerCache.cs @@ -0,0 +1,63 @@ +using System; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Two persistent GL sampler objects (Repeat + ClampToEdge) created once +/// per GL context. Renderers the appropriate +/// one to a texture unit instead of mutating per-texture +/// GL_TEXTURE_WRAP_S/T 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. +/// +/// +/// Ported from +/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. +/// Filter modes match '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]. +/// +/// +/// +/// Lifetime: created once at GL init, disposed with the GL context. +/// Anything that binds a sampler MUST unbind it (BindSampler(unit, 0)) +/// 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. +/// +/// +public sealed class SamplerCache : IDisposable +{ + private readonly GL _gl; + + /// Sampler with WrapS = WrapT = Repeat. The default for textures uploaded by . + public uint Wrap { get; } + + /// 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. + 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); + } +} diff --git a/src/AcDream.App/Rendering/Shaders/particle.frag b/src/AcDream.App/Rendering/Shaders/particle.frag index 4633285f..7fb908dc 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.frag +++ b/src/AcDream.App/Rendering/Shaders/particle.frag @@ -4,15 +4,23 @@ in vec2 vTex; in vec4 vColor; out vec4 fragColor; -// Procedural rain/snow streak — no texture, just a radial falloff -// centred on the quad so droplets read as small soft circles. Good -// enough for weather + basic spell auras without a texture pipeline. +uniform sampler2D uParticleTexture; +uniform bool uUseTexture; void main() { - // Signed distance from quad center (in UV space). - vec2 d = vTex - vec2(0.5, 0.5); - float r = length(d) * 2.0; // 0 at center, 1 at corner - float falloff = smoothstep(1.0, 0.4, r); - if (falloff < 0.02) discard; - fragColor = vec4(vColor.rgb, vColor.a * falloff); + vec4 texel; + if (uUseTexture) { + texel = texture(uParticleTexture, vTex); + } else { + vec2 d = vTex - vec2(0.5, 0.5); + float r = length(d) * 2.0; + float falloff = smoothstep(1.0, 0.4, r); + texel = vec4(1.0, 1.0, 1.0, falloff); + } + + vec4 color = texel * vColor; + if (color.a < 0.02) + discard; + + fragColor = color; } diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 7b26dbf6..6b45a70d 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -4,26 +4,21 @@ layout(location = 0) in vec2 aQuad; layout(location = 1) in vec2 aTex; -// Per-instance: world-space center + size -layout(location = 2) in vec4 aPosAndSize; -layout(location = 3) in vec4 aColor; +// Per-instance: world-space center, authored sheet axes, color. +layout(location = 2) in vec4 aCenter; +layout(location = 3) in vec4 aAxisX; +layout(location = 4) in vec4 aAxisY; +layout(location = 5) in vec4 aColor; uniform mat4 uViewProjection; -uniform vec3 uCameraRight; -uniform vec3 uCameraUp; out vec2 vTex; out vec4 vColor; void main() { - vec3 center = aPosAndSize.xyz; - float size = aPosAndSize.w; - - // Billboard: offset the quad vertex along the camera's right + up - // basis vectors so it always faces the viewer. - vec3 world = center - + uCameraRight * (aQuad.x * size) - + uCameraUp * (aQuad.y * size); + vec3 world = aCenter.xyz + + aAxisX.xyz * aQuad.x + + aAxisY.xyz * aQuad.y; vTex = aTex; vColor = aColor; diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index c7044676..a9b3d16c 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -1,46 +1,15 @@ #version 430 core -// Sky mesh fragment shader — final composite matching retail's -// D3D fixed-function: -// -// fragment.rgb = texture.rgb × vTint + lightning_flash -// fragment.a = texture.a × (1 - uTransparency) × uSurfTranslucency -// (uSurfTranslucency is OPACITY directly per retail's -// D3DPolyRender::SetSurface at 0x59c7a6, NOT 1-x) -// -// vTint arrives from the vertex shader with retail's per-vertex -// lighting formula baked in (Emissive + lightAmbient + lightDiffuse × -// max(N·L, 0)) — see sky.vert for the decompile citation. The keyframe -// SkyObjectReplace.Luminosity override is folded into uEmissive on the -// CPU side (SkyRenderer.cs) so vTint already saturates properly for -// bright keyframes; the previous shader had a redundant uLuminosity -// multiply that was double-dimming clouds, removed 2026-04-26. -// -// See `docs/research/2026-04-23-sky-material-state.md`. in vec2 vTex; in vec3 vTint; -in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far) +in float vFogFactor; // 1 = no fog, 0 = full fog color out vec4 fragColor; uniform sampler2D uDiffuse; -uniform float uTransparency; // 0 = fully visible, 1 = fully transparent -// 1.0 = apply fog mix to this submesh; 0.0 = skip fog (Additive sky -// surfaces — sun/moon/stars per retail SetFFFogAlphaDisabled(1) at -// D3DPolyRender::SetSurface 0x59c882). Set per-submesh on the CPU side. -uniform float uApplyFog; -// Surface.Translucency float (0..1) used DIRECTLY as opacity (NOT 1-x). -// Distinct from uTransparency (per-keyframe Replace override). Retail -// D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) reads -// Surface.Translucency when the Translucent (0x10) bit is set and feeds -// _ftol2(translucency × 255) directly as vertex alpha. ACViewer -// (TextureCache.cs:142) + WorldBuilder (ObjectMeshManager.cs:1115) both -// invert it (1-x) and are wrong. For non-Translucent surfaces the CPU -// side (GfxObjMesh.Build) sets uSurfTranslucency = 1.0 ⇒ no effect. -uniform float uSurfTranslucency; +uniform float uTransparency; // keyframe transparency: 0 visible, 1 transparent +uniform float uApplyFog; // 1 for foggable sky layers; raw-additive surfaces keep retail fog disabled +uniform float uSurfOpacity; // final surface opacity multiplier from the CPU -// Shared SceneLighting UBO — fog params drive the mix, flash channel -// bumps sky brightness during lightning strikes. Matches sky.vert's -// declaration exactly. struct Light { vec4 posAndKind; vec4 dirAndRange; @@ -58,79 +27,21 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // Composite: texture × per-vertex lit. Replace.Luminosity (per - // keyframe) and Surface.Luminosity are both folded into uEmissive - // on the CPU side (SkyRenderer.cs) so vTint already carries the - // right tint for the time-of-day. Retail's fragment formula - // (FUN_0059da60 non-luminous branch) is texture × litColor × - // vertex.color(=white), so `texture × vTint` is the retail-faithful - // composite. vec3 rgb = sampled.rgb * vTint; - // Retail-faithful sky fog mix with a "fog floor" mitigation: - // - // Dereth sky meshes are authored at radii 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)); - // Final fragment alpha: - // uTransparency — keyframe-replace transparency override (0..1). - // 0 = fully visible, 1 = fully transparent. - // Applied as (1 - x). - // uSurfTranslucency — the dat's Surface.Translucency value when the - // Translucent flag is set, else 1.0. Despite the - // name, retail uses this as OPACITY directly (per - // D3DPolyRender::SetSurface at 0x59c7a6 which - // writes _ftol2(translucency × 255) into vertex - // alpha). Multiply directly — NOT (1 - x). - // - // For the rain mesh 0x01004C42/4C44 (translucency=0.5): a = 1*1*0.5 = 0.5 - // matches retail curr_alpha=127, halves the additive streak. - // For cloud surface 0x08000023 (translucency=0.25): a = 1*1*0.25 = 0.25 - // matches retail curr_alpha=63, dim cloud (was 3× too bright with - // the previous 1-x formula). - // For non-Translucent surfaces uSurfTranslucency = 1.0, no effect. - float a = sampled.a * (1.0 - uTransparency) * uSurfTranslucency; + float a = sampled.a * (1.0 - uTransparency) * uSurfOpacity; if (a < 0.01) discard; fragColor = vec4(rgb, a); } diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 1a2427f7..0d6b4f1e 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -47,6 +47,7 @@ uniform vec3 uSunDir; // unit vector FROM surface TO sun // Per-submesh (from Surface.Luminosity float): uniform float uEmissive; +uniform float uDiffuseFactor; // Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to // compute the vertex fog factor. Must match sky.frag's declaration. @@ -87,7 +88,7 @@ void main() { float diff = max(dot(worldNormal, uSunDir), 0.0); vec3 lit = vec3(uEmissive) // material.Emissive + uAmbientColor // material.Ambient(1) × light.Ambient - + uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L + + (uSunColor * uDiffuseFactor) * diff; vTint = clamp(lit, 0.0, 1.0); // Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR, diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index c5939507..29cba3f7 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -48,6 +48,7 @@ public sealed unsafe class SkyRenderer : IDisposable private readonly DatCollection _dats; private readonly Shader _shader; private readonly TextureCache _textures; + private readonly SamplerCache _samplers; // Lazily-built GPU resources per sky-GfxObj. private readonly Dictionary> _gpuByGfxObj = new(); @@ -61,12 +62,13 @@ public sealed unsafe class SkyRenderer : IDisposable public float Near { get; set; } = 0.1f; public float Far { get; set; } = 1_000_000f; - public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures) + public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures, SamplerCache samplers) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _shader = shader ?? throw new ArgumentNullException(nameof(shader)); _textures = textures ?? throw new ArgumentNullException(nameof(textures)); + _samplers = samplers ?? throw new ArgumentNullException(nameof(samplers)); } /// @@ -106,8 +108,10 @@ public sealed unsafe class SkyRenderer : IDisposable Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, - SkyKeyframe keyframe) - => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false); + SkyKeyframe keyframe, + bool environOverrideActive = false) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, + postScenePass: false, environOverrideActive: environOverrideActive); /// /// Draw the POST-SCENE sky objects (the foreground rain mesh @@ -134,8 +138,10 @@ public sealed unsafe class SkyRenderer : IDisposable Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, - SkyKeyframe keyframe) - => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true); + SkyKeyframe keyframe, + bool environOverrideActive = false) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, + postScenePass: true, environOverrideActive: environOverrideActive); /// /// Shared pass for and . @@ -151,7 +157,8 @@ public sealed unsafe class SkyRenderer : IDisposable float dayFraction, DayGroupData? group, SkyKeyframe keyframe, - bool postScenePass) + bool postScenePass, + bool environOverrideActive) { if (group is null || group.SkyObjects.Count == 0) return; @@ -227,6 +234,11 @@ public sealed unsafe class SkyRenderer : IDisposable // foreground rain — double-thick rain not matching retail. if (obj.IsPostScene != postScenePass) continue; if (!obj.IsVisible(dayFraction)) continue; + // Retail GameSky::Draw (0x00506ff0) skips Properties bit 0x02 + // objects while an AdminEnvirons fog override is active. Normal + // DayGroup fog/tint still draws them. + if (environOverrideActive && (obj.Properties & 0x02u) != 0u) + continue; // Apply per-keyframe replace overrides. uint gfxObjId = obj.GfxObjId; @@ -243,20 +255,18 @@ public sealed unsafe class SkyRenderer : IDisposable // NO Dereth sky surface carries the SurfaceType.Luminous flag // bit (0x40) — the differentiator is purely the float field. float replaceLuminosity = float.NaN; + float replaceDiffuse = float.NaN; if (replaces.TryGetValue((uint)i, out var rep)) { if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId; if (rep.Rotate != 0f) headingDeg = rep.Rotate; transparent = Math.Clamp(rep.Transparent, 0f, 1f); if (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity; - // MaxBright is a CAP: even if the surface authored Lum=1.0, - // a per-keyframe MaxBright trims it. When no explicit - // Luminosity replace exists, MaxBright still acts as the - // ceiling (applied against sub.SurfLuminosity at draw time). + // Retail GameSky::UseTime routes max_bright through + // CPhysicsObj::SetDiffusion, so it replaces material diffuse, + // not emissive/luminosity. if (rep.MaxBright > 0f) - replaceLuminosity = float.IsNaN(replaceLuminosity) - ? rep.MaxBright - : MathF.Min(replaceLuminosity, rep.MaxBright); + replaceDiffuse = rep.MaxBright; } if (gfxObjId == 0) continue; @@ -277,18 +287,24 @@ public sealed unsafe class SkyRenderer : IDisposable // if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0)) // int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f // - // Weather objects (property bit 0x04 set, bit 0x08 unset) - // have their frame origin set to player_pos + (0, 0, -120m). - // The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local - // Z range 0.11..814.90 (815m tall, 113m radius). Without the - // offset the cylinder bottom sits at z=0.11 ABOVE the camera - // (skyView translation is zeroed so model-origin == camera); - // looking horizontally shows nothing, looking up shows a - // distant cylinder. With -120m the cylinder spans z = - // (camera-119.89)..(camera+694.90) in view space — camera - // is inside, looking in any direction shows surrounding - // walls — the volumetric foreground-rain look retail has. - if (postScenePass) + // Gate: bit 0x04 (weather) set AND bit 0x08 unset. NOT every + // post-scene SkyObject — bit 0x01 (post-scene) is independent + // of bit 0x04 (weather). Today's Dereth ships every post-scene + // entry as also weather-flagged so the previous unconditional + // offset was a no-op divergence, but a future DayGroup with a + // post-scene-but-not-weather entry (e.g. a foreground sun rim) + // would have been pushed 120m below the camera and rendered as + // floor lint. + // + // Without the offset on the rain cylinder GfxObjs + // 0x01004C42/0x01004C44 (local Z range 0.11..814.90) the + // cylinder bottom sits at z=0.11 ABOVE the camera (skyView + // translation is zeroed so model-origin == camera); looking + // horizontally shows nothing. With -120m the cylinder spans z + // = (camera-119.89)..(camera+694.90) — camera is inside, + // looking in any direction shows surrounding walls — the + // volumetric foreground-rain look retail has. + if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u) model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f); _shader.SetMatrix4("uModel", model); @@ -343,20 +359,17 @@ public sealed unsafe class SkyRenderer : IDisposable float effEmissive = float.IsNaN(replaceLuminosity) ? sub.SurfLuminosity : replaceLuminosity; + float effDiffuse = float.IsNaN(replaceDiffuse) + ? sub.SurfDiffuse + : replaceDiffuse; _shader.SetFloat("uEmissive", effEmissive); + _shader.SetFloat("uDiffuseFactor", effDiffuse); - // Retail per-Surface translucency override (D3DPolyRender::SetSurface - // at 0x59c7a6, decomp 425255-425260): when the Surface's - // Translucent (0x10) bit is set, retail computes - // curr_alpha = _ftol2(translucency × 255) and writes it as vertex - // alpha — i.e. the dat's Translucency float is the OPACITY - // directly, NOT inverted. ACViewer and WorldBuilder both invert - // it (1 - x) and are wrong by the same misread. The shader uses - // it directly as an opacity multiplier; for non-Translucent - // surfaces the GfxObjMesh.Build() path keeps SurfTranslucency=1.0 - // (no effect). Critical for rain (Translucency=0.5 → opacity 0.5) - // and clouds (Translucency=0.25 → opacity 0.25, dim like retail). - _shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency); + // Material alpha is final opacity: 1 - Surface.Translucency + // for Translucent surfaces, 1 for non-Translucent surfaces. + // The CPU computes it once so the shader just multiplies it + // with texture alpha and keyframe transparency. + _shader.SetFloat("uSurfOpacity", sub.SurfOpacity); // Retail D3DPolyRender::SetSurface at 0x59c882 calls // SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) @@ -364,9 +377,12 @@ public sealed unsafe class SkyRenderer : IDisposable // additive cloud sheet are drawn WITHOUT fog. Skipping fog // on additive surfaces keeps the sun bright at horizon // dusk/dawn (where fog would otherwise dim it to fog color). - // Non-additive sky meshes (the dome, opaque cloud layers) - // still mix toward fog with the floor mitigation in sky.frag. - _shader.SetFloat("uApplyFog", sub.IsAdditive ? 0f : 1f); + // Non-additive sky meshes (the dome/background layers) + // still mix toward keyframe fog with the floor mitigation + // in sky.frag. That restores the broad green/purple Rainy + // DayGroup tint behind the cloud sheet while raw-additive + // 0x08000023 remains unfogged and keeps the pink detail. + _shader.SetFloat("uApplyFog", sub.DisableFog ? 0f : 1f); uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); @@ -393,14 +409,17 @@ public sealed unsafe class SkyRenderer : IDisposable // Scrolling clouds are also forced to REPEAT (the running // UV offset can drift outside [0,1] regardless of authored // range, and they'd show their own seam bleed otherwise). + // + // Implementation: bind a persistent sampler object to + // texture unit 0. Sampler state overrides the texture's + // own wrap state, so two renderers can share the same + // texture handle but sample it with different wrap modes + // safely. Ported from WorldBuilder + // (Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:312). bool needsRepeat = sub.NeedsUvRepeat || obj.TexVelocityX != 0f || obj.TexVelocityY != 0f; - int wrapMode = needsRepeat - ? (int)TextureWrapMode.Repeat - : (int)TextureWrapMode.ClampToEdge; - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, wrapMode); + _gl.BindSampler(0, needsRepeat ? _samplers.Wrap : _samplers.Clamp); _gl.BindVertexArray(sub.Vao); _gl.DrawElements(PrimitiveType.Triangles, @@ -411,6 +430,12 @@ public sealed unsafe class SkyRenderer : IDisposable } // Restore GL state expected by the rest of the pipeline. + // Critical: unbind the sampler from unit 0. While bound, sampler + // state overrides the texture's own wrap parameters, so leaving + // (e.g.) Clamp bound would silently force ClampToEdge on every + // subsequent draw on unit 0 regardless of how that texture was + // configured at upload time. + _gl.BindSampler(0, 0); _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); _gl.Enable(EnableCap.DepthTest); @@ -639,7 +664,7 @@ public sealed unsafe class SkyRenderer : IDisposable Console.WriteLine( $"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " + $"OrigTexture=0x{origTex:X8} Translucency={trans} " + - $"SurfLuminosity={surface.Luminosity:F4} SurfTranslucency={surface.Translucency:F4}"); + $"SurfLuminosity={surface.Luminosity:F4} SurfaceTranslucency={surface.Translucency:F4}"); } } @@ -692,8 +717,10 @@ public sealed unsafe class SkyRenderer : IDisposable SurfaceId = sm.SurfaceId, IsAdditive = isAdditive, SurfLuminosity = sm.Luminosity, + SurfDiffuse = sm.Diffuse, NeedsUvRepeat = sm.NeedsUvRepeat, - SurfTranslucency = sm.SurfTranslucency, + SurfOpacity = sm.SurfOpacity, + DisableFog = sm.DisableFog, }; } @@ -733,6 +760,7 @@ public sealed unsafe class SkyRenderer : IDisposable /// docs/research/2026-04-23-sky-retail-verbatim.md §6. /// public float SurfLuminosity; + public float SurfDiffuse; /// /// True when the source mesh's authored UVs exceed [0,1] (e.g. /// the inner sky/star layer 0x010015EF and the cloud meshes — @@ -744,17 +772,11 @@ public sealed unsafe class SkyRenderer : IDisposable /// public bool NeedsUvRepeat; /// - /// Surface.Translucency float (0..1) carried through from - /// . Passed to the - /// sky fragment shader as uSurfTranslucency and used - /// DIRECTLY as opacity (NOT 1 - x). Retail's - /// D3DPolyRender::SetSurface at 0x59c7a6 - /// (decomp lines 425255-425260) computes - /// curr_alpha = _ftol2(translucency × 255) and writes that - /// as vertex.color.alpha — i.e. translucency is opacity directly. - /// For non-Translucent surfaces the GfxObjMesh.Build() path keeps - /// this at 1.0 so they stay fully opaque. + /// Final surface opacity from . + /// Translucent surfaces use 1 - Surface.Translucency; other + /// surfaces stay at 1.0. /// - public float SurfTranslucency; + public float SurfOpacity; + public bool DisableFog; } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index e59a2559..077a12cb 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -178,8 +178,9 @@ public sealed unsafe class TextureCache : IDisposable if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) return DecodedTexture.Magenta; - var rs = _dats.Get((uint)surfaceTexture.Textures[0]); - if (rs is null) + uint renderSurfaceId = (uint)surfaceTexture.Textures[0]; + if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) + && !_dats.HighRes.TryGet(renderSurfaceId, out rs)) return DecodedTexture.Magenta; // Start with the texture's default palette, then apply overlays. diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index a4515ccf..93fd62eb 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -156,22 +156,20 @@ public static class GameEventWiring dispatcher.Register(GameEventType.VictimNotification, e => { var p = GameEvents.ParseVictimNotification(e.Payload.Span); - if (p is not null) combat.OnVictimNotification( - p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType, - p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType); + if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error); }); dispatcher.Register(GameEventType.DefenderNotification, e => { var p = GameEvents.ParseDefenderNotification(e.Payload.Span); if (p is not null) combat.OnDefenderNotification( - p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType, + p.Value.AttackerName, 0u, p.Value.DamageType, p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical); }); dispatcher.Register(GameEventType.AttackerNotification, e => { var p = GameEvents.ParseAttackerNotification(e.Payload.Span); if (p is not null) combat.OnAttackerNotification( - p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent); + p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent); }); dispatcher.Register(GameEventType.EvasionAttackerNotification, e => { @@ -188,12 +186,15 @@ public static class GameEventWiring var p = GameEvents.ParseAttackDone(e.Payload.Span); if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError); }); + dispatcher.Register(GameEventType.CombatCommenceAttack, e => + { + if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span)) + combat.OnCombatCommenceAttack(); + }); dispatcher.Register(GameEventType.KillerNotification, e => { - // ISSUES.md #10 — orphan parser, never registered before. The - // server fires this after a player lands a killing blow. var p = GameEvents.ParseKillerNotification(e.Payload.Span); - if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid); + if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info); }); // ── Spells ──────────────────────────────────────────────── diff --git a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs index f3df54e8..d4fc1f59 100644 --- a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs +++ b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs @@ -3,60 +3,79 @@ using System.Buffers.Binary; namespace AcDream.Core.Net.Messages; /// -/// Outbound 0x0008 AttackTargetRequest GameAction. +/// Outbound combat attack GameActions. +/// +/// Retail/ACE use distinct payloads for melee and missile: /// -/// -/// Wire layout (inside the 0xF7B1 GameAction envelope): /// /// u32 0xF7B1 // GameAction envelope opcode /// u32 gameActionSequence // client sequence -/// u32 0x0008 // sub-opcode -/// u32 targetGuid // who to attack -/// f32 powerLevel // [0.0, 1.0] — the power bar position -/// f32 accuracyLevel // [0.0, 1.0] — for missile weapons +/// u32 0x0008 // TargetedMeleeAttack +/// u32 targetGuid /// u32 attackHeight // 1=High, 2=Medium, 3=Low +/// f32 powerLevel // [0.0, 1.0] +/// +/// u32 0xF7B1 +/// u32 gameActionSequence +/// u32 0x000A // TargetedMissileAttack +/// u32 targetGuid +/// u32 attackHeight +/// f32 accuracyLevel // [0.0, 1.0] /// -/// /// -/// -/// 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 -/// / AttackerNotification -/// / DefenderNotification / EvasionAttackerNotification / -/// EvasionDefenderNotification with the result. -/// -/// -/// -/// References: r02 §7 (wire format), r08 §3 opcode 0x0008. -/// +/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10, +/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE +/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and +/// holtburger protocol game_action.rs. /// public static class AttackTargetRequest { public const uint GameActionEnvelope = 0xF7B1u; - public const uint SubOpcode = 0x0008u; + public const uint TargetedMeleeAttackOpcode = 0x0008u; + public const uint TargetedMissileAttackOpcode = 0x000Au; + public const uint CancelAttackOpcode = 0x01B7u; - /// - /// Build the wire body for an attack request. - /// - /// [0..1] melee power bar position. - /// [0..1] missile accuracy bar position; pass 0 for melee. - /// 1=High, 2=Medium, 3=Low. - public static byte[] Build( + /// Build the wire body for a targeted melee attack. + public static byte[] BuildMelee( uint gameActionSequence, uint targetGuid, - float powerLevel, - float accuracyLevel, - uint attackHeight) + uint attackHeight, + float powerLevel) { - byte[] body = new byte[28]; + byte[] body = new byte[24]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMeleeAttackOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight); + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), powerLevel); + return body; + } + + /// Build the wire body for a targeted missile attack. + public static byte[] BuildMissile( + uint gameActionSequence, + uint targetGuid, + uint attackHeight, + float accuracyLevel) + { + byte[] body = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMissileAttackOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight); BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight); + return body; + } + + /// Build the wire body for cancelling an active attack request. + public static byte[] BuildCancel(uint gameActionSequence) + { + byte[] body = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), CancelAttackOpcode); return body; } } diff --git a/src/AcDream.Core.Net/Messages/CharacterActions.cs b/src/AcDream.Core.Net/Messages/CharacterActions.cs index 0da95053..4abbbc39 100644 --- a/src/AcDream.Core.Net/Messages/CharacterActions.cs +++ b/src/AcDream.Core.Net/Messages/CharacterActions.cs @@ -22,9 +22,17 @@ public static class CharacterActions public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode + [Flags] public enum CombatMode : uint { - Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5, + Undef = 0, + NonCombat = 0x01, + Melee = 0x02, + Missile = 0x04, + Magic = 0x08, + + ValidCombat = NonCombat | Melee | Missile | Magic, + CombatCombat = Melee | Missile | Magic, } /// Spend XP to raise an attribute (Strength, Endurance, etc). diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 1541e074..39b30cde 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages; /// /// /// -/// All other fields (weenie header, object description, motion tables, +/// Most other fields (extended weenie header, object description, motion tables, /// palettes, texture overrides, animation frames, velocity, ...) are /// consumed-but-ignored so the parse position ends up wherever the /// client-side caller wanted — a Parse call doesn't need to reach -/// the end of the body to return useful output. We stop after PhysicsData -/// since that's the last segment containing fields acdream cares about -/// in this phase. +/// the end of the body to return useful output. We read through the fixed +/// WeenieHeader prefix for Name/ItemType, then stop before optional header +/// tails. /// /// /// @@ -51,6 +51,8 @@ public static class CreateObject public const uint PaletteTypePrefix = 0x04000000u; /// SurfaceTexture dat id type prefix. public const uint SurfaceTextureTypePrefix = 0x05000000u; + /// Icon dat id type prefix. + public const uint IconTypePrefix = 0x06000000u; [Flags] public enum PhysicsDescriptionFlag : uint @@ -78,9 +80,9 @@ public static class 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. + /// 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. /// public readonly record struct Parsed( uint Guid, @@ -92,6 +94,7 @@ public static class CreateObject uint? BasePaletteId, float? ObjScale, string? Name, + uint? ItemType, ServerMotionState? MotionState, uint? MotionTableId, ushort InstanceSequence = 0, @@ -136,7 +139,59 @@ public static class CreateObject ushort? SideStepCommand = null, float? SideStepSpeed = null, ushort? TurnCommand = null, - float? TurnSpeed = null); + float? TurnSpeed = null, + byte MovementType = 0, + uint? MoveToParameters = null, + float? MoveToSpeed = null, + float? MoveToRunRate = null, + MoveToPathData? MoveToPath = null) + { + /// + /// 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. + /// + public bool IsServerControlledMoveTo => MovementType is 6 or 7; + + public bool MoveToCanRun => !MoveToParameters.HasValue + || (MoveToParameters.Value & 0x2u) != 0; + + /// + /// MovementParameters bit 9 (mask 0x200) — set when the creature is + /// chasing its target. Cross-checked against acclient.h:31423-31443 + /// (named retail) + ACE MovementParamFlags.MoveTowards. + /// + public bool MoveTowards => MoveToParameters.HasValue + && (MoveToParameters.Value & 0x200u) != 0; + } + + /// + /// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7). + /// Wire layout per MovementParameters::UnPackNet @ 0x0052ac50 + /// + the leading Origin + optional target guid for type 6: + /// + /// type 6 (MoveToObject) only: u32 TargetGuid + /// Origin: u32 cellId, then 3 floats (local x/y/z within the landblock) + /// MovementParameters (28 bytes, exact retail order): + /// u32 flags, f32 distance_to_object, f32 min_distance, + /// f32 fail_distance, f32 speed, f32 walk_run_threshhold, + /// f32 desired_heading + /// + /// (The trailing runRate float is captured separately on + /// .) + /// + 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); /// /// One entry in the InterpretedMotionState's Commands list (MotionItem). @@ -390,27 +445,39 @@ public static class CreateObject pos += 9 * 2; AlignTo4(ref pos); - // --- WeenieHeader: read just the Name field (second after flags). --- + // --- WeenieHeader: read the fixed prefix fields we need. --- + // ACE WorldObject_Networking.SerializeCreateObject writes: + // weenieFlags, Name, WeenieClassId(PackedDword), + // IconId(PackedDwordOfKnownType 0x06000000), ItemType, + // ObjectDescriptionFlags, align. string? name = null; + uint? itemType = null; if (body.Length - pos >= 4) { pos += 4; // skip weenieFlags u32 try { name = ReadString16L(body, ref pos); + _ = ReadPackedDword(body, ref pos); // WeenieClassId + _ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); + if (body.Length - pos >= 4) + itemType = ReadU32(body, ref pos); + if (body.Length - pos >= 4) + _ = ReadU32(body, ref pos); // ObjectDescriptionFlags + AlignTo4(ref pos); } catch { /* truncated name — partial result is still useful */ } } return new Parsed(guid, position, setupTableId, animParts, - textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId, + textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). Parsed PartialResult() => new( guid, position, setupTableId, animParts, - textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId); + textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId); } catch { @@ -528,6 +595,9 @@ public static class CreateObject float? sidestepSpeed = null; ushort? turnCommand = null; float? turnSpeed = null; + uint? moveToParameters = null; + float? moveToSpeed = null; + float? moveToRunRate = null; List? commands = null; // 0 = Invalid is the only union variant we care about for static @@ -630,14 +700,62 @@ public static class CreateObject } done:; } + else if (movementType is 6 or 7) + { + TryParseMoveToPayload( + mv, + p, + movementType, + out moveToParameters, + out moveToSpeed, + out moveToRunRate); + } return new ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, - sidestepCommand, sidestepSpeed, turnCommand, turnSpeed); + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, + movementType, + moveToParameters, + moveToSpeed, + moveToRunRate); } catch { return null; } } + + private static bool TryParseMoveToPayload( + ReadOnlySpan 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; + } } diff --git a/src/AcDream.Core.Net/Messages/DeleteObject.cs b/src/AcDream.Core.Net/Messages/DeleteObject.cs new file mode 100644 index 00000000..c18bb139 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/DeleteObject.cs @@ -0,0 +1,39 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound ObjectDelete GameMessage (opcode 0xF747). +/// +/// +/// Retail dispatch path: +/// CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 reads guid from +/// buf+4 and instance sequence from buf+8, then calls +/// SmartBox::HandleDeleteObject 0x00451EA0. ACE emits the same +/// layout from GameMessageDeleteObject. +/// +/// +public static class DeleteObject +{ + public const uint Opcode = 0xF747u; + + public readonly record struct Parsed(uint Guid, ushort InstanceSequence); + + /// + /// Parse a 0xF747 body. must start with the + /// 4-byte opcode, matching every other parser in this namespace. + /// + public static Parsed? TryParse(ReadOnlySpan 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); + } +} diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 68891402..d9131628 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -147,56 +147,34 @@ public static class GameEvents // ── Combat notifications ──────────────────────────────────────────────── - /// 0x01AC VictimNotification — "you got hit for X". - public readonly record struct VictimNotification( - string AttackerName, - uint AttackerGuid, - uint DamageType, - uint Damage, - uint HitQuadrant, - uint Critical, - uint AttackType); + /// 0x01AC VictimNotification - death message for the victim. + public readonly record struct VictimNotification(string DeathMessage); public static VictimNotification? ParseVictimNotification(ReadOnlySpan payload) { int pos = 0; - try - { - string name = ReadString16L(payload, ref pos); - if (payload.Length - pos < 24) return null; - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint atkType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - return new VictimNotification(name, guid, damageType, damage, quad, crit, atkType); - } + try { return new VictimNotification(ReadString16L(payload, ref pos)); } catch { return null; } } - /// 0x01AD KillerNotification — "you killed X". - public readonly record struct KillerNotification(string VictimName, uint VictimGuid); + /// 0x01AD KillerNotification - death message for the killer. + public readonly record struct KillerNotification(string DeathMessage); public static KillerNotification? ParseKillerNotification(ReadOnlySpan payload) { int pos = 0; - try - { - string name = ReadString16L(payload, ref pos); - if (payload.Length - pos < 4) return null; - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); - return new KillerNotification(name, guid); - } + try { return new KillerNotification(ReadString16L(payload, ref pos)); } catch { return null; } } - /// 0x01B1 AttackerNotification — "you hit X for Y%". + /// 0x01B1 AttackerNotification - "you hit X". public readonly record struct AttackerNotification( string DefenderName, uint DamageType, + double HealthPercent, uint Damage, - float DamagePercent); + uint Critical, + ulong AttackConditions); public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan payload) { @@ -204,23 +182,26 @@ public static class GameEvents try { string name = ReadString16L(payload, ref pos); - if (payload.Length - pos < 12) return null; - uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4; - return new AttackerNotification(name, damageType, damage, pct); + if (payload.Length - pos < 28) return null; + uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8; + uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8; + return new AttackerNotification(name, damageType, pct, damage, crit, cond); } catch { return null; } } - /// 0x01B2 DefenderNotification — "X hit you for Y". + /// 0x01B2 DefenderNotification - "X hit you". public readonly record struct DefenderNotification( string AttackerName, - uint AttackerGuid, uint DamageType, + double HealthPercent, uint Damage, uint HitQuadrant, - uint Critical); + uint Critical, + ulong AttackConditions); public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan payload) { @@ -228,40 +209,42 @@ public static class GameEvents try { string name = ReadString16L(payload, ref pos); - if (payload.Length - pos < 20) return null; - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - return new DefenderNotification(name, guid, dtype, dmg, quad, crit); + if (payload.Length - pos < 32) return null; + uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8; + uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8; + return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond); } catch { return null; } } - /// 0x01B3 EvasionAttackerNotification — "X evaded". + /// 0x01B3 EvasionAttackerNotification - "X evaded". public static string? ParseEvasionAttackerNotification(ReadOnlySpan payload) { int pos = 0; try { return ReadString16L(payload, ref pos); } catch { return null; } } - /// 0x01B4 EvasionDefenderNotification — "you evaded X". + /// 0x01B4 EvasionDefenderNotification - "you evaded X". public static string? ParseEvasionDefenderNotification(ReadOnlySpan payload) { int pos = 0; try { return ReadString16L(payload, ref pos); } catch { return null; } } - /// 0x01A7 AttackDone — (attackSequence, weenieError). + /// 0x01B8 CombatCommenceAttack - empty payload. + public static bool ParseCombatCommenceAttack(ReadOnlySpan payload) => payload.Length == 0; + + /// 0x01A7 AttackDone - single WeenieError value. public readonly record struct AttackDone(uint AttackSequence, uint WeenieError); public static AttackDone? ParseAttackDone(ReadOnlySpan payload) { - if (payload.Length < 8) return null; - return new AttackDone( - BinaryPrimitives.ReadUInt32LittleEndian(payload), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); + if (payload.Length < 4) return null; + return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload)); } // ── Spell enchantments ────────────────────────────────────────────────── diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 65791a71..87562815 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -127,6 +127,10 @@ public static class UpdateMotion float? sidestepSpeed = null; ushort? turnCommand = null; float? turnSpeed = null; + uint? moveToParameters = null; + float? moveToSpeed = null; + float? moveToRunRate = null; + CreateObject.MoveToPathData? moveToPath = null; List? commands = null; if (movementType == 0) @@ -135,7 +139,7 @@ public static class UpdateMotion // MovementInvalid branch, just reached via the header'd path. // Includes the Commands list (MotionItem[]) that carries // Actions, emotes, and other one-shots not in ForwardCommand. - if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; uint flags = packed & 0x7Fu; @@ -158,13 +162,13 @@ public static class UpdateMotion if ((flags & 0x1u) != 0) { - if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } if ((flags & 0x2u) != 0) { - if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } @@ -221,14 +225,108 @@ public static class UpdateMotion } done:; } + else if (movementType is 6 or 7) + { + TryParseMoveToPayload( + body, + pos, + movementType, + out moveToParameters, + out moveToSpeed, + out moveToRunRate, + out moveToPath); + } return new Parsed(guid, new CreateObject.ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, - sidestepCommand, sidestepSpeed, turnCommand, turnSpeed)); + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, + movementType, + moveToParameters, + moveToSpeed, + moveToRunRate, + moveToPath)); } catch { return null; } } + + private static bool TryParseMoveToPayload( + ReadOnlySpan 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 + // 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; + } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 3389fb74..885ec634 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1,6 +1,7 @@ using System.Buffers.Binary; using System.Net; using System.Threading.Channels; +using AcDream.Core.Combat; using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; @@ -53,12 +54,23 @@ public sealed class WorldSession : IDisposable uint? BasePaletteId, float? ObjScale, string? Name, + uint? ItemType, CreateObject.ServerMotionState? MotionState, uint? MotionTableId); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; + /// + /// Fires when the session parses a 0xF747 ObjectDelete game message. + /// Retail routes this through + /// CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 → + /// SmartBox::HandleDeleteObject 0x00451EA0; ACE emits it when + /// an object leaves the world, including the living creature object + /// after its corpse is created. + /// + public event Action? EntityDeleted; + /// /// Payload for : the server guid of the entity /// whose motion changed and its new server-side stance + forward command. @@ -634,10 +646,17 @@ public sealed class WorldSession : IDisposable parsed.Value.BasePaletteId, parsed.Value.ObjScale, parsed.Value.Name, + parsed.Value.ItemType, parsed.Value.MotionState, parsed.Value.MotionTableId)); } } + else if (op == DeleteObject.Opcode) + { + var parsed = DeleteObject.TryParse(body); + if (parsed is not null) + EntityDeleted?.Invoke(parsed.Value); + } else if (op == UpdateMotion.Opcode) { // Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an @@ -909,6 +928,48 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// Send retail ChangeCombatMode (0x0053). + public void SendChangeCombatMode(CombatMode mode) + { + uint seq = NextGameActionSequence(); + byte[] body = CharacterActions.BuildChangeCombatMode( + seq, + (CharacterActions.CombatMode)(uint)mode); + SendGameAction(body); + } + + /// Send retail TargetedMeleeAttack (0x0008). + public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel) + { + uint seq = NextGameActionSequence(); + byte[] body = AttackTargetRequest.BuildMelee( + seq, + targetGuid, + (uint)attackHeight, + powerLevel); + SendGameAction(body); + } + + /// Send retail TargetedMissileAttack (0x000A). + public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel) + { + uint seq = NextGameActionSequence(); + byte[] body = AttackTargetRequest.BuildMissile( + seq, + targetGuid, + (uint)attackHeight, + accuracyLevel); + SendGameAction(body); + } + + /// Send retail CancelAttack (0x01B7). + public void SendCancelAttack() + { + uint seq = NextGameActionSequence(); + byte[] body = AttackTargetRequest.BuildCancel(seq); + SendGameAction(body); + } + /// /// Phase I.6: send a TurbineChat RequestSendToRoomById to a /// global community room (General / Trade / LFG / Roleplay / diff --git a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs new file mode 100644 index 00000000..bf67a852 --- /dev/null +++ b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs @@ -0,0 +1,308 @@ +using AcDream.Core.Physics; + +namespace AcDream.Core.Combat; + +/// +/// Retail-faithful combat animation planner for server-sent motion commands. +/// +/// Retail evidence: +/// - ClientCombatSystem::ExecuteAttack (0x0056BB70) only sends the +/// targeted melee/missile GameAction and sets response state; it does not +/// locally choose or play a swing animation. +/// - ClientCombatSystem::HandleCommenceAttackEvent (0x0056AD20) +/// updates the power bar/busy state; it carries no MotionCommand. +/// - ACE Player_Melee.DoSwingMotion chooses a swing via +/// CombatManeuverTable.GetMotion and broadcasts that MotionCommand +/// in UpdateMotion. +/// +/// So acdream treats combat GameEvents as state/UI signals and treats +/// UpdateMotion command IDs as the animation authority. +/// +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; +} diff --git a/src/AcDream.Core/Combat/CombatManeuverSelector.cs b/src/AcDream.Core/Combat/CombatManeuverSelector.cs new file mode 100644 index 00000000..8d0d07fc --- /dev/null +++ b/src/AcDream.Core/Combat/CombatManeuverSelector.cs @@ -0,0 +1,89 @@ +using DatReaderWriter.DBObjs; +using DatMotionCommand = DatReaderWriter.Enums.MotionCommand; +using DatMotionStance = DatReaderWriter.Enums.MotionStance; +using DatAttackHeight = DatReaderWriter.Enums.AttackHeight; +using DatAttackType = DatReaderWriter.Enums.AttackType; + +namespace AcDream.Core.Combat; + +/// +/// Selects combat swing motions from the retail CombatTable DBObj. +/// +/// Retail evidence: +/// - CombatManeuverTable::Get (0x0056AB60) loads DB type +/// 0x1000000D for a 0x30xxxxxx combat table id. +/// - ACE CombatManeuverTable.GetMotion indexes maneuvers by +/// stance, attack height, and attack type, returning all matching motions. +/// - ACE Player_Melee.GetSwingAnimation then chooses +/// motions[1] when more than one motion exists and power is below +/// the subdivision threshold; otherwise it uses motions[0]. +/// +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 FindMotions( + CombatTable table, + DatMotionStance stance, + DatAttackHeight attackHeight, + DatAttackType attackType) + { + var result = new List(); + + 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 Candidates, + DatAttackType EffectiveAttackType, + float Subdivision) +{ + public static CombatManeuverSelection None { get; } = new( + Found: false, + Motion: DatMotionCommand.Invalid, + Candidates: Array.Empty(), + EffectiveAttackType: DatAttackType.Undef, + Subdivision: 0f); +} diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index a70d6d72..a57d37d3 100644 --- a/src/AcDream.Core/Combat/CombatModel.cs +++ b/src/AcDream.Core/Combat/CombatModel.cs @@ -7,14 +7,17 @@ namespace AcDream.Core.Combat; // Full research: docs/research/deepdives/r02-combat-system.md // ───────────────────────────────────────────────────────────────────── +[Flags] public enum CombatMode { Undef = 0, - NonCombat = 1, - Melee = 2, - Missile = 3, - Magic = 4, - Peaceful = 5, + NonCombat = 0x01, + Melee = 0x02, + Missile = 0x04, + Magic = 0x08, + + ValidCombat = NonCombat | Melee | Missile | Magic, + CombatCombat = Melee | Missile | Magic, } public enum AttackHeight @@ -24,6 +27,51 @@ public enum AttackHeight Low = 3, } +public enum CombatAttackAction +{ + Low, + Medium, + High, +} + +/// +/// 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. +/// +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, + }; +} + /// /// Retail uses a 15-bit flags enum for attack types — weapon categories. /// See r02 §2 + ACE.Entity.Enum.AttackType. @@ -31,20 +79,26 @@ public enum AttackHeight [Flags] public enum AttackType : uint { - None = 0, - Punch = 0x0001, - Kick = 0x0002, - Thrust = 0x0004, - Slash = 0x0008, - DoubleSlash = 0x0010, - TripleSlash = 0x0020, - DoubleThrust = 0x0040, - TripleThrust = 0x0080, - Offhand = 0x0100, - OffhandSlash = 0x0200, - OffhandThrust = 0x0400, - ThrustSlash = 0x0800, - // more in r02 §2 + None = 0, + Punch = 0x0001, + Thrust = 0x0002, + Slash = 0x0004, + Kick = 0x0008, + OffhandPunch = 0x0010, + DoubleSlash = 0x0020, + TripleSlash = 0x0040, + DoubleThrust = 0x0080, + TripleThrust = 0x0100, + OffhandThrust = 0x0200, + OffhandSlash = 0x0400, + OffhandDoubleSlash = 0x0800, + OffhandTripleSlash = 0x1000, + OffhandDoubleThrust = 0x2000, + OffhandTripleThrust = 0x4000, + Unarmed = Punch | Kick | OffhandPunch, + MultiStrike = DoubleSlash | TripleSlash | DoubleThrust | TripleThrust + | OffhandDoubleSlash | OffhandTripleSlash + | OffhandDoubleThrust | OffhandTripleThrust, } [Flags] diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 93a5094c..15018b0f 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -39,6 +39,8 @@ public sealed class CombatState { private readonly ConcurrentDictionary _healthByGuid = new(); + public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat; + /// Fires when a target's health percent changes (from UpdateHealth). public event Action? HealthChanged; @@ -57,6 +59,12 @@ public sealed class CombatState /// An attack commit completed (0x01A7). WeenieError = 0 on success. public event Action? AttackDone; + /// The server accepted the attack and the power bar/animation can begin. + public event Action? AttackCommenced; + + /// The locally requested or server-confirmed combat mode changed. + public event Action? CombatModeChanged; + /// /// Fires when the server confirms the player landed a killing blow /// (GameEvent KillerNotification (0x01AD)). Event payload is @@ -94,6 +102,15 @@ public sealed class CombatState HealthChanged?.Invoke(targetGuid, healthPercent); } + public void SetCombatMode(CombatMode mode) + { + if (CurrentMode == mode) + return; + + CurrentMode = mode; + CombatModeChanged?.Invoke(mode); + } + public void OnVictimNotification( string attackerName, uint attackerGuid, uint damageType, uint damage, uint hitQuadrant, uint critical, uint attackType) @@ -140,5 +157,8 @@ public sealed class CombatState public void OnAttackDone(uint attackSequence, uint weenieError) => AttackDone?.Invoke(attackSequence, weenieError); + public void OnCombatCommenceAttack() + => AttackCommenced?.Invoke(); + public void Clear() => _healthByGuid.Clear(); } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 47f43685..24ed7a56 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -200,21 +200,14 @@ public static class GfxObjMesh // docs/research/2026-04-23-sky-retail-verbatim.md §6). var translucency = TranslucencyKind.Opaque; var luminosity = 0f; - // SurfTranslucency = the OPACITY multiplier the shader applies - // to fragment alpha. 1.0 = fully opaque (default, non-Translucent - // surfaces). For Translucent-flag surfaces, retail's - // D3DPolyRender::SetSurface at 0x59c7a6 (decomp lines 425255- - // 425260) computes curr_alpha = _ftol2(translucency × 255) and - // feeds that as vertex.color.alpha — so the dat's Translucency - // float is the OPACITY directly (NOT inverted). For rain - // (translucency=0.5) opacity is 0.5; for cloud surface - // 0x08000023 (translucency=0.25) opacity is 0.25 — that's why - // retail's clouds are dim and acdream's were 3× too bright - // before this fix (we used 1-translucency, inverting the - // semantic). ACViewer's TextureCache.cs:142 and WorldBuilder's - // ObjectMeshManager.cs:1115 also use 1-translucency and are - // both wrong by the same misread. - var surfTranslucency = 1.0f; + // SurfOpacity = (1 - Surface.Translucency) for Translucent + // surfaces, 1.0 otherwise. See + // TranslucencyKindExtensions.OpacityFromSurfaceTranslucency for + // the decomp citation (CMaterial::SetTranslucencySimple at + // 0x005396f0 writes material alpha as 1 - translucency). + var diffuse = 1f; + var surfOpacity = 1f; + var disableFog = false; if (dats is not null) { var surface = dats.Get(surfaceId); @@ -222,13 +215,16 @@ public static class GfxObjMesh { translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); luminosity = surface.Luminosity; + diffuse = surface.Diffuse; // Apply the dat's Translucency value as opacity ONLY // when the Translucent flag (0x10) is set on the // Surface. Without this gate, surfaces with // Translucency=0 (non-Translucent default) would // render fully transparent. - if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0) - surfTranslucency = surface.Translucency; + surfOpacity = TranslucencyKindExtensions.OpacityFromSurfaceTranslucency( + surface.Type, + surface.Translucency); + disableFog = TranslucencyKindExtensions.DisablesFixedFunctionFog(surface.Type); } } @@ -256,8 +252,10 @@ public static class GfxObjMesh { Translucency = translucency, Luminosity = luminosity, + Diffuse = diffuse, NeedsUvRepeat = needsUvRepeat, - SurfTranslucency = surfTranslucency, + SurfOpacity = surfOpacity, + DisableFog = disableFog, }); } return result; diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index 31542a60..6b517e78 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -13,67 +13,40 @@ public sealed record GfxObjSubMesh( { /// /// How this sub-mesh should be composited into the frame. - /// Populated from Surface.Type flags at upload time (requires a DatCollection). - /// Defaults to so offline fixtures - /// that don't supply dat access compile and pass unchanged. + /// Populated from Surface.Type flags at upload time. /// public TranslucencyKind Translucency { get; init; } = TranslucencyKind.Opaque; /// - /// Self-illumination strength of the Surface (Surface.Luminosity - /// field, 0..1 fraction — NOT the SurfaceType.Luminous flag bit). - /// Retail uses this as an emissive coefficient in the per-vertex - /// lighting formula: - /// - /// tint = clamp(vec3(Luminosity) + AmbColor + diffuse * DirColor, 0, 1) - /// fragment = texture * tint - /// - /// For Dereth's sky meshes, the DOME (0x010015EE) and SUN/MOON - /// (0x01001348) have Luminosity=1.0 (self-illuminated — emissive - /// saturates the lighting math so the baked texture always renders - /// at full brightness). CLOUDS (0x010015EF, 0x01004C36) have - /// Luminosity=0.0 (lit by ambient+diffuse — pick up the - /// time-of-day tint). See - /// docs/research/2026-04-23-sky-retail-verbatim.md §6. - /// Defaults to 0.0 (fully lit) so non-sky meshes render through the - /// normal lighting path without change. + /// Surface.Luminosity. Retail uses this as material emissive. /// public float Luminosity { get; init; } = 0f; /// - /// True when at least one vertex's UV component lies outside the - /// [0, 1] range, meaning the mesh was authored to have its - /// texture tile across the geometry (i.e. it expects - /// GL_REPEAT/D3DTADDRESS_WRAP). The sky renderer reads - /// this to decide between GL_REPEAT (this flag set, or any - /// scrolling layer) and GL_CLAMP_TO_EDGE (all UVs strictly - /// in [0,1]), which avoids wall-seam bleed on the dome - /// (UVs in [0,1]) while still tiling the inner star/cloud - /// layers (UVs in [~0.4, ~4.6]) correctly. - /// Defaults to false so non-sky consumers get the previous behavior. + /// Surface.Diffuse. Retail sky keyframes route SkyObjectReplace.MaxBright + /// through CPhysicsObj::SetDiffusion (0x005119e0), which lands in + /// CMaterial::SetDiffuseSimple (0x00539750). + /// + public float Diffuse { get; init; } = 1f; + + /// + /// True when at least one vertex UV component lies outside [0, 1], so + /// the mesh expects texture repeat instead of clamp. /// public bool NeedsUvRepeat { get; init; } = false; /// - /// Surface.Translucency float (0..1) treated as an OPACITY - /// multiplier on fragment alpha. 1.0 = fully opaque (default for - /// non-Translucent surfaces). Distinct from the - /// classifier above, which buckets the - /// flag bits. Retail's D3DPolyRender::SetSurface at - /// 0x59c7a6 (decomp lines 425255-425260) reads - /// Surface.Translucency when the Translucent (0x10) bit - /// is set, computes curr_alpha = _ftol2(translucency × 255), - /// and writes that as vertex alpha — i.e. the dat's Translucency float - /// is used DIRECTLY as opacity, NOT inverted. ACViewer - /// (TextureCache.cs:142) and WorldBuilder - /// (ObjectMeshManager.cs:1115) both use 1 - translucency - /// and are wrong by the same misread. - /// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5; - /// with the (SrcAlpha, One) additive blend the rain streaks - /// contribute at half intensity. For cloud surface 0x08000023 - /// (translucency=0.25): opacity = 0.25 (matches retail's dim clouds). - /// Defaults to 1.0 (fully opaque) so non-Translucent surfaces render - /// at full opacity without change. + /// Final opacity multiplier derived from Surface.Translucency. Retail + /// translucency is transparency: 0.0 is opaque and 1.0 is invisible. + /// CMaterial::SetTranslucencySimple at 0x005396f0 writes material alpha + /// as 1 - translucency. /// - public float SurfTranslucency { get; init; } = 1f; + public float SurfOpacity { get; init; } = 1f; + + /// + /// 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. + /// + public bool DisableFog { get; init; } = false; } diff --git a/src/AcDream.Core/Meshing/TranslucencyKind.cs b/src/AcDream.Core/Meshing/TranslucencyKind.cs index 07aaa290..d4ab468c 100644 --- a/src/AcDream.Core/Meshing/TranslucencyKind.cs +++ b/src/AcDream.Core/Meshing/TranslucencyKind.cs @@ -106,4 +106,25 @@ public static class TranslucencyKindExtensions return TranslucencyKind.Opaque; } + + /// + /// Retail translucency is transparency: 0 = opaque, 1 = invisible. + /// CMaterial::SetTranslucencySimple at 0x005396f0 writes material alpha + /// as 1 - translucency. + /// + public static float OpacityFromSurfaceTranslucency(SurfaceType type, float translucency) + { + if ((type & SurfaceType.Translucent) == 0) + return 1f; + + return Math.Clamp(1f - translucency, 0f, 1f); + } + + /// + /// 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. + /// + public static bool DisablesFixedFunctionFog(SurfaceType type) + => (type & SurfaceType.Additive) != 0; } diff --git a/src/AcDream.Core/Physics/AnimationCommandRouter.cs b/src/AcDream.Core/Physics/AnimationCommandRouter.cs new file mode 100644 index 00000000..1101c03c --- /dev/null +++ b/src/AcDream.Core/Physics/AnimationCommandRouter.cs @@ -0,0 +1,97 @@ +namespace AcDream.Core.Physics; + +/// +/// 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. +/// +public static class AnimationCommandRouter +{ + private const uint ActionMask = 0x10000000u; + private const uint ModifierMask = 0x20000000u; + private const uint SubStateMask = 0x40000000u; + private const uint ClassMask = 0xFF000000u; + + /// + /// Classifies a reconstructed full MotionCommand. + /// + 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; + } + + /// + /// Reconstructs and routes a 16-bit wire command. + /// + public static AnimationCommandRouteKind RouteWireCommand( + AnimationSequencer sequencer, + uint currentStyle, + ushort wireCommand, + float speedMod = 1f) + { + uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand); + return RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod); + } + + /// + /// Routes a full MotionCommand to the matching sequencer API. + /// + 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, +} diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 9afe076e..ffce8e18 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -330,6 +330,33 @@ public sealed class AnimationSequencer /// makes the jump look delayed (legs stand still for ~100 ms while /// the link drains, then fold into Falling). Defaults to false to /// preserve normal smooth transitions for everything else. + /// + /// 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. WalkForward → + /// Ready) instead of triggering 's + /// unconditional ClearCyclicTail 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). + /// + 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 ─── diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs index 1a0a3e2b..016d8e16 100644 --- a/src/AcDream.Core/Physics/MotionCommandResolver.cs +++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs @@ -84,6 +84,24 @@ public static class MotionCommandResolver result[lo] = full; } } + + ApplyNamedRetailOverrides(result); return result; } + + private static void ApplyNamedRetailOverrides(Dictionary 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; + } } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 81d8201a..038f675a 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -72,12 +72,20 @@ public static class MotionCommand /// regular SetCycle transition. /// public const uint FallDown = 0x10000050u; - /// 0x10000057 — Dead. - public const uint Dead = 0x10000057u; + /// 0x40000011 - persistent dead substate. + public const uint Dead = 0x40000011u; + /// 0x10000057 - Sanctuary death-trigger action. + public const uint Sanctuary = 0x10000057u; + /// 0x41000012 - crouching substate. + public const uint Crouch = 0x41000012u; + /// 0x41000013 - sitting substate. + public const uint Sitting = 0x41000013u; + /// 0x41000014 - sleeping substate. + public const uint Sleeping = 0x41000014u; /// 0x41000011 — Crouch lower bound for blocked-jump check. public const uint CrouchLowerBound = 0x41000011u; - /// 0x41000014 — upper bound of crouch/sit/sleep range. - public const uint CrouchUpperBound = 0x41000014u; + /// 0x41000015 - exclusive upper bound of crouch/sit/sleep range. + public const uint CrouchUpperExclusive = 0x41000015u; } /// @@ -819,7 +827,7 @@ public sealed class MotionInterpreter /// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false: /// return 0x49 /// uVar1 = InterpretedState.ForwardCommand - /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead): + /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead): /// return 0x48 /// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range): /// return 0x48 @@ -850,7 +858,7 @@ public sealed class MotionInterpreter return false; // Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015). - if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound) + if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive) return false; // Need Gravity flag + Contact + OnWalkable for ground-based motion. diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs new file mode 100644 index 00000000..90a0388b --- /dev/null +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -0,0 +1,304 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Per-tick steering for server-controlled remote creatures while a +/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet +/// is the active locomotion source. +/// +/// +/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo" +/// stabilizer. With the full MoveTo path payload now captured on +/// , +/// 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. +/// +/// +/// +/// Retail references: +/// +/// +/// MoveToManager::HandleMoveToPosition (0x00529d80) — the +/// per-tick driver. Computes heading-to-target, fires an aux +/// TurnLeft/TurnRight command when |delta| > 20°, snaps +/// orientation when within tolerance, and tests arrival via +/// dist <= min_distance (chase) or +/// dist >= distance_to_object (flee). +/// +/// +/// MoveToManager::_DoMotion / _StopMotion route turn +/// commands through CMotionInterp::DoInterpretedMotion — i.e. +/// MoveToManager itself does NOT touch the body. The body's actual +/// velocity comes from CMotionInterp::apply_current_movement +/// reading InterpretedState.ForwardCommand = RunForward and +/// emitting velocity.Y = RunAnimSpeed × speedMod, transformed by +/// the body's orientation. +/// +/// +/// +/// +/// +/// 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 set_heading(true) snap-on-aligned fudge), and +/// arrival detection via min_distance. +/// +/// +/// +/// ACE divergence: ACE swaps the chase/flee arrival predicates +/// (dist <= DistanceToObject vs retail's dist <= MinDistance). +/// We follow retail. +/// +/// +public static class RemoteMoveToDriver +{ + /// + /// Heading tolerance below which we snap orientation directly to the + /// target heading (ACE's set_heading(target, true) + /// server-tic-rate fudge). Above tolerance we rotate at + /// . Retail value (line 307251 of + /// acclient_2013_pseudo_c.txt) is 20°. + /// + public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f; + + /// + /// Default angular rate for in-motion heading correction when delta + /// exceeds . Picked to match + /// ACE's TurnSpeed default of π/2 rad/s for monsters; + /// when the per-creature value differs, the future port can wire it + /// in via the TurnSpeed field on InterpretedMotionState. + /// + public const float TurnRateRadPerSec = MathF.PI / 2.0f; + + /// + /// Float-comparison slack for the arrival predicate. With + /// min_distance == 0 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. + /// + public const float ArrivalEpsilon = 0.05f; + + /// + /// 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. + /// + public const double StaleDestinationSeconds = 1.5; + + public enum DriveResult + { + /// Within arrival window — caller should zero velocity. + Arrived, + /// Steering active — caller should let + /// apply_current_movement set body velocity from the cycle. + Steering, + } + + /// + /// Steer body orientation toward + /// and report whether the body has arrived or should keep running. + /// Pure function — emits the updated orientation via + /// (the input is not mutated; the + /// caller assigns the new value back to its body). + /// + /// + /// min_distance from the wire's MovementParameters block — + /// retail's HandleMoveToPosition chase-arrival threshold. + /// + /// + /// distance_to_object from the wire — ACE's chase-arrival + /// threshold (default 0.6 m, the melee range). The actual arrival + /// gate is max(minDistance, distanceToObject): retail-faithful + /// when retail sends min_distance > 0, ACE-compatible when + /// ACE puts the value in distance_to_object with + /// min_distance == 0. Without this, ACE's min_distance==0 + /// 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). + /// + 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 max(MinDistance, DistanceToObject) 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; + } + + /// + /// Convert a landblock-local Origin from a MoveTo packet + /// () + /// into acdream's render world space using the same arithmetic as + /// OnLivePositionUpdated: shift by the landblock-grid offset + /// from the live-mode center. + /// + 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); + } + + /// + /// Cap horizontal velocity so the body lands exactly at + /// rather than overshooting past + /// it during the final tick of approach. Without this clamp, a body + /// running at RunAnimSpeed × speedMod ≈ 4 m/s 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). + /// + /// + /// The clamp is a strict scale-down of the horizontal component + /// (X/Y); the vertical component (Z) is left to gravity / terrain + /// handling. false (flee branch) is a + /// no-op since fleeing has no overshoot risk — the body wants to + /// move AWAY from the destination. + /// + /// + 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); + } + + /// Wrap an angle in radians to [-π, π]. + 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; + } +} diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs new file mode 100644 index 00000000..af4d14dd --- /dev/null +++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs @@ -0,0 +1,87 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Chooses the visible locomotion cycle for server-controlled remotes whose +/// UpdateMotion packet is a MoveToObject/MoveToPosition union rather than an +/// InterpretedMotionState. +/// +/// Retail references: +/// +/// +/// MovementManager::PerformMovement (0x00524440) dispatches movement +/// types 6/7 into MoveToManager::MoveToObject/MoveToPosition instead +/// of unpacking an InterpretedMotionState. +/// +/// +/// MovementParameters::UnPackNet (0x0052AC50) shows MoveTo packets +/// carry movement params + run rate, not a ForwardCommand field. +/// +/// +/// ACE MovementData.Write uses the same movement type union; holtburger +/// documents the matching MovementType::MoveToPosition = 7. +/// +/// +/// +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; + } +} diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 9bb6aa69..e48b9a40 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -37,9 +37,9 @@ public static class SurfaceDecoder PixelFormat.PFID_R8G8B8 => DecodeR8G8B8(rs), PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs), PixelFormat.PFID_X8R8G8B8 => DecodeX8R8G8B8(rs), - PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1), - PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2), - PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3), + PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap), + PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap), + PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap), PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs), PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap), PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), @@ -245,7 +245,7 @@ public static class SurfaceDecoder return new DecodedTexture(rgba, rs.Width, rs.Height); } - private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format) + private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap) { var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); var rgba = new byte[rs.Width * rs.Height * 4]; @@ -256,6 +256,8 @@ public static class SurfaceDecoder rgba[s + 1] = pixels[i].g; rgba[s + 2] = pixels[i].b; rgba[s + 3] = pixels[i].a; + if (isClipMap && rgba[s + 0] == 0 && rgba[s + 1] == 0 && rgba[s + 2] == 0) + rgba[s + 3] = 0; } return new DecodedTexture(rgba, rs.Width, rs.Height); } diff --git a/src/AcDream.Core/Vfx/EmitterDescLoader.cs b/src/AcDream.Core/Vfx/EmitterDescLoader.cs index 4f247d40..86235247 100644 --- a/src/AcDream.Core/Vfx/EmitterDescLoader.cs +++ b/src/AcDream.Core/Vfx/EmitterDescLoader.cs @@ -1,73 +1,38 @@ using System; using System.Collections.Concurrent; using System.Numerics; +using DatReaderWriter; +using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter; +using DatEmitterType = DatReaderWriter.Enums.EmitterType; +using DatParticleType = DatReaderWriter.Enums.ParticleType; namespace AcDream.Core.Vfx; /// -/// Resolves instances by their retail emitter -/// dat id (0x32xxxxxx range). The current build of -/// Chorizite.DatReaderWriter (v2.1.7) doesn't yet ship a -/// ParticleEmitterInfo 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. -/// -/// -/// Field mapping once the dat-type arrives (docs/research/deepdives/ -/// r04-vfx-particles.md §1 + references/DatReaderWriter's own generated -/// ParticleEmitterInfo.generated.cs): -/// -/// -/// Birthrate1 / EmitRate (retail stores the avg -/// time between spawns, not the rate). -/// -/// -/// Lifespan ± LifespanRandLifetimeMin / LifetimeMax -/// range. -/// -/// -/// A, MinA, MaxA → primary initial velocity with magnitude -/// jitter; B / C are secondary spread components. -/// -/// -/// StartScale, FinalScale / StartTrans, FinalTrans -/// interpolate linearly over life. -/// -/// -/// +/// Resolves retail ParticleEmitterInfo dat records +/// (0x32xxxxxx) into acdream runtime descriptors. /// public sealed class EmitterDescRegistry { + private const uint FallbackEmitterId = 0xFFFFFFFFu; + + private readonly Func? _resolver; private readonly ConcurrentDictionary _byId = new(); public EmitterDescRegistry() + : this((Func?)null) { - // Seed with a handful of well-known AC emitter ids plus a - // fallback. Ids here come from empirical ACViewer dat dumps — - // see r04 §5.2 for the more complete inventory. - Register(new EmitterDesc - { - DatId = 0xFFFFFFFFu, // "default" sentinel - Type = ParticleType.LocalVelocity, - Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera, - EmitRate = 10f, - MaxParticles = 32, - LifetimeMin = 0.6f, - LifetimeMax = 1.2f, - OffsetDir = new Vector3(0, 0, 1), - MinOffset = 0f, - MaxOffset = 0.1f, - SpawnDiskRadius = 0.1f, - InitialVelocity = new Vector3(0, 0, 0.5f), - VelocityJitter = 0.3f, - StartSize = 0.25f, - EndSize = 0.6f, - StartAlpha = 0.85f, - EndAlpha = 0f, - }); + } + + public EmitterDescRegistry(DatCollection dats) + : this(id => SafeGet(dats, id)) + { + } + + public EmitterDescRegistry(Func? resolver) + { + _resolver = resolver; + Register(BuildFallback()); } public void Register(EmitterDesc desc) @@ -78,10 +43,159 @@ public sealed class EmitterDescRegistry public EmitterDesc Get(uint emitterId) { - if (_byId.TryGetValue(emitterId, out var desc)) return desc; - if (_byId.TryGetValue(0xFFFFFFFFu, out var fallback)) return fallback; + if (_byId.TryGetValue(emitterId, out var desc)) + return desc; + + if (_resolver is not null) + { + var dat = _resolver(emitterId); + if (dat is not null) + { + desc = FromDat(emitterId, dat); + _byId[emitterId] = desc; + return desc; + } + } + + if (_byId.TryGetValue(FallbackEmitterId, out var fallback)) + return fallback; + throw new InvalidOperationException("No default emitter registered in registry."); } public int Count => _byId.Count; + + public static EmitterDesc FromDat(uint emitterId, DatParticleEmitter dat) + { + ArgumentNullException.ThrowIfNull(dat); + + float birthrate = MathF.Max(0f, (float)dat.Birthrate); + float lifespan = MathF.Max(0f, (float)dat.Lifespan); + float lifespanRand = MathF.Abs((float)dat.LifespanRand); + float lifetimeMin = MathF.Max(0f, lifespan - lifespanRand); + float lifetimeMax = MathF.Max(lifetimeMin, lifespan + lifespanRand); + + // ParticleEmitterInfo has no "additive" field; retail derives blend + // state from the particle GfxObj surface material. + var flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera; + if (dat.IsParentLocal) + flags |= EmitterFlags.AttachLocal; + + // ParticleEmitterInfo stores translucency, not opacity. Retail feeds + // StartTrans/FinalTrans to PhysicsPart::SetTranslucency; the GL path + // uses the complement as source alpha. + float startOpacity = 1f - Math.Clamp((float)dat.StartTrans, 0f, 1f); + float endOpacity = 1f - Math.Clamp((float)dat.FinalTrans, 0f, 1f); + + return new EmitterDesc + { + DatId = emitterId, + Type = MapParticleType(dat.ParticleType), + EmitterKind = MapEmitterKind(dat.EmitterType), + Flags = flags, + GfxObjId = dat.GfxObjId.DataId, + HwGfxObjId = dat.HwGfxObjId.DataId, + Birthrate = birthrate, + EmitRate = dat.EmitterType == DatEmitterType.BirthratePerSec && birthrate > 0f + ? 1f / birthrate + : 0f, + MaxParticles = Math.Max(1, dat.MaxParticles), + InitialParticles = Math.Max(0, dat.InitialParticles), + TotalParticles = Math.Max(0, dat.TotalParticles), + TotalDuration = MathF.Max(0f, (float)dat.TotalSeconds), + Lifespan = lifespan, + LifespanRand = lifespanRand, + LifetimeMin = lifetimeMin, + LifetimeMax = lifetimeMax, + OffsetDir = dat.OffsetDir, + MinOffset = dat.MinOffset, + MaxOffset = dat.MaxOffset, + SpawnDiskRadius = dat.MaxOffset, + InitialVelocity = dat.A, + Gravity = dat.B, + A = dat.A, + MinA = dat.MinA, + MaxA = dat.MaxA, + B = dat.B, + MinB = dat.MinB, + MaxB = dat.MaxB, + C = dat.C, + MinC = dat.MinC, + MaxC = dat.MaxC, + StartSize = dat.StartScale, + EndSize = dat.FinalScale, + ScaleRand = dat.ScaleRand, + StartAlpha = startOpacity, + EndAlpha = endOpacity, + TransRand = dat.TransRand, + }; + } + + private static DatParticleEmitter? SafeGet(DatCollection dats, uint id) + { + if (dats is null) + return null; + try + { + return dats.Get(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, + }; } diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index 0054c8b7..bfb47e11 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Numerics; +using System.Threading; using AcDream.Core.Physics; using DatReaderWriter.Types; @@ -62,10 +63,30 @@ public sealed class ParticleHookSink : IAnimationHookSink // key ("the smoke trail I spawned 2 seconds ago"), so we track by // (entity, emitterId). private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); + // entityId → set of live emitter handles. Dictionary-as-set so we can + // remove individual handles when their emitter dies (M4 fix — + // ConcurrentBag couldn't drop entries, so handles for naturally-expired + // emitters used to leak). + private readonly ConcurrentDictionary> _handlesByEntity = new(); + // Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied. + private readonly ConcurrentDictionary _trackingByHandle = new(); + private readonly ConcurrentDictionary _renderPassByEntity = new(); + private readonly ConcurrentDictionary _rotationByEntity = new(); + private int _anonymousEmitterSerial; public ParticleHookSink(ParticleSystem system) { _system = system ?? throw new ArgumentNullException(nameof(system)); + _system.EmitterDied += OnEmitterDied; + } + + private void OnEmitterDied(int handle) + { + if (!_trackingByHandle.TryRemove(handle, out var t)) + return; + _handlesByKey.TryRemove((t.EntityId, t.KeyId), out _); + if (_handlesByEntity.TryGetValue(t.EntityId, out var bag)) + bag.TryRemove(handle, out _); } public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) @@ -104,6 +125,54 @@ public sealed class ParticleHookSink : IAnimationHookSink } } + public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) + => _renderPassByEntity[entityId] = renderPass; + + public void SetEntityRotation(uint entityId, Quaternion rotation) + => _rotationByEntity[entityId] = rotation; + + public void ClearEntityRenderPass(uint entityId) + => _renderPassByEntity.TryRemove(entityId, out _); + + /// + /// 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 + /// ParticleEmitter::UpdateParticles at 0x0051d2d4, which + /// re-reads the parent frame each tick when is_parent_local != 0. + /// Safe to call for entities with no live emitters (no-op). + /// + public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation) + { + _rotationByEntity[entityId] = rotation; + if (!_handlesByEntity.TryGetValue(entityId, out var bag)) + return; + foreach (var handle in bag.Keys) + _system.UpdateEmitterAnchor(handle, anchor, rotation); + } + + public void StopAllForEntity(uint entityId, bool fadeOut) + { + if (_handlesByEntity.TryRemove(entityId, out var handles)) + { + foreach (var handle in handles.Keys) + { + _system.StopEmitter(handle, fadeOut); + _trackingByHandle.TryRemove(handle, out _); + } + } + + foreach (var key in _handlesByKey.Keys) + { + if (key.EntityId == entityId) + _handlesByKey.TryRemove(key, out _); + } + + ClearEntityRenderPass(entityId); + _rotationByEntity.TryRemove(entityId, out _); + } + private void SpawnFromHook( uint entityId, Vector3 worldPos, @@ -115,15 +184,35 @@ public sealed class ParticleHookSink : IAnimationHookSink // Spawn position: entity pose + hook offset. PartIndex will be // used when the renderer passes per-part transforms through; for // now, fold it into the root pos. - var anchor = worldPos + offset; + var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) + ? rot + : Quaternion.Identity; + var anchor = worldPos + Vector3.Transform(offset, rotation); + var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass) + ? pass + : ParticleRenderPass.Scene; int handle = _system.SpawnEmitterById( emitterId: emitterInfoId, anchor: anchor, - rot: Quaternion.Identity, + rot: rotation, attachedObjectId: entityId, - attachedPartIndex: partIndex); + attachedPartIndex: partIndex, + renderPass: renderPass); - _handlesByKey[(entityId, logicalId)] = handle; + uint keyId = logicalId != 0 + ? logicalId + : 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial); + if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle)) + { + _system.StopEmitter(oldHandle, fadeOut: false); + _trackingByHandle.TryRemove(oldHandle, out _); + } + + _handlesByKey[(entityId, keyId)] = handle; + _handlesByEntity + .GetOrAdd(entityId, _ => new ConcurrentDictionary()) + .TryAdd(handle, 0); + _trackingByHandle[handle] = (entityId, keyId); } } diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index 1c85b5ad..53c5d700 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -5,33 +5,18 @@ using System.Numerics; namespace AcDream.Core.Vfx; /// -/// Runtime particle orchestrator — port of retail's CParticleManager -/// (r04 §2). Owns a pool of active 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. -/// -/// -/// Not thread-safe — called only from the render thread (same thread that -/// drives TickAnimations). -/// -/// -/// -/// Handle-based API so callers can stop a specific emitter later (cast -/// interrupt, fadeout). returns a positive -/// integer; accepts it. -/// +/// Runtime particle orchestrator. The data and update rules are a direct +/// port of retail's ParticleEmitterInfo, ParticleEmitter, and +/// Particle::Update paths from the named retail decompilation. /// 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 _byHandle = new(); private readonly List _handleOrder = new(); - private int _nextHandle = 1; + private int _nextHandle = 1; private float _time; private int _activeParticleCount; @@ -49,7 +34,8 @@ public sealed class ParticleSystem : IParticleSystem Vector3 anchor, Quaternion? rot = null, uint attachedObjectId = 0, - int attachedPartIndex = -1) + int attachedPartIndex = -1, + ParticleRenderPass renderPass = ParticleRenderPass.Scene) { ArgumentNullException.ThrowIfNull(desc); @@ -61,43 +47,45 @@ public sealed class ParticleSystem : IParticleSystem AnchorRot = rot ?? Quaternion.Identity, AttachedObjectId = attachedObjectId, AttachedPartIndex = attachedPartIndex, + RenderPass = renderPass, Particles = new Particle[Math.Max(1, desc.MaxParticles)], StartedAt = _time, + LastEmitTime = _time, + LastEmitOffset = anchor, }; + _byHandle[handle] = emitter; _handleOrder.Add(handle); + + for (int i = 0; i < desc.InitialParticles; i++) + SpawnOne(emitter, allowWhenFull: false); + return handle; } - /// - /// Convenience: spawn by retail emitter id — the registry resolves to - /// the correct , or falls back to the default - /// if unknown. Used by the hook sink when a CreateParticleHook arrives. - /// public int SpawnEmitterById( uint emitterId, Vector3 anchor, Quaternion? rot = null, uint attachedObjectId = 0, - int attachedPartIndex = -1) + int attachedPartIndex = -1, + ParticleRenderPass renderPass = ParticleRenderPass.Scene) { var desc = _registry.Get(emitterId); - return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex); + return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass); } public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f) { - // Full PhysicsScript dispatch is on hold until the DatReaderWriter - // library exposes ParticleEmitterInfo / PhysicsScript. For now, - // this is a no-op — callers use SpawnEmitter or the hook sink. + // Full PhysicsScript scheduling lives in PhysicsScriptRunner. } public void StopEmitter(int handle, bool fadeOut) { - if (!_byHandle.TryGetValue(handle, out var em)) return; + if (!_byHandle.TryGetValue(handle, out var em)) + return; + em.Finished = true; - // fadeOut=false would stop instantly; our renderer currently drops - // Finished emitters that have no living particles each tick. if (!fadeOut) { for (int i = 0; i < em.Particles.Length; i++) @@ -105,259 +93,454 @@ public sealed class ParticleSystem : IParticleSystem } } + /// + /// Refresh an active emitter's world anchor + orientation. Required for + /// retail's is_parent_local=1 (acdream's + /// ) semantics: retail + /// ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the + /// LIVE parent frame each tick when is_parent_local != 0. The + /// caller (typically a tick loop tracking a moving parent — the camera + /// for sky-PES, an entity for animation hooks) drives this every frame. + /// + 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; + } + + /// True when the given handle still maps to a live emitter. + public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle); + + /// + /// 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. ) use this to prune + /// per-entity handle tracking so the per-entity bag doesn't grow without + /// bound during a long session. + /// + public event Action? EmitterDied; + public void Tick(float dt) { - if (dt <= 0f) return; + if (dt <= 0f) + return; + _time += dt; _activeParticleCount = 0; - // Iterate handles by a snapshot so StopEmitter-inside-emit is safe. for (int i = 0; i < _handleOrder.Count; i++) { int handle = _handleOrder[i]; - if (!_byHandle.TryGetValue(handle, out var em)) continue; + if (!_byHandle.TryGetValue(handle, out var em)) + continue; - AdvanceEmitter(em, dt); - _activeParticleCount += CountAlive(em); + AdvanceEmitter(em); + int live = CountAlive(em); + em.ActiveCount = live; + _activeParticleCount += live; - bool durationDone = em.Desc.TotalDuration > 0f - && (_time - em.StartedAt) > em.Desc.TotalDuration; - if (durationDone) em.Finished = true; + if (em.Desc.TotalDuration > 0f && (_time - em.StartedAt) > em.Desc.TotalDuration) + em.Finished = true; - // Drop emitter entirely when it has no live particles AND is - // marked finished (duration elapsed, StopEmitter, etc). - if (em.Finished && CountAlive(em) == 0) + if (em.Desc.TotalParticles > 0 && em.TotalEmitted >= em.Desc.TotalParticles) + em.Finished = true; + + if (em.Finished && live == 0) { _byHandle.Remove(handle); _handleOrder.RemoveAt(i); i--; + EmitterDied?.Invoke(handle); } } } - /// - /// Enumerate every live particle with its emitter description for - /// the renderer. Yields (emitter, particleIndex) so the caller can - /// read em.Particles[idx] directly. - /// public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive() { foreach (var handle in _handleOrder) { - if (!_byHandle.TryGetValue(handle, out var em)) continue; + if (!_byHandle.TryGetValue(handle, out var em)) + continue; + for (int i = 0; i < em.Particles.Length; i++) { - if (em.Particles[i].Alive) yield return (em, i); + if (em.Particles[i].Alive) + yield return (em, i); } } } - // ── Private: emission + integration ────────────────────────────────────── - - private void AdvanceEmitter(ParticleEmitter em, float dt) + private void AdvanceEmitter(ParticleEmitter em) { - if (!em.Finished && em.Desc.EmitRate > 0f) - { - em.EmittedAccumulator += dt * em.Desc.EmitRate; - while (em.EmittedAccumulator >= 1.0f) - { - em.EmittedAccumulator -= 1.0f; - SpawnOne(em); - } - } - - // Update every particle slot. for (int i = 0; i < em.Particles.Length; i++) { ref var p = ref em.Particles[i]; - if (!p.Alive) continue; + if (!p.Alive) + continue; - p.Age += dt; - if (p.Age >= p.Lifetime) + p.Age = _time - p.SpawnedAt; + if (p.Lifetime <= 0f || p.Age >= p.Lifetime) { p.Alive = false; continue; } - Integrate(ref p, em, dt); - + p.Position = ComputePosition(em, p); float tLife = Math.Clamp(p.Age / p.Lifetime, 0f, 1f); - p.Size = Lerp(em.Desc.StartSize, em.Desc.EndSize, tLife); - float alpha = Lerp(em.Desc.StartAlpha, em.Desc.EndAlpha, tLife); + p.Size = Lerp(p.StartSize, p.EndSize, tLife); + p.Rotation = Lerp(em.Desc.StartRotation, em.Desc.EndRotation, tLife); + float alpha = Lerp(p.StartAlpha, p.EndAlpha, tLife); p.ColorArgb = Color32(alpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, tLife); } + + if (em.Finished || _time < em.StartedAt + em.Desc.StartDelay) + return; + + while (ShouldEmitParticle(em)) + { + if (!SpawnOne(em, allowWhenFull: false)) + break; + } + + if (em.Desc.Birthrate <= 0f && em.Desc.EmitRate > 0f) + { + float dt = _time - em.LastEmitTime; + em.EmittedAccumulator += dt * em.Desc.EmitRate; + em.LastEmitTime = _time; + while (em.EmittedAccumulator >= 1f) + { + em.EmittedAccumulator -= 1f; + if (!SpawnOne(em, allowWhenFull: false)) + break; + } + } } - private void SpawnOne(ParticleEmitter em) + private bool ShouldEmitParticle(ParticleEmitter em) { - // Find a free slot; overwrite the oldest if pool is full. - int slot = -1; - for (int i = 0; i < em.Particles.Length; i++) + var desc = em.Desc; + if (desc.TotalParticles > 0 && em.TotalEmitted >= desc.TotalParticles) + return false; + + if (CountAlive(em) >= desc.MaxParticles) + return false; + + if (desc.Birthrate <= 0f) + return false; + + return desc.EmitterKind switch { - if (!em.Particles[i].Alive) { slot = i; break; } - } + ParticleEmitterKind.BirthratePerSec => (_time - em.LastEmitTime) > desc.Birthrate, + ParticleEmitterKind.BirthratePerMeter => + Vector3.DistanceSquared(em.AnchorPos, em.LastEmitOffset) > desc.Birthrate * desc.Birthrate, + _ => false, + }; + } + + private bool SpawnOne(ParticleEmitter em, bool allowWhenFull) + { + int slot = FindFreeSlot(em); + if (slot < 0 && allowWhenFull) + slot = FindOldestSlot(em); if (slot < 0) - { - // Pool saturated; overwrite the slot closest to dying (oldest - // by age / lifetime ratio). Matches retail's behaviour of - // recycling the expiring particle rather than dropping. - float best = -1f; - for (int i = 0; i < em.Particles.Length; i++) - { - ref var p = ref em.Particles[i]; - float r = p.Lifetime > 0 ? p.Age / p.Lifetime : 1f; - if (r > best) { best = r; slot = i; } - } - if (slot < 0) return; - } + return false; ref var particle = ref em.Particles[slot]; + particle = default; particle.Alive = true; - particle.Age = 0f; - particle.Lifetime = Lerp(em.Desc.LifetimeMin, em.Desc.LifetimeMax, - (float)_rng.NextDouble()); - - // Position = emitter anchor + random offset in a disk perpendicular - // to OffsetDir. This models the retail annulus. - Vector3 disk = RandomDiskVector(em.Desc.OffsetDir, em.Desc.MaxOffset); - particle.Position = em.AnchorPos + disk; particle.SpawnedAt = _time; + particle.Lifetime = RandomLifespan(em.Desc); + particle.EmissionOrigin = em.AnchorPos; + particle.SpawnRotation = em.AnchorRot; - // Velocity = initial vector ± jitter in all three axes. - Vector3 v = em.Desc.InitialVelocity; - if (em.Desc.VelocityJitter > 0f) + Vector3 localOffset = RandomOffset(em.Desc); + Vector3 localA = RandomVector(em.Desc.A, em.Desc.MinA, em.Desc.MaxA); + Vector3 localB = RandomVector(em.Desc.B, em.Desc.MinB, em.Desc.MaxB); + Vector3 localC = RandomVector(em.Desc.C, em.Desc.MinC, em.Desc.MaxC); + + if (localA == Vector3.Zero && em.Desc.InitialVelocity != Vector3.Zero) { - v += new Vector3( - RandomCentered(em.Desc.VelocityJitter), - RandomCentered(em.Desc.VelocityJitter), - RandomCentered(em.Desc.VelocityJitter)); + localA = em.Desc.InitialVelocity; + if (em.Desc.VelocityJitter > 0f) + { + localA += new Vector3( + RandomCentered(em.Desc.VelocityJitter), + RandomCentered(em.Desc.VelocityJitter), + RandomCentered(em.Desc.VelocityJitter)); + } } - particle.Velocity = v; - particle.Size = em.Desc.StartSize; - particle.Rotation = em.Desc.StartRotation; - particle.ColorArgb = em.Desc.StartColorArgb; + if (localB == Vector3.Zero && em.Desc.Gravity != Vector3.Zero) + localB = em.Desc.Gravity; + + InitParticleVectors(em, ref particle, localOffset, localA, localB, localC); + + particle.Velocity = particle.A; + particle.StartSize = RandomScale(em.Desc.StartSize, em.Desc.ScaleRand); + particle.EndSize = RandomScale(em.Desc.EndSize, em.Desc.ScaleRand); + particle.StartAlpha = RandomTrans(em.Desc.StartAlpha, em.Desc.TransRand); + particle.EndAlpha = RandomTrans(em.Desc.EndAlpha, em.Desc.TransRand); + particle.Size = particle.StartSize; + particle.ColorArgb = Color32(particle.StartAlpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, 0f); + particle.Position = ComputePosition(em, particle); + + em.TotalEmitted++; + em.LastEmitTime = _time; + em.LastEmitOffset = em.AnchorPos; + return true; } - // ── 13 retail motion integrators (r04 §3) ──────────────────────────────── - - private void Integrate(ref Particle p, ParticleEmitter em, float dt) + private Vector3 ComputePosition(ParticleEmitter em, Particle p) { + float t = p.Age; + Vector3 origin = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0 + ? em.AnchorPos + : p.EmissionOrigin; + Vector3 offset = p.Offset; + Vector3 a = p.A; + Vector3 b = p.B; + Vector3 c = p.C; + + return em.Desc.Type switch + { + ParticleType.Still => origin + offset, + ParticleType.LocalVelocity or ParticleType.GlobalVelocity => + origin + offset + t * a, + ParticleType.ParabolicLVGA or ParticleType.ParabolicLVLA or ParticleType.ParabolicGVGA => + origin + offset + t * a + 0.5f * t * t * b, + ParticleType.ParabolicLVGAGR or ParticleType.ParabolicLVLALR or ParticleType.ParabolicGVGAGR => + origin + offset + t * a + 0.5f * t * t * b, + ParticleType.Swarm => + origin + offset + t * a + new Vector3( + MathF.Cos(t * b.X) * c.X, + MathF.Sin(t * b.Y) * c.Y, + MathF.Cos(t * b.Z) * c.Z), + ParticleType.Explode => + origin + offset + new Vector3( + (t * b.X + c.X * a.X) * t, + (t * b.Y + c.Y * a.X) * t, + (t * b.Z + c.Z * a.X + a.Z) * t), + ParticleType.Implode => + origin + offset + MathF.Cos(a.X * t) * c + t * t * b, + _ => origin + offset + t * a, + }; + } + + private void InitParticleVectors( + ParticleEmitter em, + ref Particle particle, + Vector3 localOffset, + Vector3 localA, + Vector3 localB, + Vector3 localC) + { + // Retail Particle::Init 0x0051c930 resolves local/global vector + // spaces once at spawn; Particle::Update 0x0051c290 then integrates + // those stored world-space coefficients each frame. + particle.Offset = ToSpawnWorld(em, localOffset); + particle.A = localA; + particle.B = localB; + particle.C = localC; + switch (em.Desc.Type) { - case ParticleType.Still: - // No motion. Age + fade only. - break; - case ParticleType.LocalVelocity: - // Constant spawn velocity, no acceleration. - p.Position += p.Velocity * dt; - break; - - case ParticleType.GlobalVelocity: - // Uses emitter's InitialVelocity (global/world-space); - // each particle keeps its own copy already (set at spawn), - // so behaves identically to LocalVelocity at runtime. - p.Position += p.Velocity * dt; - break; - - case ParticleType.Parabolic: - case ParticleType.ParabolicLVGV: case ParticleType.ParabolicLVGA: + particle.A = ToSpawnWorld(em, localA); + break; + case ParticleType.ParabolicLVLA: - case ParticleType.ParabolicGVGA: - case ParticleType.ParabolicGVLA: - case ParticleType.ParabolicLALV: - // Velocity decays with gravity; position integrates. - p.Velocity += em.Desc.Gravity * dt; - p.Position += p.Velocity * dt; + particle.A = ToSpawnWorld(em, localA); + particle.B = ToSpawnWorld(em, localB); + break; + + case ParticleType.ParabolicLVGAGR: + particle.A = ToSpawnWorld(em, localA); + particle.C = localC; break; case ParticleType.Swarm: - // Orbital drift around anchor. Apply a tangential swirl. - { - Vector3 toCenter = em.AnchorPos - p.Position; - Vector3 axis = em.Desc.OffsetDir == Vector3.Zero ? Vector3.UnitZ : em.Desc.OffsetDir; - Vector3 tangent = Vector3.Normalize(Vector3.Cross(axis, toCenter)); - p.Velocity = Vector3.Lerp(p.Velocity, tangent * em.Desc.InitialVelocity.Length(), dt * 4f); - p.Position += p.Velocity * dt; - } + particle.A = ToSpawnWorld(em, localA); break; case ParticleType.Explode: - // Push outward along (position - anchor). - { - Vector3 dir = p.Position - em.AnchorPos; - if (dir.LengthSquared() < 1e-6f) dir = Vector3.UnitZ; - else dir = Vector3.Normalize(dir); - p.Velocity = dir * em.Desc.InitialVelocity.Length(); - p.Position += p.Velocity * dt; - } + particle.A = localA; + particle.B = localB; + particle.C = RandomExplodeDirection(localC); break; case ParticleType.Implode: - // Pull inward toward anchor. - { - Vector3 dir = em.AnchorPos - p.Position; - float dist = dir.Length(); - if (dist < 0.01f) { p.Alive = false; break; } - dir /= dist; - p.Velocity = dir * em.Desc.InitialVelocity.Length(); - p.Position += p.Velocity * dt; - } + particle.A = localA; + particle.B = localB; + particle.Offset = new Vector3( + particle.Offset.X * localC.X, + particle.Offset.Y * localC.Y, + particle.Offset.Z * localC.Z); + particle.C = particle.Offset; break; - default: - p.Position += p.Velocity * dt; + case ParticleType.ParabolicLVLALR: + particle.A = ToSpawnWorld(em, localA); + particle.B = ToSpawnWorld(em, localB); + particle.C = ToSpawnWorld(em, localC); + break; + + case ParticleType.ParabolicGVGAGR: + particle.C = localC; break; } } - // ── Utility ────────────────────────────────────────────────────────────── + private static Vector3 ToSpawnWorld(ParticleEmitter em, Vector3 value) + => em.AnchorRot == Quaternion.Identity ? value : Vector3.Transform(value, em.AnchorRot); + + private Vector3 RandomExplodeDirection(Vector3 localC) + { + float yaw = RandomRange(-MathF.PI, MathF.PI); + float pitch = RandomRange(-MathF.PI, MathF.PI); + float cosPitch = MathF.Cos(pitch); + Vector3 c = new( + MathF.Cos(yaw) * localC.X * cosPitch, + MathF.Sin(yaw) * localC.Y * cosPitch, + MathF.Sin(pitch) * localC.Z); + + return NormalizeCheckSmall(ref c) ? Vector3.Zero : c; + } + + private int FindFreeSlot(ParticleEmitter em) + { + for (int i = 0; i < em.Particles.Length; i++) + { + if (!em.Particles[i].Alive) + return i; + } + + return -1; + } + + private static int FindOldestSlot(ParticleEmitter em) + { + int slot = -1; + float best = -1f; + for (int i = 0; i < em.Particles.Length; i++) + { + ref var p = ref em.Particles[i]; + float r = p.Lifetime > 0f ? p.Age / p.Lifetime : 1f; + if (r > best) + { + best = r; + slot = i; + } + } + + return slot; + } private static int CountAlive(ParticleEmitter em) { int n = 0; for (int i = 0; i < em.Particles.Length; i++) - if (em.Particles[i].Alive) n++; + { + if (em.Particles[i].Alive) + n++; + } + return n; } + private float RandomLifespan(EmitterDesc desc) + { + float lifespan = desc.Lifespan > 0f ? desc.Lifespan : (desc.LifetimeMin + desc.LifetimeMax) * 0.5f; + float rand = desc.LifespanRand > 0f ? desc.LifespanRand : MathF.Abs(desc.LifetimeMax - desc.LifetimeMin) * 0.5f; + float value = lifespan + RandomCentered(rand); + if (value <= 0f && desc.LifetimeMax > 0f) + value = Lerp(desc.LifetimeMin, desc.LifetimeMax, (float)_rng.NextDouble()); + return MathF.Max(0f, value); + } + + private Vector3 RandomOffset(EmitterDesc desc) + { + float min = MathF.Min(desc.MinOffset, desc.MaxOffset); + float max = MathF.Max(desc.MinOffset, desc.MaxOffset); + if (max <= 0f) + return Vector3.Zero; + + Vector3 axis = NormalizeOrZero(desc.OffsetDir); + Vector3 v = new( + RandomCentered(1f), + RandomCentered(1f), + RandomCentered(1f)); + + if (axis != Vector3.Zero) + v -= axis * Vector3.Dot(v, axis); + + if (v.LengthSquared() < 1e-8f) + v = axis != Vector3.Zero ? Perpendicular(axis) : Vector3.UnitX; + else + v = Vector3.Normalize(v); + + return v * Lerp(min, max, (float)_rng.NextDouble()); + } + + private Vector3 RandomVector(Vector3 direction, float min, float max) + { + if (direction == Vector3.Zero) + return Vector3.Zero; + + if (max < min) + (min, max) = (max, min); + + return direction * Lerp(min, max, (float)_rng.NextDouble()); + } + + private float RandomScale(float baseValue, float rand) + => Math.Clamp(baseValue + RandomCentered(rand), 0.1f, 10f); + + private float RandomTrans(float baseValue, float rand) + => Math.Clamp(baseValue + RandomCentered(rand), 0f, 1f); + + private float RandomCentered(float halfWidth) + => ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth; + + private float RandomRange(float min, float max) + => Lerp(min, max, (float)_rng.NextDouble()); + private static float Lerp(float a, float b, float t) => a + (b - a) * t; + private static Vector3 NormalizeOrZero(Vector3 v) + => v.LengthSquared() > 1e-8f ? Vector3.Normalize(v) : Vector3.Zero; + + private static bool NormalizeCheckSmall(ref Vector3 v) + { + float length = v.Length(); + if (length < 1e-8f) + return true; + + v /= length; + return false; + } + + private static Vector3 Perpendicular(Vector3 v) + { + Vector3 basis = MathF.Abs(v.X) < 0.9f ? Vector3.UnitX : Vector3.UnitY; + return Vector3.Normalize(Vector3.Cross(v, basis)); + } + private static uint Color32(float alpha, uint startArgb, uint endArgb, float t) { - // Blend RGB channels linearly; apply alpha override from fade. - byte sa = (byte)((startArgb >> 24) & 0xFF); byte sr = (byte)((startArgb >> 16) & 0xFF); - byte sg = (byte)((startArgb >> 8) & 0xFF); - byte sb = (byte)( startArgb & 0xFF); - byte ea = (byte)((endArgb >> 24) & 0xFF); + byte sg = (byte)((startArgb >> 8) & 0xFF); + byte sb = (byte)(startArgb & 0xFF); byte er = (byte)((endArgb >> 16) & 0xFF); - byte eg = (byte)((endArgb >> 8) & 0xFF); - byte eb = (byte)( endArgb & 0xFF); + byte eg = (byte)((endArgb >> 8) & 0xFF); + byte eb = (byte)(endArgb & 0xFF); + byte r = (byte)Math.Clamp(sr + (er - sr) * t, 0f, 255f); byte g = (byte)Math.Clamp(sg + (eg - sg) * t, 0f, 255f); byte b = (byte)Math.Clamp(sb + (eb - sb) * t, 0f, 255f); byte a = (byte)Math.Clamp(alpha * 255f, 0f, 255f); return ((uint)a << 24) | ((uint)r << 16) | ((uint)g << 8) | b; } - - private Vector3 RandomDiskVector(Vector3 axis, float maxRadius) - { - if (maxRadius <= 0f) return Vector3.Zero; - // Two perpendicular vectors to axis. - Vector3 n = Vector3.Normalize(axis == Vector3.Zero ? Vector3.UnitZ : axis); - Vector3 t1 = Math.Abs(n.X) < 0.9f - ? Vector3.Normalize(Vector3.Cross(n, Vector3.UnitX)) - : Vector3.Normalize(Vector3.Cross(n, Vector3.UnitY)); - Vector3 t2 = Vector3.Normalize(Vector3.Cross(n, t1)); - float theta = (float)(_rng.NextDouble() * Math.PI * 2.0); - float r = maxRadius * MathF.Sqrt((float)_rng.NextDouble()); - return (t1 * MathF.Cos(theta) + t2 * MathF.Sin(theta)) * r; - } - - private float RandomCentered(float halfWidth) - { - return ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth; - } } diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs index f50f740b..68161340 100644 --- a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs +++ b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs @@ -139,15 +139,7 @@ public sealed class PhysicsScriptRunner _active.RemoveAt(i); } - _active.Add(new ActiveScript - { - Script = script, - ScriptId = scriptId, - EntityId = entityId, - AnchorWorld = anchorWorldPos, - StartTimeAbs = _now, - NextHookIndex = 0, - }); + AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0); if (DiagEnabled) { @@ -159,6 +151,24 @@ public sealed class PhysicsScriptRunner return true; } + private void AddActiveScript( + DatPhysicsScript script, + uint scriptId, + uint entityId, + Vector3 anchorWorldPos, + float delaySeconds) + { + _active.Add(new ActiveScript + { + Script = script, + ScriptId = scriptId, + EntityId = entityId, + AnchorWorld = anchorWorldPos, + StartTimeAbs = _now + Math.Max(0f, delaySeconds), + NextHookIndex = 0, + }); + } + /// /// Advance every active script by . /// Fires each hook whose @@ -233,18 +243,18 @@ public sealed class PhysicsScriptRunner if (hook is CallPESHook call) { // CallPESHook.PES = sub-script id; Pause = delay before the - // sub-script starts (retail's ScriptManager links it into - // the list with StartTime = now + Pause). For our flat-list - // design we just recurse Play() — the sub-script schedules - // its own hooks from its own time zero. If Pause > 0 we - // delay by baking it into the sub-script's StartTimeAbs. - Play(call.PES, a.EntityId, a.AnchorWorld); - if (call.Pause > 0f && _active.Count > 0) + // sub-script starts. Retail links it into the active script + // list with StartTime = now + Pause; our flat list preserves + // that timing without replacing the currently running script. + var subScript = ResolveScript(call.PES); + if (subScript is null || subScript.ScriptData.Count == 0) { - var sub = _active[^1]; - sub.StartTimeAbs = _now + call.Pause; - _active[^1] = sub; + if (DiagEnabled) + Console.WriteLine($"[pes] CallPES: script 0x{call.PES:X8} not found / empty"); + return; } + + AddActiveScript(subScript, call.PES, a.EntityId, a.AnchorWorld, call.Pause); return; } diff --git a/src/AcDream.Core/Vfx/VfxModel.cs b/src/AcDream.Core/Vfx/VfxModel.cs index 77527adc..56974314 100644 --- a/src/AcDream.Core/Vfx/VfxModel.cs +++ b/src/AcDream.Core/Vfx/VfxModel.cs @@ -4,90 +4,123 @@ using System.Numerics; namespace AcDream.Core.Vfx; -// ───────────────────────────────────────────────────────────────────── -// Scaffold for R4 — VFX / particle system data model. -// Full research: docs/research/deepdives/r04-vfx-particles.md -// Runtime GPU batching lives in AcDream.App/Rendering/Vfx (Silk.NET GL). -// ───────────────────────────────────────────────────────────────────── - /// -/// 13 retail particle motion integrators. See r04 §1. -/// Parabolic variants apply gravity with different orientation/decay rules. +/// Retail particle motion integrators from ParticleType in +/// acclient.h. Values are the retail dat values. /// public enum ParticleType { - Still = 0, // static, fades out in place - LocalVelocity = 1, // moves at its spawn velocity - Parabolic = 2, // gravity arc - ParabolicLVGV = 3, // local+global velocity parabolic - ParabolicLVGA = 4, - ParabolicLVLA = 5, - ParabolicGVGA = 6, - ParabolicGVLA = 7, - ParabolicLALV = 8, - Swarm = 9, // orbits spawn point with randomness - Explode = 10, // all particles push outward - Implode = 11, // all particles pull inward - GlobalVelocity = 12, + Unknown = 0, + Still = 1, + LocalVelocity = 2, + ParabolicLVGA = 3, + ParabolicLVGAGR = 4, + Swarm = 5, + Explode = 6, + Implode = 7, + ParabolicLVLA = 8, + ParabolicLVLALR = 9, + ParabolicGVGA = 10, + ParabolicGVGAGR = 11, + GlobalVelocity = 12, + NumParticleType = 13, +} + +/// +/// Retail EmitterType from acclient.h. +/// +public enum ParticleEmitterKind +{ + Unknown = 0, + BirthratePerSec = 1, + BirthratePerMeter = 2, +} + +/// +/// Render stage for an active particle emitter. +/// +public enum ParticleRenderPass +{ + Scene = 0, + SkyPreScene = 1, + SkyPostScene = 2, } [Flags] public enum EmitterFlags : uint { - None = 0, - Additive = 0x01, // blend mode: SrcAlpha / One (vs default SrcAlpha / InvSrcAlpha) - Billboard = 0x02, + None = 0, + Additive = 0x01, + Billboard = 0x02, FaceCamera = 0x04, - AttachLocal= 0x08, // particles follow parent anchor frame + AttachLocal = 0x08, } /// -/// Per-emitter configuration from the ParticleEmitterInfo dat. -/// See r04 §1 + DatReaderWriter.ParticleEmitterInfo. +/// Per-emitter configuration from the retail ParticleEmitterInfo +/// dat object. /// public sealed class EmitterDesc { - public uint DatId { get; init; } - public ParticleType Type { get; init; } - public EmitterFlags Flags { get; init; } - public uint TextureSurfaceId { get; init; } // 0x06xxxxxx - public uint SoundOnSpawn { get; init; } + public uint DatId { get; init; } + public ParticleType Type { get; init; } + public ParticleEmitterKind EmitterKind { get; init; } = ParticleEmitterKind.BirthratePerSec; + public EmitterFlags Flags { get; init; } + public uint TextureSurfaceId { get; init; } + public uint GfxObjId { get; init; } + public uint HwGfxObjId { get; init; } + public uint SoundOnSpawn { get; init; } - // Emission behavior - public float EmitRate { get; init; } // particles / sec - public int MaxParticles { get; init; } - public float LifetimeMin { get; init; } - public float LifetimeMax { get; init; } - public float StartDelay { get; init; } - public float TotalDuration { get; init; } // 0 = infinite + // Emission behavior. + public float Birthrate { get; init; } + public float EmitRate { get; init; } + public int MaxParticles { get; init; } + public int InitialParticles { get; init; } + public int TotalParticles { get; init; } + public float LifetimeMin { get; init; } + public float LifetimeMax { get; init; } + public float Lifespan { get; init; } + public float LifespanRand { get; init; } + public float StartDelay { get; init; } + public float TotalDuration { get; init; } - // Spawn geometry (disk annulus perpendicular to OffsetDir) - public Vector3 OffsetDir { get; init; } = new(0, 0, 1); - public float MinOffset { get; init; } - public float MaxOffset { get; init; } - public float SpawnDiskRadius { get; init; } + // Spawn geometry. + public Vector3 OffsetDir { get; init; } = new(0, 0, 1); + public float MinOffset { get; init; } + public float MaxOffset { get; init; } + public float SpawnDiskRadius { get; init; } - // Initial kinematics - public Vector3 InitialVelocity { get; init; } - public float VelocityJitter { get; init; } - public Vector3 Gravity { get; init; } = new(0, 0, -9.8f); + // Kinematics. A/B/C are the retail vector coefficients. + public Vector3 InitialVelocity { get; init; } + public float VelocityJitter { get; init; } + public Vector3 Gravity { get; init; } = new(0, 0, -9.8f); + public Vector3 A { get; init; } + public float MinA { get; init; } = 1f; + public float MaxA { get; init; } = 1f; + public Vector3 B { get; init; } + public float MinB { get; init; } = 1f; + public float MaxB { get; init; } = 1f; + public Vector3 C { get; init; } + public float MinC { get; init; } = 1f; + public float MaxC { get; init; } = 1f; - // Appearance over lifetime (retail: start + end, linearly interpolated) - public uint StartColorArgb { get; init; } = 0xFFFFFFFF; - public uint EndColorArgb { get; init; } = 0xFFFFFFFF; - public float StartAlpha { get; init; } = 1f; - public float EndAlpha { get; init; } = 0f; - public float StartSize { get; init; } = 0.5f; - public float EndSize { get; init; } = 0.5f; - public float StartRotation { get; init; } - public float EndRotation { get; init; } + // Appearance over lifetime. + public uint StartColorArgb { get; init; } = 0xFFFFFFFF; + public uint EndColorArgb { get; init; } = 0xFFFFFFFF; + public float StartAlpha { get; init; } = 1f; + public float EndAlpha { get; init; } = 0f; + public float StartSize { get; init; } = 0.5f; + public float EndSize { get; init; } = 0.5f; + public float ScaleRand { get; init; } + public float TransRand { get; init; } + public float StartRotation { get; init; } + public float EndRotation { get; init; } } /// /// 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. /// public sealed class PhysicsScript { @@ -98,34 +131,43 @@ public sealed class PhysicsScript public sealed record PhysicsScriptHook( float StartTime, PhysicsScriptHookType Type, - uint RefDataId, // EmitterInfo / Sound / PartTransform - int PartIndex, // attach to this part + uint RefDataId, + int PartIndex, Vector3 Offset, bool IsParentLocal); public enum PhysicsScriptHookType { - CreateParticle = 18, // matches retail animation-hook type - DestroyParticle= 19, - PlaySound = 1, - AnimationDone = 2, + CreateParticle = 18, + DestroyParticle = 19, + PlaySound = 1, + AnimationDone = 2, } /// -/// Individual runtime particle. Owned by the ParticleSystem; -/// advanced per-frame. +/// Individual runtime particle. Owned by the ParticleSystem. /// public struct Particle { - public Vector3 Position; - public Vector3 Velocity; - public float SpawnedAt; - public float Lifetime; // seconds - public float Age; - public uint ColorArgb; // current - public float Size; - public float Rotation; - public bool Alive; + public Vector3 EmissionOrigin; + public Quaternion SpawnRotation; + public Vector3 Position; + public Vector3 Velocity; + public Vector3 Offset; + public Vector3 A; + public Vector3 B; + public Vector3 C; + public float SpawnedAt; + public float Lifetime; + public float Age; + public float StartSize; + public float EndSize; + public float StartAlpha; + public float EndAlpha; + public uint ColorArgb; + public float Size; + public float Rotation; + public bool Alive; } /// @@ -134,16 +176,20 @@ public struct Particle /// public sealed class ParticleEmitter { - public EmitterDesc Desc { get; init; } = null!; - public Vector3 AnchorPos { get; set; } - public Quaternion AnchorRot { get; set; } = Quaternion.Identity; - public uint AttachedObjectId { get; set; } // 0 = world-space only - public int AttachedPartIndex { get; set; } = -1; - public Particle[] Particles { get; init; } = null!; - public int ActiveCount; - public float EmittedAccumulator; // fractional particles pending - public float StartedAt; // game-time seconds - public bool Finished; + public EmitterDesc Desc { get; init; } = null!; + public Vector3 AnchorPos { get; set; } + public Quaternion AnchorRot { get; set; } = Quaternion.Identity; + public uint AttachedObjectId { get; set; } + public int AttachedPartIndex { get; set; } = -1; + public Particle[] Particles { get; init; } = null!; + public ParticleRenderPass RenderPass { get; init; } + public int ActiveCount; + public float EmittedAccumulator; + public float StartedAt; + public float LastEmitTime; + public Vector3 LastEmitOffset; + public int TotalEmitted; + public bool Finished; } /// @@ -151,20 +197,25 @@ public sealed class ParticleEmitter /// public interface IParticleSystem { - /// Spawn an emitter attached to a world position (or entity). - int SpawnEmitter(EmitterDesc desc, Vector3 anchor, Quaternion? rot = null, - uint attachedObjectId = 0, int attachedPartIndex = -1); + /// Spawn an emitter attached to a world position or entity. + int SpawnEmitter( + EmitterDesc desc, + Vector3 anchor, + Quaternion? rot = null, + uint attachedObjectId = 0, + int attachedPartIndex = -1, + ParticleRenderPass renderPass = ParticleRenderPass.Scene); - /// Fire a full PhysicsScript at a target (the retail PlayScript dispatch). + /// Fire a full PhysicsScript at a target. void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f); /// Advance all active emitters by dt seconds. void Tick(float dt); - /// Stop an emitter early (e.g. cast interrupted). + /// Stop an emitter early. void StopEmitter(int handle, bool fadeOut); - /// Current active particle count (for HUD stats). + /// Current active particle count. int ActiveParticleCount { get; } - int ActiveEmitterCount { get; } + int ActiveEmitterCount { get; } } diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index ada27534..b8204ba5 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -34,6 +34,7 @@ public sealed class SkyObjectData public float TexVelocityX; public float TexVelocityY; public uint GfxObjId; + public uint PesObjectId; public uint Properties; /// @@ -531,6 +532,7 @@ public static class SkyDescLoader TexVelocityX = s.TexVelocityX, TexVelocityY = s.TexVelocityY, GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u, + PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u, Properties = s.Properties, }; diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index d32936c9..f740efb9 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -241,28 +241,32 @@ public sealed class GameEventWiringTests } [Fact] - public void WireAll_KillerNotification_FiresKillLandedOnCombatState() + public void WireAll_KillerNotification_AppendsCombatLine() { - // Issue #10 — orphan parser at GameEvents.ParseKillerNotification - // existed but was never registered for dispatch until 2026-04-25. - // Now wired: 0x01AD lands on CombatState.OnKillerNotification + - // fires the KillLanded event. - var (d, _, combat, _, _) = MakeAll(); - string? gotVictimName = null; - uint gotVictimGuid = 0; - combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; }; - - // Wire shape: string16L victimName + u32 victimGuid - byte[] nameBytes = MakeString16L("Drudge"); - byte[] payload = new byte[nameBytes.Length + 4]; - Array.Copy(nameBytes, payload, nameBytes.Length); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u); + var (d, _, _, _, chat) = MakeAll(); + byte[] payload = MakeString16L("You killed the drudge!"); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload)); d.Dispatch(env!.Value); - Assert.Equal("Drudge", gotVictimName); - Assert.Equal(0x80001234u, gotVictimGuid); + Assert.Equal(1, chat.Count); + var entry = chat.Snapshot()[0]; + Assert.Equal(ChatKind.Combat, entry.Kind); + Assert.Equal(CombatLineKind.Info, entry.CombatKind); + Assert.Equal("You killed the drudge!", entry.Text); + } + + [Fact] + public void WireAll_CombatCommenceAttack_FiresCombatStateEvent() + { + var (d, _, combat, _, _) = MakeAll(); + bool commenced = false; + combat.AttackCommenced += () => commenced = true; + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty())); + d.Dispatch(env!.Value); + + Assert.True(commenced); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs index 979aeaa9..94194619 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs @@ -57,4 +57,13 @@ public sealed class CharacterActionsTests Assert.Equal(2u, // Melee = 2 BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } + + [Fact] + public void CombatMode_UsesRetailAceBitValues() + { + Assert.Equal(1u, (uint)CharacterActions.CombatMode.NonCombat); + Assert.Equal(2u, (uint)CharacterActions.CombatMode.Melee); + Assert.Equal(4u, (uint)CharacterActions.CombatMode.Missile); + Assert.Equal(8u, (uint)CharacterActions.CombatMode.Magic); + } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs index 2352cacf..b10b308b 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs @@ -1,6 +1,5 @@ using System; using System.Buffers.Binary; -using System.Text; using AcDream.Core.Net.Messages; using Xunit; @@ -8,105 +7,140 @@ namespace AcDream.Core.Net.Tests.Messages; public sealed class CombatEventTests { - private static byte[] MakeString16L(string s) - { - byte[] data = Encoding.ASCII.GetBytes(s); - int recordSize = 2 + data.Length; - int padding = (4 - (recordSize & 3)) & 3; - byte[] result = new byte[recordSize + padding]; - BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); - Array.Copy(data, 0, result, 2, data.Length); - return result; - } - [Fact] - public void AttackTargetRequest_Build_EmitsCorrectWireBytes() + public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes() { - byte[] body = AttackTargetRequest.Build( + byte[] body = AttackTargetRequest.BuildMelee( gameActionSequence: 3, targetGuid: 0x12345678u, - powerLevel: 0.75f, - accuracyLevel: 0.5f, - attackHeight: 2); + attackHeight: 2, + powerLevel: 0.75f); - Assert.Equal(28, body.Length); + Assert.Equal(24, body.Length); Assert.Equal(AttackTargetRequest.GameActionEnvelope, BinaryPrimitives.ReadUInt32LittleEndian(body)); Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); - Assert.Equal(AttackTargetRequest.SubOpcode, + Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(0x12345678u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(2u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); Assert.Equal(0.75f, - BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4); + BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4); + } + + [Fact] + public void AttackTargetRequest_BuildMissile_EmitsRetailWireBytes() + { + byte[] body = AttackTargetRequest.BuildMissile( + gameActionSequence: 4, + targetGuid: 0x87654321u, + attackHeight: 1, + accuracyLevel: 0.5f); + + Assert.Equal(24, body.Length); + Assert.Equal(AttackTargetRequest.TargetedMissileAttackOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x87654321u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); Assert.Equal(0.5f, BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4); - Assert.Equal(2u, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24))); } [Fact] - public void ParseVictimNotification_RoundTrip() + public void AttackTargetRequest_BuildCancel_HasNoPayload() { - byte[] name = MakeString16L("Attacker"); - byte[] tail = new byte[24]; - BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu); // guid - BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 1u); // damageType - BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 42u); // damage - BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(12), 3u); // quadrant - BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(16), 1u); // crit - BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(20), 8u); // attackType + byte[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5); - byte[] payload = new byte[name.Length + tail.Length]; - Buffer.BlockCopy(name, 0, payload, 0, name.Length); - Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length); + Assert.Equal(12, body.Length); + Assert.Equal(AttackTargetRequest.CancelAttackOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + [Fact] + public void ParseAttackDone_HoltburgerFixture() + { + var env = ParseFixture("B0F700000000000000000000A701000036000000"); + + Assert.Equal(GameEventType.AttackDone, env.EventType); + var parsed = GameEvents.ParseAttackDone(env.Payload.Span); - var parsed = GameEvents.ParseVictimNotification(payload); Assert.NotNull(parsed); - Assert.Equal("Attacker", parsed!.Value.AttackerName); - Assert.Equal(0xAAu, parsed.Value.AttackerGuid); - Assert.Equal(42u, parsed.Value.Damage); + Assert.Equal(0u, parsed!.Value.AttackSequence); + Assert.Equal(0x36u, parsed.Value.WeenieError); + } + + [Fact] + public void ParseAttackerNotification_HoltburgerFixture() + { + var env = ParseFixture("B0F700000000000001000000B10100000E0044727564676520526176656E657201000000000000000000D03F25000000010000000600000000000000"); + + var parsed = GameEvents.ParseAttackerNotification(env.Payload.Span); + + Assert.NotNull(parsed); + Assert.Equal("Drudge Ravener", parsed!.Value.DefenderName); + Assert.Equal(1u, parsed.Value.DamageType); + Assert.Equal(0.25, parsed.Value.HealthPercent, 6); + Assert.Equal(37u, parsed.Value.Damage); Assert.Equal(1u, parsed.Value.Critical); + Assert.Equal(6ul, parsed.Value.AttackConditions); } [Fact] - public void ParseAttackerNotification_RoundTrip() + public void ParseDefenderNotification_HoltburgerFixture() { - byte[] name = MakeString16L("Drudge"); - byte[] tail = new byte[12]; - BinaryPrimitives.WriteUInt32LittleEndian(tail, 1u); // damageType - BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 30u); // damage - BinaryPrimitives.WriteSingleLittleEndian(tail.AsSpan(8), 0.15f); // percent + var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000"); - byte[] payload = new byte[name.Length + tail.Length]; - Buffer.BlockCopy(name, 0, payload, 0, name.Length); - Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length); + var parsed = GameEvents.ParseDefenderNotification(env.Payload.Span); - var parsed = GameEvents.ParseAttackerNotification(payload); Assert.NotNull(parsed); - Assert.Equal("Drudge", parsed!.Value.DefenderName); - Assert.Equal(30u, parsed.Value.Damage); - Assert.Equal(0.15f, parsed.Value.DamagePercent, 4); + Assert.Equal("Banderling", parsed!.Value.AttackerName); + Assert.Equal(0x10u, parsed.Value.DamageType); + Assert.Equal(0.125, parsed.Value.HealthPercent, 6); + Assert.Equal(18u, parsed.Value.Damage); + Assert.Equal(1u, parsed.Value.HitQuadrant); + Assert.Equal(0u, parsed.Value.Critical); + Assert.Equal(8ul, parsed.Value.AttackConditions); } [Fact] - public void ParseEvasionAttackerNotification_RoundTrip() + public void ParseEvasionNotifications_HoltburgerFixtures() { - byte[] payload = MakeString16L("Thrower"); - Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload)); + var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000"); + var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000"); + + Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span)); + Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span)); } [Fact] - public void ParseAttackDone_RoundTrip() + public void ParseCombatCommenceAttack_HoltburgerFixture() { - byte[] payload = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error + var env = ParseFixture("B0F700000000000005000000B8010000"); - var parsed = GameEvents.ParseAttackDone(payload); - Assert.NotNull(parsed); - Assert.Equal(42u, parsed!.Value.AttackSequence); - Assert.Equal(0u, parsed.Value.WeenieError); + Assert.Equal(GameEventType.CombatCommenceAttack, env.EventType); + Assert.True(GameEvents.ParseCombatCommenceAttack(env.Payload.Span)); + } + + [Fact] + public void ParseDeathNotifications_HoltburgerFixtures() + { + var victim = ParseFixture("B0F700000000000006000000AC0100000E00596F752068617665206469656421"); + var killer = ParseFixture("B0F700000000000007000000AD0100001600596F75206B696C6C6564207468652064727564676521"); + + Assert.Equal("You have died!", GameEvents.ParseVictimNotification(victim.Payload.Span)?.DeathMessage); + Assert.Equal("You killed the drudge!", GameEvents.ParseKillerNotification(killer.Payload.Span)?.DeathMessage); + } + + private static GameEventEnvelope ParseFixture(string hex) + { + byte[] body = Convert.FromHexString(hex); + var env = GameEventEnvelope.TryParse(body); + Assert.NotNull(env); + return env.Value; } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs new file mode 100644 index 00000000..a7dea334 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -0,0 +1,99 @@ +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CreateObjectTests +{ + [Fact] + public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType() + { + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000002u, + name: "Drudge", + itemType: (uint)ItemType.Creature); + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal(0x50000002u, parsed.Value.Guid); + Assert.Equal("Drudge", parsed.Value.Name); + Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType); + } + + private static byte[] BuildMinimalCreateObjectWithWeenieHeader( + uint guid, + string name, + uint itemType) + { + var bytes = new List(); + 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 bytes, uint value) + { + Span tmp = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(tmp, value); + bytes.AddRange(tmp.ToArray()); + } + + private static void WriteU16(List bytes, ushort value) + { + Span tmp = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(tmp, value); + bytes.AddRange(tmp.ToArray()); + } + + private static void WritePackedDword(List 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 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 bytes) + { + while ((bytes.Count & 3) != 0) + bytes.Add(0); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs new file mode 100644 index 00000000..b464cab1 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs @@ -0,0 +1,39 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class DeleteObjectTests +{ + [Fact] + public void RejectsWrongOpcode() + { + Span body = stackalloc byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu); + + Assert.Null(DeleteObject.TryParse(body)); + } + + [Fact] + public void RejectsTruncated() + { + Assert.Null(DeleteObject.TryParse(ReadOnlySpan.Empty)); + Assert.Null(DeleteObject.TryParse(new byte[9])); + } + + [Fact] + public void ParsesGuidAndInstanceSequence() + { + Span 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); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 08de618a..09f9eb96 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -185,7 +185,8 @@ public class UpdateMotionTests [Fact] public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance() { - // movementType != 0 means one of the Move* variants we don't parse. + // movementType != 0 means one of the Move* variants; a truncated + // non-Invalid payload still returns the outer state. // The parser must still return a valid Parsed with the outer stance // and a null ForwardCommand rather than failing the whole message. var body = new byte[4 + 4 + 2 + 6 + 4]; @@ -194,7 +195,7 @@ public class UpdateMotionTests BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; p += 6; - body[p++] = 1; // movementType = MoveToObject (non-Invalid) + body[p++] = 7; // movementType = MoveToPosition (non-Invalid) body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2; @@ -202,5 +203,152 @@ public class UpdateMotionTests Assert.NotNull(result); Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance); Assert.Null(result.Value.MotionState.ForwardCommand); + Assert.Equal((byte)7, result.Value.MotionState.MovementType); + Assert.True(result.Value.MotionState.IsServerControlledMoveTo); + } + + [Fact] + public void ParsesMoveToPositionSpeedAndRunRate() + { + // Layout after MovementData's movementType/motionFlags/currentStyle: + // Origin: cell + xyz (16 bytes) + // MoveToParameters: flags, distance, min, fail, speed, + // walk/run threshold, desired heading (28 bytes) + // runRate: f32 + var body = new byte[4 + 4 + 2 + 6 + 4 + 16 + 28 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; + body[p++] = 7; // MoveToPosition + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; + + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 10f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 20f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 30f); p += 4; + + const uint canWalkCanRunMoveTowards = 0x1u | 0x2u | 0x200u; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), canWalkCanRunMoveTowards); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 90.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; + + var result = UpdateMotion.TryParse(body); + + Assert.NotNull(result); + Assert.Equal((byte)7, result!.Value.MotionState.MovementType); + Assert.True(result.Value.MotionState.IsServerControlledMoveTo); + Assert.Equal((ushort)0x003D, result.Value.MotionState.Stance); + Assert.Null(result.Value.MotionState.ForwardCommand); + Assert.Equal(canWalkCanRunMoveTowards, result.Value.MotionState.MoveToParameters); + Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed); + Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate); + Assert.True(result.Value.MotionState.MoveToCanRun); + Assert.True(result.Value.MotionState.MoveTowards); + + // Phase L.1c (2026-04-28): full path payload retained. + Assert.NotNull(result.Value.MotionState.MoveToPath); + var path = result.Value.MotionState.MoveToPath!.Value; + Assert.Null(path.TargetGuid); + Assert.Equal(0xA8B4000Eu, path.OriginCellId); + Assert.Equal(10f, path.OriginX); + Assert.Equal(20f, path.OriginY); + Assert.Equal(30f, path.OriginZ); + Assert.Equal(0.6f, path.DistanceToObject); + Assert.Equal(0.0f, path.MinDistance); + Assert.Equal(float.MaxValue, path.FailDistance); + Assert.Equal(15.0f, path.WalkRunThreshold); + Assert.Equal(90.0f, path.DesiredHeading); + } + + [Fact] + public void ParsesAttackHigh1_AsActionForwardCommand() + { + // Phase L.1c followup (2026-04-28): regression that verifies the + // wire-format ACE uses for melee swings — mt=0 with + // ForwardCommand=AttackHigh1 (0x0062 in low 16 bits) and + // ForwardSpeed (typically the animSpeed). The receiver in + // GameWindow.OnLiveMotionUpdated relies on this layout to bulk-copy + // ForwardCommand into the body's InterpretedState so that + // get_state_velocity returns 0 (gate is RunForward||WalkForward). + var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x800003B5u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; // header padding + + body[p++] = 0; // mt = Invalid (interpreted) + body[p++] = 0; // motion_flags + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003C); p += 2; // stance: HandCombat + + // InterpretedMotionState: flags = ForwardCommand (0x02) | ForwardSpeed (0x04) + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x06u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0062); p += 2; // AttackHigh1 low bits + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // animSpeed + + var result = UpdateMotion.TryParse(body); + + Assert.NotNull(result); + Assert.Equal((byte)0, result!.Value.MotionState.MovementType); + Assert.False(result.Value.MotionState.IsServerControlledMoveTo); + Assert.Equal((ushort)0x0062, result.Value.MotionState.ForwardCommand); + Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed); + } + + [Fact] + public void ParsesMoveToObjectTargetGuidAndOrigin() + { + // Type 6 (MoveToObject) prepends a u32 target guid before the + // standard Origin + MovementParameters + runRate payload. + // Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72. + var body = new byte[20 + 4 + 16 + 28 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; // MovementData header padding + + body[p++] = 6; // MoveToObject + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; + + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid + + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z + + const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate + + var result = UpdateMotion.TryParse(body); + + Assert.NotNull(result); + Assert.Equal((byte)6, result!.Value.MotionState.MovementType); + Assert.True(result.Value.MotionState.IsServerControlledMoveTo); + Assert.NotNull(result.Value.MotionState.MoveToPath); + var path = result.Value.MotionState.MoveToPath!.Value; + Assert.Equal(0x80001234u, path.TargetGuid); + Assert.Equal(0xA8B4000Eu, path.OriginCellId); + Assert.Equal(5f, path.OriginX); + Assert.Equal(6f, path.OriginY); + Assert.Equal(7f, path.OriginZ); + Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate); } } diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs new file mode 100644 index 00000000..0bdd0bec --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using AcDream.Core.Combat; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionCombatTests +{ + private static WorldSession NewSession() + { + var ep = new IPEndPoint(IPAddress.Loopback, 65000); + return new WorldSession(ep); + } + + [Fact] + public void SendChangeCombatMode_UsesSequenceAndRetailModeValue() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendChangeCombatMode(CombatMode.Magic); + + Assert.NotNull(captured); + Assert.Equal(CharacterActions.BuildChangeCombatMode( + 1, + CharacterActions.CombatMode.Magic), captured); + } + + [Fact] + public void SendMeleeAttack_UsesRetailMeleeBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendMeleeAttack(0x50000002u, AttackHeight.High, 0.75f); + + Assert.NotNull(captured); + Assert.Equal(AttackTargetRequest.BuildMelee( + 1, + 0x50000002u, + (uint)AttackHeight.High, + 0.75f), captured); + } + + [Fact] + public void SendMissileAttack_UsesRetailMissileBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendMissileAttack(0x50000003u, AttackHeight.Low, 0.5f); + + Assert.NotNull(captured); + Assert.Equal(AttackTargetRequest.BuildMissile( + 1, + 0x50000003u, + (uint)AttackHeight.Low, + 0.5f), captured); + } + + [Fact] + public void SendCancelAttack_UsesRetailCancelBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendCancelAttack(); + + Assert.NotNull(captured); + Assert.Equal(AttackTargetRequest.BuildCancel(1), captured); + } +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs new file mode 100644 index 00000000..7a4a9a1f --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs @@ -0,0 +1,89 @@ +using AcDream.Core.Combat; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Combat; + +public sealed class CombatAnimationPlannerTests +{ + [Theory] + [InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed + [InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh + [InlineData(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed + [InlineData(0x1000018Eu, CombatAnimationKind.MeleeSwing)] // PunchFastLow + [InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot + [InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload + [InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1 + [InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow6 + [InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell + [InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff + [InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1 + [InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward + [InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead + [InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat + [InlineData(0x80000043u, CombatAnimationKind.CombatStance)] // SlingCombat + [InlineData(0x80000044u, CombatAnimationKind.CombatStance)] // 2HandedSwordCombat + public void ClassifyMotionCommand_RecognisesRetailCombatCommands( + uint command, + CombatAnimationKind expected) + { + Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command)); + } + + [Theory] + [InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh + [InlineData(0x017D, 0x1000017Du)] // OffhandDoubleThrustMed + [InlineData(0x018B, 0x1000018Bu)] // AttackLow6 + [InlineData(0x018E, 0x1000018Eu)] // PunchFastLow + public void MotionCommandResolver_UsesNamedRetailLateCombatCommands( + ushort wireCommand, + uint expectedFullCommand) + { + Assert.Equal(expectedFullCommand, MotionCommandResolver.ReconstructFullCommand(wireCommand)); + } + + [Fact] + public void PlanFromWireCommand_Swing_IsActionOverlay() + { + var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0058, speedMod: 1.25f); + + Assert.Equal(CombatAnimationKind.MeleeSwing, plan.Kind); + Assert.Equal(AnimationCommandRouteKind.Action, plan.RouteKind); + Assert.Equal(0x10000058u, plan.MotionCommand); + Assert.Equal(1.25f, plan.SpeedMod); + Assert.True(plan.HasMotion); + } + + [Fact] + public void PlanFromWireCommand_Dead_IsPersistentSubState() + { + var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0011); + + Assert.Equal(CombatAnimationKind.Death, plan.Kind); + Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind); + Assert.Equal(MotionCommand.Dead, plan.MotionCommand); + } + + [Fact] + public void PlanFromWireCommand_Unknown_IsNone() + { + var plan = CombatAnimationPlanner.PlanFromWireCommand(0xFFFF); + + Assert.Equal(CombatAnimationPlan.None, plan); + Assert.False(plan.HasMotion); + } + + [Theory] + [InlineData(CombatAnimationEvent.CombatCommenceAttack)] + [InlineData(CombatAnimationEvent.AttackDone)] + [InlineData(CombatAnimationEvent.AttackerNotification)] + [InlineData(CombatAnimationEvent.DefenderNotification)] + [InlineData(CombatAnimationEvent.EvasionAttackerNotification)] + [InlineData(CombatAnimationEvent.EvasionDefenderNotification)] + [InlineData(CombatAnimationEvent.VictimNotification)] + [InlineData(CombatAnimationEvent.KillerNotification)] + public void PlanForEvent_DoesNotInventAnimations(CombatAnimationEvent combatEvent) + { + Assert.Equal(CombatAnimationPlan.None, CombatAnimationPlanner.PlanForEvent(combatEvent)); + } +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs new file mode 100644 index 00000000..c970d45d --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs @@ -0,0 +1,43 @@ +using AcDream.Core.Combat; + +namespace AcDream.Core.Tests.Combat; + +public sealed class CombatInputPlannerTests +{ + [Fact] + public void ToggleMode_FromNonCombat_UsesDefaultCombatMode() + { + Assert.Equal(CombatMode.Melee, CombatInputPlanner.ToggleMode(CombatMode.NonCombat)); + Assert.Equal( + CombatMode.Missile, + CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile)); + } + + [Fact] + public void ToggleMode_FromCombat_ReturnsNonCombat() + { + Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee)); + Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic)); + } + + [Theory] + [InlineData(CombatAttackAction.Low, AttackHeight.Low)] + [InlineData(CombatAttackAction.Medium, AttackHeight.Medium)] + [InlineData(CombatAttackAction.High, AttackHeight.High)] + public void HeightFor_MapsRetailAttackKeys(CombatAttackAction action, AttackHeight expected) + { + Assert.Equal(expected, CombatInputPlanner.HeightFor(action)); + } + + [Theory] + [InlineData(CombatMode.Melee, true)] + [InlineData(CombatMode.Missile, true)] + [InlineData(CombatMode.NonCombat, false)] + [InlineData(CombatMode.Magic, false)] + public void SupportsTargetedAttack_MatchesRetailExecuteAttackModes( + CombatMode mode, + bool expected) + { + Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode)); + } +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs b/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs new file mode 100644 index 00000000..72e32a7a --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs @@ -0,0 +1,155 @@ +using AcDream.Core.Combat; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using DatAttackHeight = DatReaderWriter.Enums.AttackHeight; +using DatAttackType = DatReaderWriter.Enums.AttackType; +using DatMotionCommand = DatReaderWriter.Enums.MotionCommand; +using DatMotionStance = DatReaderWriter.Enums.MotionStance; +using Xunit; + +namespace AcDream.Core.Tests.Combat; + +public sealed class CombatManeuverSelectorTests +{ + [Fact] + public void SelectMotion_UsesFirstEntryAtOrAboveSubdivision() + { + var table = MakeTable( + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.SlashMed), + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.BackhandMed)); + + var atThreshold = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.Medium, + DatAttackType.Slash, + powerLevel: CombatManeuverSelector.DefaultSubdivision); + + var highPower = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.Medium, + DatAttackType.Slash, + powerLevel: 1f); + + Assert.Equal(DatMotionCommand.SlashMed, atThreshold.Motion); + Assert.Equal(DatMotionCommand.SlashMed, highPower.Motion); + } + + [Fact] + public void SelectMotion_UsesSecondEntryBelowSubdivision() + { + var table = MakeTable( + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.SlashMed), + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.BackhandMed)); + + var selection = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.Medium, + DatAttackType.Slash, + powerLevel: 0.2f); + + Assert.True(selection.Found); + Assert.Equal(DatMotionCommand.BackhandMed, selection.Motion); + Assert.Equal(DatAttackType.Slash, selection.EffectiveAttackType); + Assert.Equal(2, selection.Candidates.Count); + } + + [Fact] + public void SelectMotion_ThrustSlashWeaponUsesTwoThirdsSubdivision() + { + var table = MakeTable( + Entry(DatMotionStance.SwordCombat, DatAttackHeight.High, + DatAttackType.Slash, DatMotionCommand.SlashHigh), + Entry(DatMotionStance.SwordCombat, DatAttackHeight.High, + DatAttackType.Slash, DatMotionCommand.BackhandHigh)); + + var normal = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.High, + DatAttackType.Slash, + powerLevel: 0.5f); + + var thrustSlash = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.High, + DatAttackType.Slash, + powerLevel: 0.5f, + isThrustSlashWeapon: true); + + Assert.Equal(DatMotionCommand.SlashHigh, normal.Motion); + Assert.Equal(DatMotionCommand.BackhandHigh, thrustSlash.Motion); + Assert.Equal(CombatManeuverSelector.ThrustSlashSubdivision, thrustSlash.Subdivision); + } + + [Fact] + public void SelectMotion_MissingLookupReturnsNone() + { + var table = MakeTable( + Entry(DatMotionStance.BowCombat, DatAttackHeight.High, + DatAttackType.Punch, DatMotionCommand.Shoot)); + + var selection = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.High, + DatAttackType.Punch, + powerLevel: 0.5f); + + Assert.Equal(CombatManeuverSelection.None, selection); + } + + [Fact] + public void FindMotions_PreservesRetailTableOrder() + { + var table = MakeTable( + Entry(DatMotionStance.HandCombat, DatAttackHeight.Low, + DatAttackType.Kick, DatMotionCommand.AttackLow1), + Entry(DatMotionStance.HandCombat, DatAttackHeight.Low, + DatAttackType.Kick, (DatMotionCommand)0x1000018Eu), + Entry(DatMotionStance.HandCombat, DatAttackHeight.Low, + DatAttackType.Punch, DatMotionCommand.AttackLow2)); + + var motions = CombatManeuverSelector.FindMotions( + table, + DatMotionStance.HandCombat, + DatAttackHeight.Low, + DatAttackType.Kick); + + Assert.Equal(new[] + { + DatMotionCommand.AttackLow1, + (DatMotionCommand)0x1000018Eu, + }, motions); + } + + private static CombatTable MakeTable(params CombatManeuver[] maneuvers) + { + var table = new CombatTable(); + table.CombatManeuvers.AddRange(maneuvers); + return table; + } + + private static CombatManeuver Entry( + DatMotionStance stance, + DatAttackHeight height, + DatAttackType type, + DatMotionCommand motion) + { + return new CombatManeuver + { + Style = stance, + AttackHeight = height, + AttackType = type, + MinSkillLevel = 0, + Motion = motion, + }; + } +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs index d9baae81..0478c69d 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -27,6 +27,51 @@ public sealed class CombatStateTests Assert.Equal(1f, state.GetHealthPercent(0xDEAD)); } + [Fact] + public void CombatMode_UsesRetailAceBitValues() + { + Assert.Equal(1, (int)CombatMode.NonCombat); + Assert.Equal(2, (int)CombatMode.Melee); + Assert.Equal(4, (int)CombatMode.Missile); + Assert.Equal(8, (int)CombatMode.Magic); + } + + [Fact] + public void AttackType_UsesNamedRetailBitValues() + { + Assert.Equal(0x0001u, (uint)AttackType.Punch); + Assert.Equal(0x0002u, (uint)AttackType.Thrust); + Assert.Equal(0x0004u, (uint)AttackType.Slash); + Assert.Equal(0x0008u, (uint)AttackType.Kick); + Assert.Equal(0x0010u, (uint)AttackType.OffhandPunch); + Assert.Equal(0x79E0u, (uint)AttackType.MultiStrike); + } + + [Fact] + public void SetCombatMode_TracksCurrentMode_AndFiresEvent() + { + var state = new CombatState(); + CombatMode? seen = null; + state.CombatModeChanged += mode => seen = mode; + + state.SetCombatMode(CombatMode.Missile); + + Assert.Equal(CombatMode.Missile, state.CurrentMode); + Assert.Equal(CombatMode.Missile, seen); + } + + [Fact] + public void OnCombatCommenceAttack_FiresAttackCommenced() + { + var state = new CombatState(); + bool seen = false; + state.AttackCommenced += () => seen = true; + + state.OnCombatCommenceAttack(); + + Assert.True(seen); + } + [Fact] public void OnVictimNotification_FiresDamageTaken() { diff --git a/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs b/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs index 04a6c4be..2a4b960c 100644 --- a/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs @@ -4,15 +4,12 @@ using DatReaderWriter.Enums; namespace AcDream.Core.Tests.Meshing; /// -/// Verifies that maps -/// SurfaceType flag combinations to the correct -/// according to the documented priority order: -/// Additive > InvAlpha > AlphaBlend (Alpha|Translucent) > ClipMap > Opaque +/// Verifies the retail surface-state mapping used by the GL render split. +/// Priority order is: +/// Translucent+ClipMap override, Additive, InvAlpha, AlphaBlend, ClipMap, Opaque. /// public class TranslucencyKindTests { - // ── Opaque cases ──────────────────────────────────────────────────────── - [Fact] public void Opaque_FromZeroFlags_ReturnsOpaque() => Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType((SurfaceType)0)); @@ -25,8 +22,6 @@ public class TranslucencyKindTests public void Opaque_FromBase1ImageFlag_ReturnsOpaque() => Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1Image)); - // ── ClipMap cases ─────────────────────────────────────────────────────── - [Fact] public void ClipMap_FromBase1ClipMapFlag_ReturnsClipMap() => Assert.Equal(TranslucencyKind.ClipMap, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap)); @@ -36,8 +31,6 @@ public class TranslucencyKindTests => Assert.Equal(TranslucencyKind.ClipMap, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap | SurfaceType.Gouraud)); - // ── AlphaBlend cases ──────────────────────────────────────────────────── - [Fact] public void AlphaBlend_FromAlphaFlag_ReturnsAlphaBlend() => Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha)); @@ -56,7 +49,14 @@ public class TranslucencyKindTests => Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha | SurfaceType.Base1ClipMap)); - // ── InvAlpha cases ────────────────────────────────────────────────────── + [Fact] + public void AlphaBlend_TranslucentClipMapAdditiveCloud_ReturnsAlphaBlend() + => Assert.Equal(TranslucencyKind.AlphaBlend, + TranslucencyKindExtensions.FromSurfaceType( + SurfaceType.Base1ClipMap + | SurfaceType.Translucent + | SurfaceType.Alpha + | SurfaceType.Additive)); [Fact] public void InvAlpha_FromInvAlphaFlag_ReturnsInvAlpha() @@ -67,15 +67,40 @@ public class TranslucencyKindTests => Assert.Equal(TranslucencyKind.InvAlpha, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.InvAlpha | SurfaceType.Alpha)); - // ── Additive cases ────────────────────────────────────────────────────── - [Fact] public void Additive_FromAdditiveFlag_ReturnsAdditive() => Assert.Equal(TranslucencyKind.Additive, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Additive)); [Fact] - public void Additive_AdditiveBeatsAllOther() + public void Additive_AdditiveBeatsNonTranslucentBlendFlags() => Assert.Equal(TranslucencyKind.Additive, TranslucencyKindExtensions.FromSurfaceType( SurfaceType.Additive | SurfaceType.InvAlpha | SurfaceType.Alpha | SurfaceType.Base1ClipMap)); + + [Fact] + public void OpacityFromSurfaceTranslucency_NonTranslucentIgnoresRawValue() + { + Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Base1Image, 0f)); + Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Base1Image, 0.75f)); + } + + [Fact] + public void OpacityFromSurfaceTranslucency_TranslucentInvertsAndClamps() + { + Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, -0.25f)); + Assert.Equal(0.75f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, 0.25f)); + Assert.Equal(0f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, 1.25f)); + } + + [Fact] + public void DisablesFixedFunctionFog_RawAdditiveEvenWhenBlendForcedToAlpha() + { + var cloud = SurfaceType.Base1ClipMap + | SurfaceType.Translucent + | SurfaceType.Alpha + | SurfaceType.Additive; + + Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(cloud)); + Assert.True(TranslucencyKindExtensions.DisablesFixedFunctionFog(cloud)); + } } diff --git a/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs new file mode 100644 index 00000000..83ca7d07 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs @@ -0,0 +1,66 @@ +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public sealed class AnimationCommandRouterTests +{ + private const uint NonCombat = 0x8000003Du; + + [Theory] + [InlineData(0x00000000u, AnimationCommandRouteKind.None)] + [InlineData(0x10000057u, AnimationCommandRouteKind.Action)] // Sanctuary + [InlineData(0x2500003Bu, AnimationCommandRouteKind.Modifier)] // Jump + [InlineData(0x13000087u, AnimationCommandRouteKind.ChatEmote)] // Wave + [InlineData(0x41000003u, AnimationCommandRouteKind.SubState)] // Ready + [InlineData(0x40000011u, AnimationCommandRouteKind.SubState)] // Dead + [InlineData(0x8000003Du, AnimationCommandRouteKind.Ignored)] // NonCombat style + public void Classify_ReturnsRetailRouteKind(uint command, AnimationCommandRouteKind expected) + { + Assert.Equal(expected, AnimationCommandRouter.Classify(command)); + } + + [Fact] + public void RouteWireCommand_SubState_UsesSetCycle() + { + var seq = MakeEmptySequencer(); + + var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011); + + Assert.Equal(AnimationCommandRouteKind.SubState, route); + Assert.Equal(NonCombat, seq.CurrentStyle); + Assert.Equal(MotionCommand.Dead, seq.CurrentMotion); + } + + [Fact] + public void RouteWireCommand_Sanctuary_IsActionNotDeadCycle() + { + var seq = MakeEmptySequencer(); + + var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0057); + + Assert.Equal(AnimationCommandRouteKind.Action, route); + Assert.Equal(0u, seq.CurrentMotion); + } + + [Fact] + public void RouteWireCommand_Wave_IsChatEmote() + { + var seq = MakeEmptySequencer(); + + var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0087); + + Assert.Equal(AnimationCommandRouteKind.ChatEmote, route); + } + + private static AnimationSequencer MakeEmptySequencer() + { + return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + } + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index ac492dd7..b5f584a5 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -223,6 +223,46 @@ public sealed class AnimationSequencerTests } } + [Fact] + public void HasCycle_PresentInTable_ReturnsTrue() + { + // Phase L.1c followup (2026-04-28): regression guard for + // "torso on the ground" — caller (GameWindow MoveTo path) needs + // to query the table before SetCycle to avoid the + // ClearCyclicTail wipe on a missing cycle. + const uint Style = 0x003Cu; // HandCombat + const uint Motion = 0x0003u; // Ready + const uint AnimId = 0x03000001u; + + var setup = Fixtures.MakeSetup(2); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId); + var loader = new FakeLoader(); + loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); + var seq = new AnimationSequencer(setup, mt, loader); + + // Caller passes the SAME shape SetCycle expects: full style with + // class byte (0x80000000) and full motion (0x40000000 / 0x10000000). + Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u)); + } + + [Fact] + public void HasCycle_MissingFromTable_ReturnsFalse() + { + const uint Style = 0x003Cu; + const uint ReadyMotion = 0x0003u; + const uint AnimId = 0x03000001u; + + var setup = Fixtures.MakeSetup(2); + var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId); + var loader = new FakeLoader(); + loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); + var seq = new AnimationSequencer(setup, mt, loader); + + // RunForward (0x44000007) is NOT in the table — caller should + // see false and fall back to a known motion (WalkForward / Ready). + Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u)); + } + [Fact] public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms() { @@ -1313,6 +1353,45 @@ public sealed class AnimationSequencerTests Assert.Equal(99f, fr[0].Origin.X, 1); } + [Fact] + public void PlayAction_ActionSurvivesImmediateReadyCycleEcho() + { + // ACE broadcasts creature attacks as Action-class ForwardCommand + // values followed by Ready. Retail keeps currState.Substate at Ready + // while the action link drains, so the Ready echo must not abort the + // in-flight swing. + const uint Style = 0x003Du; + const uint IdleMotion = 0x41000003u; + const uint AttackMotion = 0x10000052u; + const uint IdleAnimId = 0x03000503u; + const uint AttackAnimId = 0x03000504u; + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style }; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + var cmdData = new MotionCommandData(); + cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f); + mt.Links[linkOuter] = cmdData; + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity)); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + seq.PlayAction(AttackMotion); + + seq.SetCycle(Style, IdleMotion); + + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(12f, fr[0].Origin.X, 1); + Assert.Equal(IdleMotion, seq.CurrentMotion); + } + [Fact] public void PlayAction_Modifier_ResolvesFromModifiersDict() { diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs index a233b021..c436f7ef 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs @@ -21,6 +21,10 @@ public class MotionCommandResolverTests [InlineData(0x000E, 0x6500000Eu)] // TurnLeft [InlineData(0x000F, 0x6500000Fu)] // SideStepRight [InlineData(0x0015, 0x40000015u)] // Falling + [InlineData(0x0011, 0x40000011u)] // Dead + [InlineData(0x0012, 0x41000012u)] // Crouch + [InlineData(0x0013, 0x41000013u)] // Sitting + [InlineData(0x0014, 0x41000014u)] // Sleeping // Action-class one-shots: melee attacks, death, portals [InlineData(0x0057, 0x10000057u)] // Sanctuary (death) [InlineData(0x0058, 0x10000058u)] // ThrustMed diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index e41b679e..18926116 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -685,6 +685,33 @@ public sealed class MotionInterpreterTests Assert.False(allowed); } + [Fact] + public void ContactAllowsMove_DeadState_RejectsMove() + { + var body = MakeGrounded(); + var interp = MakeInterp(body); + interp.InterpretedState.ForwardCommand = MotionCommand.Dead; + + bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); + + Assert.False(allowed); + } + + [Theory] + [InlineData(MotionCommand.Crouch)] + [InlineData(MotionCommand.Sitting)] + [InlineData(MotionCommand.Sleeping)] + public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand) + { + var body = MakeGrounded(); + var interp = MakeInterp(body); + interp.InterpretedState.ForwardCommand = postureCommand; + + bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); + + Assert.False(allowed); + } + [Fact] public void ContactAllowsMove_CrouchRange_RejectsMove() { diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs new file mode 100644 index 00000000..39182cb2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -0,0 +1,296 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Phase L.1c (2026-04-28). Covers — the +/// per-tick steering port of retail +/// MoveToManager::HandleMoveToPosition for server-controlled remote +/// creatures. +/// +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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs new file mode 100644 index 00000000..65cc50da --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs @@ -0,0 +1,88 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public sealed class ServerControlledLocomotionTests +{ + [Fact] + public void PlanMoveToStart_SeedsImmediateRunCycle() + { + var plan = ServerControlledLocomotion.PlanMoveToStart(); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(1.0f, plan.SpeedMod); + } + + [Fact] + public void PlanMoveToStart_AppliesRetailRunRate() + { + var plan = ServerControlledLocomotion.PlanMoveToStart( + moveToSpeed: 1.25f, + runRate: 1.5f, + canRun: true); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(1.875f, plan.SpeedMod); + } + + [Fact] + public void PlanMoveToStart_UsesWalkWhenRunDisallowed() + { + var plan = ServerControlledLocomotion.PlanMoveToStart( + moveToSpeed: 0.75f, + runRate: 2.0f, + canRun: false); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.WalkForward, plan.Motion); + Assert.Equal(0.75f, plan.SpeedMod); + } + + [Fact] + public void PlanFromVelocity_StopsBelowRetailNoiseThreshold() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.10f, 0.12f, 3.0f)); + + Assert.False(plan.IsMoving); + Assert.Equal(MotionCommand.Ready, plan.Motion); + Assert.Equal(1.0f, plan.SpeedMod); + } + + [Fact] + public void PlanFromVelocity_WalksForSlowServerControlledMotion() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.0f, 0.80f, 0.0f)); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.WalkForward, plan.Motion); + Assert.InRange(plan.SpeedMod, 0.25f, 0.27f); + } + + [Fact] + public void PlanFromVelocity_RunsAtRetailRunScale() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.0f, MotionInterpreter.RunAnimSpeed, 0.0f)); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(1.0f, plan.SpeedMod, precision: 4); + } + + [Fact] + public void PlanFromVelocity_ClampsVeryFastSnapshots() + { + var plan = ServerControlledLocomotion.PlanFromVelocity( + new Vector3(0.0f, 30.0f, 0.0f)); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(ServerControlledLocomotion.MaxSpeedMod, plan.SpeedMod); + } +} diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs new file mode 100644 index 00000000..1fc53e69 --- /dev/null +++ b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs @@ -0,0 +1,95 @@ +using System.Numerics; +using AcDream.Core.Vfx; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Vfx; + +public sealed class ParticleHookSinkTests +{ + private static EmitterDesc MakeDesc(uint id, bool attachLocal, int totalParticles = 0) + { + return new EmitterDesc + { + DatId = id, + Type = ParticleType.Still, + Flags = EmitterFlags.Billboard | (attachLocal ? EmitterFlags.AttachLocal : 0), + EmitterKind = ParticleEmitterKind.BirthratePerSec, + MaxParticles = 4, + InitialParticles = 1, + TotalParticles = totalParticles, + LifetimeMin = 0.05f, LifetimeMax = 0.05f, Lifespan = 0.05f, + StartSize = 1f, EndSize = 1f, + StartAlpha = 1f, EndAlpha = 1f, + Birthrate = 1000f, // effectively never re-emit + }; + } + + [Fact] + public void UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor() + { + var registry = new EmitterDescRegistry(); + registry.Register(MakeDesc(0x32000010u, attachLocal: true)); + var sys = new ParticleSystem(registry, new System.Random(42)); + var sink = new ParticleHookSink(sys); + + var hook = new CreateParticleHook + { + EmitterInfoId = 0x32000010u, + EmitterId = 0, + PartIndex = 0, + Offset = new Frame(), + }; + // First spawn at world origin. + sink.OnHook(entityId: 0xCAFEu, entityWorldPosition: Vector3.Zero, hook); + sys.Tick(0.01f); + + var live1 = System.Linq.Enumerable.Single(sys.EnumerateLive()); + Assert.Equal(Vector3.Zero, live1.Emitter.Particles[live1.Index].Position); + + // Move the parent to (5, 7, 0) — UpdateEntityAnchor must propagate. + sink.UpdateEntityAnchor(0xCAFEu, new Vector3(5, 7, 0), Quaternion.Identity); + sys.Tick(0.01f); + + var live2 = System.Linq.Enumerable.Single(sys.EnumerateLive()); + Assert.Equal(new Vector3(5, 7, 0), live2.Emitter.Particles[live2.Index].Position); + } + + [Fact] + public void EmitterDied_PrunesPerEntityHandleTracking() + { + // M4: ConcurrentBag 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); + } +} diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs index 947efe53..edc213f8 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs @@ -34,6 +34,43 @@ public sealed class ParticleSystemTests }; } + private static EmitterDesc MakeInitialParticleDesc( + ParticleType type, + Vector3 a, + Vector3 b, + Vector3 c) + { + return new EmitterDesc + { + DatId = 0x3200AA01u, + Type = type, + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 10f, + LifetimeMax = 10f, + Lifespan = 10f, + LifespanRand = 0f, + OffsetDir = Vector3.UnitZ, + MinOffset = 0f, + MaxOffset = 0f, + InitialVelocity = Vector3.Zero, + Gravity = Vector3.Zero, + A = a, + MinA = 1f, + MaxA = 1f, + B = b, + MinB = 1f, + MaxB = 1f, + C = c, + MinC = 1f, + MaxC = 1f, + StartSize = 0.5f, + EndSize = 0.5f, + StartAlpha = 1f, + EndAlpha = 1f, + }; + } + [Fact] public void SpawnEmitter_ReturnsPositiveHandle_AndTracksEmitter() { @@ -60,7 +97,7 @@ public sealed class ParticleSystemTests public void Tick_ParticlesDieAtLifetime() { var sys = MakeSystem(); - sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero); + int handle = sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero); // Use many short ticks so we can observe the death curve. // At 20/sec with 0.5s lifetime and a stable emission pool, the @@ -69,11 +106,10 @@ public sealed class ParticleSystemTests int steadyState = sys.ActiveParticleCount; Assert.InRange(steadyState, 7, 13); - // Now advance further with no spawns (stop emitter); all should die. - sys.SpawnEmitter(MakeDesc(emitRate: 0f, maxParticles: 1), Vector3.Zero); // noop - // Continue time; particles age past lifetime. + // Now advance further with no new spawns; all should die. + sys.StopEmitter(handle, fadeOut: true); for (int i = 0; i < 30; i++) sys.Tick(0.05f); // 1.5s more than lifetime - Assert.True(sys.ActiveParticleCount <= steadyState); + Assert.Equal(0, sys.ActiveParticleCount); } [Fact] @@ -100,7 +136,7 @@ public sealed class ParticleSystemTests var desc = new EmitterDesc { DatId = 0x32000002u, - Type = ParticleType.Parabolic, + Type = ParticleType.ParabolicLVGA, EmitRate = 10f, MaxParticles = 100, LifetimeMin = 2f, LifetimeMax = 2f, @@ -192,7 +228,7 @@ public sealed class ParticleSystemTests } [Fact] - public void MaxParticles_CapEnforced_OverwriteOldest() + public void MaxParticles_CapEnforced() { var sys = MakeSystem(); // Low cap, high rate, long life → rapidly hit cap. @@ -219,4 +255,239 @@ public sealed class ParticleSystemTests reg.Register(desc); Assert.Same(desc, reg.Get(0x32001234u)); } + + [Fact] + public void LocalVelocity_TransformsABySpawnRotation() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.LocalVelocity, + Vector3.UnitX, + Vector3.Zero, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, -0.0001f, 0.0001f); + Assert.InRange(pos.Y, 0.9999f, 1.0001f); + } + + [Fact] + public void GlobalVelocity_DoesNotTransformABySpawnRotation() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.GlobalVelocity, + Vector3.UnitX, + Vector3.Zero, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.9999f, 1.0001f); + Assert.InRange(pos.Y, -0.0001f, 0.0001f); + } + + [Fact] + public void ParabolicLVLA_TransformsLocalAcceleration() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.ParabolicLVLA, + Vector3.Zero, + Vector3.UnitX, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, -0.0001f, 0.0001f); + Assert.InRange(pos.Y, 0.4999f, 0.5001f); + } + + [Fact] + public void ParabolicLVGA_KeepsGlobalAcceleration() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.ParabolicLVGA, + Vector3.Zero, + Vector3.UnitX, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.4999f, 0.5001f); + Assert.InRange(pos.Y, -0.0001f, 0.0001f); + } + + [Fact] + public void EmitterDescRegistry_FromDat_PreservesRetailEnumValuesAndRates() + { + var dat = new DatReaderWriter.DBObjs.ParticleEmitter + { + EmitterType = DatReaderWriter.Enums.EmitterType.BirthratePerSec, + ParticleType = DatReaderWriter.Enums.ParticleType.Swarm, + GfxObjId = 0x01000001u, + HwGfxObjId = 0x01000002u, + Birthrate = 0.25, + MaxParticles = 17, + InitialParticles = 3, + TotalParticles = 9, + TotalSeconds = 4, + Lifespan = 2, + LifespanRand = 0.5, + A = new Vector3(1, 0, 0), + MinA = 0.5f, + MaxA = 2f, + StartScale = 0.2f, + FinalScale = 0.8f, + StartTrans = 1f, + FinalTrans = 0f, + IsParentLocal = true, + }; + + var desc = EmitterDescRegistry.FromDat(0x32000099u, dat); + + Assert.Equal(ParticleType.Swarm, desc.Type); + Assert.Equal(ParticleEmitterKind.BirthratePerSec, desc.EmitterKind); + Assert.Equal(4f, desc.EmitRate); + Assert.Equal(0x01000001u, desc.GfxObjId); + Assert.Equal(0x01000002u, desc.HwGfxObjId); + Assert.Equal(3, desc.InitialParticles); + Assert.Equal(9, desc.TotalParticles); + Assert.Equal(1.5f, desc.LifetimeMin); + Assert.Equal(2.5f, desc.LifetimeMax); + Assert.Equal(0f, desc.StartAlpha); + Assert.Equal(1f, desc.EndAlpha); + Assert.Equal(EmitterFlags.Billboard | EmitterFlags.FaceCamera | EmitterFlags.AttachLocal, desc.Flags); + Assert.True((desc.Flags & EmitterFlags.AttachLocal) != 0); + } + + [Fact] + public void UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor() + { + // Retail ParticleEmitter::UpdateParticles 0x0051d2d4 reads the live + // parent frame each tick when is_parent_local=1. With the cameraOffset + // hack removed, AttachLocal correctness now depends on the owning + // subsystem updating AnchorPos every frame via UpdateEmitterAnchor. + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32AABBCCu, + Type = ParticleType.Still, + Flags = EmitterFlags.AttachLocal | EmitterFlags.Billboard, + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, + StartSize = 1f, EndSize = 1f, + StartAlpha = 1f, EndAlpha = 1f, + // Zero motion + zero offset so position == origin == AnchorPos. + }; + int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); + sys.Tick(0.01f); + + var p1 = sys.EnumerateLive().Single().Emitter.Particles[0]; + Assert.Equal(new Vector3(10, 0, 0), p1.Position); + + // Move the live anchor; AttachLocal should track it on the next tick. + sys.UpdateEmitterAnchor(handle, new Vector3(50, 20, 5)); + sys.Tick(0.01f); + + var p2 = sys.EnumerateLive().Single().Emitter.Particles[0]; + Assert.Equal(new Vector3(50, 20, 5), p2.Position); + } + + [Fact] + public void UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin() + { + // is_parent_local=0 → particle uses its frozen EmissionOrigin; later + // anchor updates must NOT move it (retail's "frame snapshotted at + // spawn" semantics). + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32AABBCDu, + Type = ParticleType.Still, + Flags = EmitterFlags.Billboard, // NO AttachLocal + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, + StartSize = 1f, EndSize = 1f, + StartAlpha = 1f, EndAlpha = 1f, + }; + int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); + sys.Tick(0.01f); + + sys.UpdateEmitterAnchor(handle, new Vector3(99, 99, 99)); + sys.Tick(0.01f); + + var p = sys.EnumerateLive().Single().Emitter.Particles[0]; + Assert.Equal(new Vector3(10, 0, 0), p.Position); + } + + [Fact] + public void EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire() + { + var sys = MakeSystem(); + var fired = new System.Collections.Generic.List(); + 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); + } } diff --git a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs index 0eafa2e7..d86cb57b 100644 --- a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs @@ -207,4 +207,28 @@ public sealed class PhysicsScriptRunnerTests runner.Tick(0.5f); // total 0.6 > 0.5 pause Assert.Single(sink.Calls); } + + [Fact] + public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance() + { + var script = BuildScript( + (0.0, new CallPESHook { PES = 0xAA, Pause = 30f }), + (0.0, CreateHook(123))); + + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, script)); + runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero); + + runner.Tick(0.1f); + + Assert.Single(sink.Calls); + Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); + Assert.Equal(1, runner.ActiveScriptCount); + + runner.Tick(29.8f); + Assert.Single(sink.Calls); + + runner.Tick(0.3f); + Assert.Equal(2, sink.Calls.Count); + } } diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeCollection.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeCollection.cs new file mode 100644 index 00000000..48597b0d --- /dev/null +++ b/tests/AcDream.Core.Tests/World/DerethDateTimeCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace AcDream.Core.Tests.World; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class DerethDateTimeCollection +{ + public const string Name = "DerethDateTime global offset"; +} diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs index 86fb5a9f..c6659179 100644 --- a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs +++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs @@ -3,6 +3,7 @@ using Xunit; namespace AcDream.Core.Tests.World; +[Collection(DerethDateTimeCollection.Name)] public sealed class DerethDateTimeTests { // ACE calendar anchor: tick 0 = Morningthaw 1, 10 P.Y. at Morntide-and-Half diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index 3331e85a..d07d0a64 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -72,6 +72,29 @@ public sealed class SkyDescLoaderTests Assert.Equal(FogMode.Linear, kf.FogMode); } + [Fact] + public void LoadFromRegion_CapturesSkyObjectPesId() + { + var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255); + var dg = region.SkyInfo!.DayGroups[0]; + dg.SkyObjects.Add(new SkyObject + { + BeginTime = 0f, + EndTime = 1f, + DefaultGfxObjectId = 0x01004C44u, + DefaultPesObjectId = 0x3300042Cu, + Properties = 0x05, + }); + + var loaded = SkyDescLoader.LoadFromRegion(region); + + Assert.NotNull(loaded); + var obj = Assert.Single(loaded!.DayGroups[0].SkyObjects); + Assert.Equal(0x01004C44u, obj.GfxObjId); + Assert.Equal(0x3300042Cu, obj.PesObjectId); + Assert.True(obj.IsPostScene); + } + [Fact] public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude() { diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index bd3bc73f..1c677204 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace AcDream.Core.Tests.World; +[Collection(DerethDateTimeCollection.Name)] public sealed class SkyStateTests { [Fact] diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs index 7acf0d13..f05f64a5 100644 --- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs +++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace AcDream.Core.Tests.World; +[Collection(DerethDateTimeCollection.Name)] public sealed class WorldTimeDebugTests { [Fact] @@ -28,7 +29,8 @@ public sealed class WorldTimeDebugTests // fraction 1/16: solve (t + 7/16*D) mod D = 1/16*D // → t = (1/16 - 7/16) * D mod D = -6/16 * D mod D = 10/16 * D. double targetFraction = 1.0 / 16.0; // Darktide-and-Half - double syncTick = (targetFraction - (7.0 / 16.0) + 1.0) * DerethDateTime.DayTicks; + double syncTick = targetFraction * DerethDateTime.DayTicks - DerethDateTime.OriginOffsetTicks; + while (syncTick < 0) syncTick += DerethDateTime.DayTicks; var service = new WorldTimeService(SkyStateProvider.Default()); service.SyncFromServer(syncTick);