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