diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 82c06c8a..f0603c59 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -208,13 +208,15 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | -| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting. **Aetheria retired from this row 2026-07-13:** all three sigil slots use exact backgrounds/equip masks and live `PropertyInt.AetheriaBitfield` visibility. | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; inventory double-click faithfully sequences the primary weapon, incompatible shield, and mismatched ammo blockers through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | +| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the remaining seams require the jump/movement command owner and a distinct Recklessness treatment rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | +| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | + ## 4. Temporary stopgap (TS) — 34 active rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index cfb67b8c..d14083ac 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -559,8 +559,12 @@ include dungeons. TS-26's `UpdatePosition` freshness gap is retired by the nine-channel timestamp gates; TS-49 remains the explicit - DetectionManager/`LeftDetection` gap. The final two-client visual gate is - still pending. + DetectionManager/`LeftDetection` gap. A 2026-07-14 corrective visual slice + ports `CPhysicsPart::Always2D`: mode-1/no-degrade particles now keep every + authored mesh polygon through the modern global buffer, while other degrade + modes retain the billboard path. This restores the four-piece apex of + Armor/protection shield effects. The final two-client visual gate is still + pending. - **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** — local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted funnel and action-stamp gate, so ACE's server-selected melee/missile action diff --git a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md index 30a00e3f..1c5dc70a 100644 --- a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md +++ b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md @@ -3,8 +3,9 @@ ## Scope Inventory double-click of a weapon while another primary weapon is equipped, -in both peace and combat modes. The UI request sequence is shared; combat-mode -animation remains server-authoritative. +or dragging an item onto a specific paperdoll slot, in both peace and combat +modes. The UI request sequence is shared; combat-mode animation remains +server-authoritative. ## Retail oracle @@ -21,6 +22,10 @@ animation remains server-authoritative. `AutoWield` only after the server confirms the blocking item moved. - `ACCWeenieObject::UIAttemptWield @ 0x0058D590` sends `GetAndWieldItem(item, equipMask)`. +- `gmPaperDollUI::HandleDropRelease @ 0x004A4D80` routes an accepted physical + item through `AcceptDragObject @ 0x004A3B10`, which calls `AutoWield` (or + `AutoWear`) with the paperdoll's resolved target side. It does not send a + direct `GetAndWieldItem` while a blocker remains equipped. - `ClientCombatSystem::OnQualityChanged @ 0x0056C1B0` reads player PropertyInt 40 (`CombatMode`) and applies it through `SetCombatMode`. @@ -48,6 +53,10 @@ on inventory item double-click(item): if decision is WieldRight or WieldLeft or AutoSort: auto_wield(item) +on paperdoll drop(item, target slot): + resolvedMask = resolve item.ValidLocations against the authored slot side + auto_wield(item, resolvedMask) + auto_wield(item): if another auto-wield transaction is waiting for the server: consume the request without sending another inventory action @@ -60,7 +69,10 @@ auto_wield(item): blocker = player's equipped item intersecting WEAPON_READY_MASK if blocker exists: - pending = (requestedItem = item.id, blockingItem = blocker.id) + print system text "Moving to your backpack" + pending = (requestedItem = item.id, + blockingItem = blocker.id, + resolvedMask = desiredMask) send PutItemInContainer(blocker.id, player.id, placement = 0) return true @@ -82,7 +94,7 @@ on server-confirmed item move(blocker): and blocker is now loose in the player's main pack: requested = pending.requestedItem clear pending before re-entry - auto_wield(requested) + auto_wield(requested, pending.resolvedMask) on server inventory-request failure(item, error): if item matches pending.blockingItem or pending.requestedItem: @@ -124,8 +136,14 @@ separate optimistic rollback counter. ## Known remaining retail surface -This port covers the primary weapon-ready group plus retail's secondary -incompatible-shield and mismatched-ammo blockers. `CreateObject` now retains -the exact `AmmoType` needed for those decisions. Full `AutoWieldIsLegal`, -Aetheria, and dual-wield/off-hand rules remain tracked by divergence row -AP-108. +This port covers inventory activation and paperdoll drops through the same +primary weapon-ready transaction, plus retail's secondary incompatible-shield +and mismatched-ammo blockers. `CreateObject` retains the exact `AmmoType` +needed for those decisions, and paperdoll target-side intent survives every +authoritative blocker retry. Full `AutoWieldIsLegal` and dual-wield/off-hand +rules remain tracked by divergence row AP-108. Retail also appends +`" - cannot unwield the %s"` when `AttemptToPlaceInContainer` rejects +synchronously. ACDream's current send seam is void and ACE reports that +rejection asynchronously, so the successful status line is exact while that +failure suffix remains tracked instead of being fabricated at the wrong +transaction boundary. diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md index 96541259..6cd796c8 100644 --- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -1169,3 +1169,39 @@ animation-hook/PES route plus Hidden/remote-placement/UnHide ownership. Installe DAT colors, trail/cloud appearance, remote portal observation, and impact-origin presentation remain visual acceptance items; an in-memory conformance fixture cannot claim pixel appearance. + +## Corrective visual gate — authored particle geometry (2026-07-14) + +Oracle: `CPhysicsPart::Draw` at `0x0050D7A0`, `CPhysicsPart::Always2D` at +`0x0050D8A0`, and `ParticleEmitter::SetInfo` at `0x0051CE90`. + +```text +particle presentation part = construct a complete CPhysicsPart from emitter GfxObj + +Always2D(part): + if part has no degrade table or the table has no entries: + return false + return firstDegrade.DegradeMode != 1 + +draw particle: + if Always2D(part): + draw the camera-facing degrade presentation + else: + draw every authored mesh polygon and surface through CPhysicsPart::DrawMesh +``` + +The installed Armor Self chain proves why this distinction is visible: +`PS_ShieldUpGrey` resolves through table `0x34000004` to PES `0x3300068F`, +whose first four CreateParticle hooks use emitters `0x32000350`, `351`, `355`, +and `356`. Their hardware GfxObjs `0x01001666..0x01001669` are mode-1 meshes, +each with five vertices and multiple triangular polygons. Each piece contributes +an apex face meeting near local Z = 2.32 m; together they close the crystal top. +Emitter `0x32000379`/GfxObj `0x01001098` is the separate mode-2 additive +billboard. Flattening every GfxObj to one bounding-box quad discards the four +apex pieces and produces the observed open shield. + +The App renderer now follows `Always2D`: full-mesh particles reuse the mandatory +modern `GlobalMeshBuffer` batches, bindless surface handles, cull modes, and +authored indices; only the 2D case enters the six-index quad path. Mesh ownership +is keyed by the stable particle-emitter handle and releases its WB reference on +the exact `EmitterDied` lifecycle edge (or renderer teardown before WB teardown). diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a132f01f..7b1f15cb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2125,6 +2125,7 @@ public sealed class GameWindow : IDisposable _liveSession?.SendStackableSplitTo3D(item, amount), selectedObjectId: () => _selection.SelectedObjectId ?? 0u, stackSplitQuantity: _stackSplitQuantity, + systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), sendPutItemInContainer: (item, container, placement) => _liveSession?.SendPutItemInContainer(item, container, placement)); var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( @@ -2197,15 +2198,15 @@ public sealed class GameWindow : IDisposable var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider( Objects, - _visibleEntitiesByServerGuid, - LastSpawns, + () => _visibleEntitiesByServerGuid, + () => LastSpawns, playerGuid: () => _playerServerGuid, playerYawRadians: () => _playerController?.Yaw ?? 0f, playerCellId: () => _playerController?.CellId ?? 0u, selectedGuid: () => _selection.SelectedObjectId, coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, uiLocked: () => _persistedGameplay.LockUI, - playerEntities: _entitiesByServerGuid); + playerEntities: () => _entitiesByServerGuid); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); _retailChatVm = retailChatVm; AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null @@ -2290,7 +2291,6 @@ public sealed class GameWindow : IDisposable item, container, placement, amount), (source, target, amount) => _liveSession?.SendStackableMerge(source, target, amount), - (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), _itemInteractionController, _selection), Cursor: new AcDream.App.UI.RetailUiCursorBindings( @@ -2588,10 +2588,16 @@ public sealed class GameWindow : IDisposable _gl, _dats, skyShader, _textureCache!, _samplerCache); // Phase G.1 particle renderer — renders rain / snow / spell auras - // spawned into the shared ParticleSystem as billboard quads. - // Weather uses AttachLocal emitters so the rain volume follows - // the player. - _particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats); + // spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs + // retain their authored mesh; Always2D degrade modes use billboards. + // Weather uses AttachLocal emitters so its volume follows the player. + _particleRenderer = new ParticleRenderer( + _gl, + shadersDir, + _particleSystem, + _textureCache, + _dats, + _wbMeshAdapter); // A.5 T22.5: apply radii from the already-resolved _resolvedQuality. // _resolvedQuality was set by the quality block immediately after @@ -9460,6 +9466,7 @@ public sealed class GameWindow : IDisposable using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload)) { _wbMeshAdapter?.Tick(); + _particleRenderer?.BeginFrame(); } if (_frameDiag) FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor, @@ -9984,7 +9991,7 @@ public sealed class GameWindow : IDisposable _gl.Disable(EnableCap.ClipDistance0 + _cp); if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, + _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); } @@ -10108,7 +10115,6 @@ public sealed class GameWindow : IDisposable return; DisableClipDistances(); _particleRenderer.Draw( - _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, @@ -10224,7 +10230,6 @@ public sealed class GameWindow : IDisposable { sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered"; _particleRenderer.Draw( - _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, @@ -10236,7 +10241,6 @@ public sealed class GameWindow : IDisposable { sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global"; _particleRenderer.Draw( - _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene); @@ -10260,7 +10264,6 @@ public sealed class GameWindow : IDisposable // are never in the outdoor-static id set). sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; _particleRenderer.Draw( - _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, @@ -10284,7 +10287,7 @@ public sealed class GameWindow : IDisposable _gl.Disable(EnableCap.ClipDistance0 + _cp); if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, + _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } @@ -11317,7 +11320,7 @@ public sealed class GameWindow : IDisposable DisableClipDistances(); if (renderSky && _particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, + _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); EnableClipDistances(); @@ -11456,7 +11459,6 @@ public sealed class GameWindow : IDisposable && _particleRenderer is not null) { _particleRenderer.Draw( - _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, @@ -11476,7 +11478,7 @@ public sealed class GameWindow : IDisposable _activeDayGroup, kf, environOverrideActive); DisableClipDistances(); if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, + _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } else @@ -11580,7 +11582,6 @@ public sealed class GameWindow : IDisposable // composites the pixels. DisableClipDistances(); _particleRenderer.Draw( - _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, @@ -11617,7 +11618,6 @@ public sealed class GameWindow : IDisposable DisableClipDistances(); _particleRenderer.Draw( - _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, @@ -14452,6 +14452,7 @@ public sealed class GameWindow : IDisposable _fadeOverlay?.Dispose(); _clipFrame?.Dispose(); // Phase U.3 _skyRenderer?.Dispose(); // depends on sampler cache; dispose first + _particleRenderer?.Dispose(); // releases particle mesh refs before WB _samplerCache?.Dispose(); _textureCache?.Dispose(); _wbMeshAdapter?.Dispose(); // Phase N.4+N.5 WB foundation (mandatory modern path) @@ -14461,7 +14462,6 @@ public sealed class GameWindow : IDisposable _terrainModernShader?.Dispose(); _sceneLightingUbo?.Dispose(); _paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures - _particleRenderer?.Dispose(); _debugLines?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index c21cbc72..638d74c2 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Content; +using AcDream.Core.Meshing; using AcDream.Core.Vfx; using DatReaderWriter; using DatReaderWriter.DBObjs; @@ -16,6 +19,11 @@ 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 record struct MeshBatchKey(uint GfxObjId, int BatchIndex); + private readonly record struct MeshParticleDraw( + MeshBatchKey Key, + ObjectRenderBatch Batch, + MeshParticleInstance Instance); private readonly struct ParticleInstance { @@ -35,18 +43,44 @@ public sealed unsafe class ParticleRenderer : IDisposable } } + private readonly struct MeshParticleInstance + { + public readonly Matrix4x4 Model; + public readonly uint ColorArgb; + public readonly float DistanceSq; + + public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq) + { + Model = model; + ColorArgb = colorArgb; + DistanceSq = distanceSq; + } + } + private readonly GL _gl; private readonly Shader _shader; + private readonly Shader? _meshShader; private readonly TextureCache? _textures; private readonly DatCollection? _dats; + private readonly WbMeshAdapter? _meshAdapter; + private readonly ParticleSystem _particles; private readonly Dictionary _particleGfxInfoByGfxObj = new(); + private readonly Dictionary _geometryKindByGfxObj = new(); + private readonly Dictionary _meshBlendBySurface = new(); + private readonly ParticleMeshReferenceTracker? _meshReferences; + private readonly HashSet _meshLoadRequestedThisFrame = new(); + private readonly int _meshTextureHandleLoc = -1; + private readonly int _meshTextureLayerLoc = -1; private readonly uint _quadVao; private readonly uint _quadVbo; private readonly uint _quadEbo; private readonly uint _instanceVbo; + private readonly uint _meshVao; + private readonly uint _meshInstanceVbo; private float[] _instanceScratch = new float[256 * 16]; + private float[] _meshInstanceScratch = new float[256 * 20]; // MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame // (sky pre/post, scene, per-visible-cell, dynamics, unattached passes), @@ -60,15 +94,40 @@ public sealed unsafe class ParticleRenderer : IDisposable // no call overlaps another's use of these buffers. private readonly List _drawListScratch = new(64); private readonly List _runScratch = new(64); + private readonly List _meshDrawListScratch = new(64); + private readonly List _meshRunScratch = new(64); + private readonly List _submissionScratch = new(128); - public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null) + public ParticleRenderer( + GL gl, + string shadersDir, + ParticleSystem particles, + TextureCache? textures = null, + DatCollection? dats = null, + WbMeshAdapter? meshAdapter = null) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _textures = textures; _dats = dats; + _meshAdapter = meshAdapter; + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + if (_meshAdapter is not null) + { + _meshReferences = new ParticleMeshReferenceTracker( + gfxObjId => _meshAdapter.IncrementRefCount(gfxObjId), + gfxObjId => _meshAdapter.DecrementRefCount(gfxObjId)); + } _shader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle.vert"), System.IO.Path.Combine(shadersDir, "particle.frag")); + if (_meshAdapter?.MeshManager?.GlobalBuffer is not null) + { + _meshShader = new Shader(_gl, + System.IO.Path.Combine(shadersDir, "particle_mesh.vert"), + System.IO.Path.Combine(shadersDir, "particle_mesh.frag")); + _meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle"); + _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); + } float[] quadVerts = { @@ -127,67 +186,204 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.VertexAttribDivisor(5, 1); _gl.BindVertexArray(0); + + _meshVao = _gl.GenVertexArray(); + _meshInstanceVbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo); + _gl.BufferData( + BufferTargetARB.ArrayBuffer, + (nuint)(256 * 20 * sizeof(float)), + (void*)0, + BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + + _particles.EmitterDied += OnEmitterDied; } + /// + /// Starts one render frame. Wb point-of-use recovery is limited to one + /// request per missing GfxObj even though portal slicing may invoke Draw + /// many times during the frame. + /// + public void BeginFrame() + => _meshLoadRequestedThisFrame.Clear(); + public void Draw( - ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos, ParticleRenderPass renderPass = ParticleRenderPass.Scene, Func? emitterFilter = null) { - if (particles is null || camera is null) + if (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, emitterFilter); - if (draws.Count == 0) - return; - draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq)); - // draws IS _drawListScratch (see BuildDrawList) - sorting it in place - // is fine, nothing else reads it between BuildDrawList's return and - // this call. + BuildDrawLists(cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter); + if (_submissionScratch.Count > 0) + DrawOrdered(camera); + } - _shader.Use(); - _shader.SetMatrix4("uViewProjection", camera.View * camera.Projection); - _shader.SetInt("uParticleTexture", 0); + private void DrawOrdered(ICamera camera) + { + ParticleSubmissionOrdering.Sort(_submissionScratch); + GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); - _gl.Disable(EnableCap.CullFace); _gl.ActiveTexture(TextureUnit.Texture0); - var run = _runScratch; - for (int i = 0; i < draws.Count;) + ParticleSubmissionKind? activeKind = null; + for (int i = 0; i < _submissionScratch.Count;) { - var key = draws[i].Key; - run.Clear(); + ParticleSubmission submission = _submissionScratch[i]; + if (submission.Kind == ParticleSubmissionKind.Billboard) + { + ParticleDraw draw = _drawListScratch[submission.DrawIndex]; + BatchKey key = draw.Key; + if (activeKind != ParticleSubmissionKind.Billboard) + { + PrepareBillboardPipeline(camera); + activeKind = ParticleSubmissionKind.Billboard; + } + + _runScratch.Clear(); + do + { + _runScratch.Add(_drawListScratch[submission.DrawIndex].Instance); + i++; + if (i >= _submissionScratch.Count) + break; + submission = _submissionScratch[i]; + } + while (submission.Kind == ParticleSubmissionKind.Billboard + && _drawListScratch[submission.DrawIndex].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(_runScratch); + continue; + } + + if (_meshShader is null || global is null) + { + i++; + continue; + } + + MeshParticleDraw meshDraw = _meshDrawListScratch[submission.DrawIndex]; + MeshBatchKey meshKey = meshDraw.Key; + ObjectRenderBatch batch = meshDraw.Batch; + if (activeKind != ParticleSubmissionKind.Mesh) + { + PrepareMeshPipeline(camera, global); + activeKind = ParticleSubmissionKind.Mesh; + } + + _meshRunScratch.Clear(); do { - run.Add(draws[i].Instance); + _meshRunScratch.Add(_meshDrawListScratch[submission.DrawIndex].Instance); i++; + if (i >= _submissionScratch.Count) + break; + submission = _submissionScratch[i]; } - while (i < draws.Count && draws[i].Key == key); + while (submission.Kind == ParticleSubmissionKind.Mesh + && _meshDrawListScratch[submission.DrawIndex].Key == meshKey); + ApplyMeshCullMode(batch.CullMode); + TranslucencyKind blend = ResolveMeshBlend(batch); _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); + blend == TranslucencyKind.InvAlpha + ? BlendingFactor.OneMinusSrcAlpha + : BlendingFactor.SrcAlpha, + blend switch + { + TranslucencyKind.Additive => BlendingFactor.One, + TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha, + _ => BlendingFactor.OneMinusSrcAlpha, + }); + + _gl.ProgramUniform2( + _meshShader.Program, + _meshTextureHandleLoc, + (uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu), + (uint)(batch.BindlessTextureHandle >> 32)); + _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); + + UploadMeshInstances(_meshRunScratch); + _gl.DrawElementsInstancedBaseVertex( + PrimitiveType.Triangles, + (uint)batch.IndexCount, + DrawElementsType.UnsignedShort, + (void*)(batch.FirstIndex * sizeof(ushort)), + (uint)_meshRunScratch.Count, + (int)batch.BaseVertex); } _gl.BindTexture(TextureTarget.Texture2D, 0); _gl.BindVertexArray(0); _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); + _gl.Disable(EnableCap.CullFace); } - private List BuildDrawList( - ParticleSystem particles, + private void PrepareBillboardPipeline(ICamera camera) + { + _shader.Use(); + _shader.SetMatrix4("uViewProjection", camera.View * camera.Projection); + _shader.SetInt("uParticleTexture", 0); + _gl.Disable(EnableCap.CullFace); + _gl.BindVertexArray(_quadVao); + } + + private void PrepareMeshPipeline(ICamera camera, GlobalMeshBuffer global) + { + _meshShader!.Use(); + _meshShader.SetMatrix4("uViewProjection", camera.View * camera.Projection); + _gl.FrontFace(FrontFaceDirection.CW); + _gl.BindVertexArray(_meshVao); + + // GlobalMeshBuffer may grow and replace either backing buffer. Bind + // today's buffer names on every pipeline switch instead of caching + // them in this VAO at construction time. + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, global.VBO); + int vertexStride = VertexPositionNormalTexture.Size; + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(6 * sizeof(float))); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, global.IBO); + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo); + const int instanceStride = 20 * sizeof(float); + for (uint column = 0; column < 4; column++) + { + uint location = 3 + column; + _gl.EnableVertexAttribArray(location); + _gl.VertexAttribPointer( + location, + 4, + VertexAttribPointerType.Float, + false, + instanceStride, + (void*)(column * 4 * sizeof(float))); + _gl.VertexAttribDivisor(location, 1); + } + _gl.EnableVertexAttribArray(7); + _gl.VertexAttribPointer(7, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(16 * sizeof(float))); + _gl.VertexAttribDivisor(7, 1); + } + + private void BuildDrawLists( Vector3 cameraWorldPos, ParticleRenderPass renderPass, Vector3 cameraRight, @@ -196,7 +392,10 @@ public sealed unsafe class ParticleRenderer : IDisposable { var draws = _drawListScratch; draws.Clear(); - foreach (var (em, idx) in particles.EnumerateLive()) + _meshDrawListScratch.Clear(); + _submissionScratch.Clear(); + int sequence = 0; + foreach (var (em, idx) in _particles.EnumerateLive()) { if (!em.PresentationVisible) continue; @@ -213,6 +412,14 @@ public sealed unsafe class ParticleRenderer : IDisposable // ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1. Vector3 pos = p.Position; float distSq = Vector3.DistanceSquared(pos, cameraWorldPos); + uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId; + if (gfxObjId != 0 + && ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh + && TryAppendMeshDraws(em, p, gfxObjId, distSq, ref sequence)) + { + continue; + } + var gfxInfo = ResolveParticleGfxInfo(em.Desc); uint texture = gfxInfo.TextureHandle; bool useTexture = texture != 0; @@ -236,10 +443,60 @@ public sealed unsafe class ParticleRenderer : IDisposable axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size); } + int drawIndex = draws.Count; draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq))); + _submissionScratch.Add(new ParticleSubmission( + ParticleSubmissionKind.Billboard, + drawIndex, + distSq, + sequence++)); + } + } + + private bool TryAppendMeshDraws( + AcDream.Core.Vfx.ParticleEmitter emitter, + Particle particle, + uint gfxObjId, + float distanceSq, + ref int sequence) + { + if (_meshAdapter is null || _meshShader is null) + return true; + + _meshReferences!.Register(emitter.Handle, gfxObjId); + ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(gfxObjId); + if (renderData is null) + { + if (_meshLoadRequestedThisFrame.Add(gfxObjId)) + _meshAdapter.EnsureLoaded(gfxObjId); + return true; } - return draws; + Quaternion orientation = ParticleOrientation(emitter, particle); + Matrix4x4 model = Matrix4x4.CreateScale(particle.Size) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(particle.Position); + var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq); + + for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++) + { + ObjectRenderBatch batch = renderData.Batches[batchIndex]; + if (batch.IndexCount <= 0 || batch.BindlessTextureHandle == 0) + continue; + + int drawIndex = _meshDrawListScratch.Count; + _meshDrawListScratch.Add(new MeshParticleDraw( + new MeshBatchKey(gfxObjId, batchIndex), + batch, + instance)); + _submissionScratch.Add(new ParticleSubmission( + ParticleSubmissionKind.Mesh, + drawIndex, + distanceSq, + sequence++)); + } + + return true; } private void DrawInstances(List instances) @@ -290,6 +547,120 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count); } + private void UploadMeshInstances(List instances) + { + int needed = instances.Count * 20; + if (_meshInstanceScratch.Length < needed) + _meshInstanceScratch = new float[needed + 256 * 20]; + + for (int i = 0; i < instances.Count; i++) + { + MeshParticleInstance instance = instances[i]; + Matrix4x4 model = instance.Model; + int o = i * 20; + _meshInstanceScratch[o + 0] = model.M11; + _meshInstanceScratch[o + 1] = model.M12; + _meshInstanceScratch[o + 2] = model.M13; + _meshInstanceScratch[o + 3] = model.M14; + _meshInstanceScratch[o + 4] = model.M21; + _meshInstanceScratch[o + 5] = model.M22; + _meshInstanceScratch[o + 6] = model.M23; + _meshInstanceScratch[o + 7] = model.M24; + _meshInstanceScratch[o + 8] = model.M31; + _meshInstanceScratch[o + 9] = model.M32; + _meshInstanceScratch[o + 10] = model.M33; + _meshInstanceScratch[o + 11] = model.M34; + _meshInstanceScratch[o + 12] = model.M41; + _meshInstanceScratch[o + 13] = model.M42; + _meshInstanceScratch[o + 14] = model.M43; + _meshInstanceScratch[o + 15] = model.M44; + _meshInstanceScratch[o + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f; + _meshInstanceScratch[o + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f; + _meshInstanceScratch[o + 18] = (instance.ColorArgb & 0xFF) / 255f; + _meshInstanceScratch[o + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f; + } + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo); + fixed (void* bp = _meshInstanceScratch) + { + _gl.BufferData( + BufferTargetARB.ArrayBuffer, + (nuint)(instances.Count * 20 * sizeof(float)), + bp, + BufferUsageARB.DynamicDraw); + } + } + + private void ApplyMeshCullMode(CullMode mode) + { + _gl.FrontFace(FrontFaceDirection.CW); + switch (mode) + { + case CullMode.None: + _gl.Disable(EnableCap.CullFace); + break; + case CullMode.Clockwise: + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Front); + break; + case CullMode.CounterClockwise: + case CullMode.Landblock: + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); + break; + } + } + + private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch) + { + uint surfaceId = batch.Key.SurfaceId; + if (surfaceId == 0 || _dats is null) + return batch.IsAdditive ? TranslucencyKind.Additive : TranslucencyKind.AlphaBlend; + if (_meshBlendBySurface.TryGetValue(surfaceId, out TranslucencyKind blend)) + return blend; + + blend = RetailParticleBlendResolver.Resolve( + surfaceId, + batch.IsAdditive, + id => _dats.Get(id), + Console.Error.WriteLine); + _meshBlendBySurface[surfaceId] = blend; + return blend; + } + + private RetailParticleGeometryKind ResolveGeometryKind(uint gfxObjId) + { + if (_geometryKindByGfxObj.TryGetValue(gfxObjId, out RetailParticleGeometryKind kind)) + return kind; + + uint? firstDegradeMode = null; + try + { + if (_dats?.Get(gfxObjId) is { } gfx + && gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) + && gfx.DIDDegrade != 0 + && _dats.Get(gfx.DIDDegrade) is { Degrades.Count: > 0 } degrade) + { + firstDegradeMode = degrade.Degrades[0].DegradeMode; + } + } + catch (Exception ex) + { + // Missing/corrupt content must not invent a billboard. Cache the + // retail full-mesh choice and let WbMeshAdapter's normal missing- + // asset diagnostics decide whether geometry can be presented. + Console.Error.WriteLine( + $"[particle-geometry] Failed to decode GfxObj 0x{gfxObjId:X8} degrade metadata: {ex.Message}"); + } + + kind = RetailParticleGeometryClassifier.Classify(firstDegradeMode); + _geometryKindByGfxObj[gfxObjId] = kind; + return kind; + } + + private void OnEmitterDied(int handle) + => _meshReferences?.Release(handle); + private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc) { if (_textures is null) @@ -456,11 +827,17 @@ public sealed unsafe class ParticleRenderer : IDisposable public void Dispose() { + _particles.EmitterDied -= OnEmitterDied; + _meshReferences?.Dispose(); + _gl.DeleteBuffer(_quadVbo); _gl.DeleteBuffer(_quadEbo); _gl.DeleteBuffer(_instanceVbo); + _gl.DeleteBuffer(_meshInstanceVbo); _gl.DeleteVertexArray(_quadVao); + _gl.DeleteVertexArray(_meshVao); _shader.Dispose(); + _meshShader?.Dispose(); } private readonly record struct ParticleGfxInfo( diff --git a/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs b/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs new file mode 100644 index 00000000..06b8c584 --- /dev/null +++ b/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.Rendering; + +internal enum ParticleSubmissionKind +{ + Billboard, + Mesh, +} + +internal readonly record struct ParticleSubmission( + ParticleSubmissionKind Kind, + int DrawIndex, + float DistanceSq, + int Sequence); + +/// +/// Shared ordering for the two retail particle geometry paths. Transparent +/// particles are submitted back-to-front; creation/enumeration order is the +/// deterministic tiebreak so a PES chain does not shuffle at equal distance. +/// +internal static class ParticleSubmissionOrdering +{ + public static void Sort(List submissions) + { + ArgumentNullException.ThrowIfNull(submissions); + submissions.Sort(static (left, right) => + { + int distance = right.DistanceSq.CompareTo(left.DistanceSq); + return distance != 0 + ? distance + : left.Sequence.CompareTo(right.Sequence); + }); + } +} + +/// +/// Balances the modern mesh pipeline's reference count against one stable +/// particle-emitter handle. Registration and teardown are idempotent because +/// the renderer sees every live particle, not merely every live emitter. +/// +internal sealed class ParticleMeshReferenceTracker : IDisposable +{ + private readonly Action _increment; + private readonly Action _decrement; + private readonly Dictionary _gfxByEmitter = new(); + private bool _disposed; + + public ParticleMeshReferenceTracker(Action increment, Action decrement) + { + _increment = increment ?? throw new ArgumentNullException(nameof(increment)); + _decrement = decrement ?? throw new ArgumentNullException(nameof(decrement)); + } + + public void Register(int emitterHandle, uint gfxObjId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_gfxByEmitter.ContainsKey(emitterHandle)) + return; + + _gfxByEmitter.Add(emitterHandle, gfxObjId); + _increment(gfxObjId); + } + + public void Release(int emitterHandle) + { + if (_disposed || !_gfxByEmitter.Remove(emitterHandle, out uint gfxObjId)) + return; + _decrement(gfxObjId); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (uint gfxObjId in _gfxByEmitter.Values) + _decrement(gfxObjId); + _gfxByEmitter.Clear(); + } +} diff --git a/src/AcDream.App/Rendering/RetailParticleGeometryClassifier.cs b/src/AcDream.App/Rendering/RetailParticleGeometryClassifier.cs new file mode 100644 index 00000000..2d221c27 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailParticleGeometryClassifier.cs @@ -0,0 +1,60 @@ +using System; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Retail particle geometry selection from CPhysicsPart::Always2D +/// (named retail 0x0050D8A0). A GfxObj with no degrade entry, or whose first +/// degrade has mode 1, is drawn as its authored 3D mesh. Every other first +/// degrade mode uses the camera-facing 2D presentation. +/// +public static class RetailParticleGeometryClassifier +{ + public static RetailParticleGeometryKind Classify(uint? firstDegradeMode) + => firstDegradeMode is uint mode && mode != 1u + ? RetailParticleGeometryKind.Billboard + : RetailParticleGeometryKind.FullMesh; +} + +public enum RetailParticleGeometryKind +{ + FullMesh, + Billboard, +} + +/// +/// Resolves authored particle-surface blending without allowing one malformed +/// Surface entry to abort the render frame. The fallback remains the batch's +/// already-decoded additive bit; it never fabricates geometry or a material. +/// +internal static class RetailParticleBlendResolver +{ + public static TranslucencyKind Resolve( + uint surfaceId, + bool batchIsAdditive, + Func? loadSurface, + Action? diagnostic = null) + { + TranslucencyKind fallback = batchIsAdditive + ? TranslucencyKind.Additive + : TranslucencyKind.AlphaBlend; + if (surfaceId == 0 || loadSurface is null) + return fallback; + + try + { + Surface? surface = loadSurface(surfaceId); + return surface is null + ? fallback + : TranslucencyKindExtensions.FromSurfaceType(surface.Type); + } + catch (Exception ex) + { + diagnostic?.Invoke( + $"[particle-material] Failed to decode Surface 0x{surfaceId:X8}: {ex.Message}"); + return fallback; + } + } +} diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.frag b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag new file mode 100644 index 00000000..7ed4fb24 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag @@ -0,0 +1,17 @@ +#version 430 core +#extension GL_ARB_bindless_texture : require + +in vec2 vTexCoord; +in vec4 vColor; +out vec4 fragColor; + +uniform uvec2 uTextureHandle; +uniform int uTextureLayer; + +void main() { + sampler2DArray tex = sampler2DArray(uTextureHandle); + vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor; + if (color.a < 0.02) + discard; + fragColor = color; +} diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert new file mode 100644 index 00000000..812b2af8 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert @@ -0,0 +1,18 @@ +#version 430 core + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; +layout(location = 3) in mat4 aModel; +layout(location = 7) in vec4 aColor; + +uniform mat4 uViewProjection; + +out vec2 vTexCoord; +out vec4 vColor; + +void main() { + vTexCoord = aTexCoord; + vColor = aColor; + gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0); +} diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 1437f0f9..211dba77 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -46,7 +46,7 @@ public static class FixtureProvider /// . /// The live DAT collection used to resolve per-list empty-slot sprites /// (same lookup GameWindow.OnLoad performs for the production binding). - public static void Populate( + public static IRetainedPanelController? Populate( uint layoutId, ImportedLayout layout, RenderStack stack, @@ -63,10 +63,10 @@ public static class FixtureProvider healthText: () => "80/100", staminaText: () => "60/100", manaText: () => "90/100"); - break; + return null; case RadarController.LayoutId: // gmRadarUI - RadarController.Bind( + return RadarController.Bind( layout, () => new UiRadarSnapshot( PlayerHeadingDegrees: 32f, @@ -88,7 +88,6 @@ public static class FixtureProvider ], CoordinatesText: "42.1N,33.3E"), datFont: stack.VitalsDatFont); - break; case CombatUiController.LayoutId: { @@ -99,7 +98,7 @@ public static class FixtureProvider sendAttack: (_, _) => true, autoRepeatAttack: () => false); GameplaySettings gameplay = GameplaySettings.Default; - CombatUiController.Bind( + CombatUiController? controller = CombatUiController.Bind( layout, combat, attacks, @@ -110,11 +109,11 @@ public static class FixtureProvider "High", "Medium", "Low"), visible => layout.Root.Visible = visible); combat.SetCombatMode(CombatMode.Melee); - break; + return controller; } case 0x21000016u: // toolbar - ToolbarController.Bind( + return ToolbarController.Bind( layout, objects, shortcuts: () => System.Array.Empty(), @@ -126,7 +125,6 @@ public static class FixtureProvider emptyDigits: null, sendAddShortcut: null, sendRemoveShortcut: null); - break; case 0x21000023u: // inventory + paperdoll { @@ -140,8 +138,15 @@ public static class FixtureProvider var iconIds = MakeIconIds(stack); var selection = new AcDream.Core.Selection.SelectionState(); + var itemInteraction = new ItemInteractionController( + objects, + () => SampleData.PlayerGuid, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null); - InventoryController.Bind( + InventoryController inventory = InventoryController.Bind( layout, objects, playerGuid: () => SampleData.PlayerGuid, @@ -154,17 +159,18 @@ public static class FixtureProvider mainPackEmptySprite: mainPackEmpty); // Bind the paperdoll equip slots with their authored per-location UIItem prototypes. - PaperdollController.Bind( + PaperdollController paperdoll = PaperdollController.Bind( layout, objects, playerGuid: () => SampleData.PlayerGuid, iconIds: iconIds, selection: selection, - sendWield: null, // no live session in the studio + itemInteraction: itemInteraction, emptySlotSprite: contentsEmpty, datFont: stack.VitalsDatFont, - emptySlotSprites: paperdollEmpty); - break; + emptySlotSprites: paperdollEmpty, + ownsItemInteraction: true); + return new RetainedPanelControllerGroup(inventory, paperdoll); } case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E) @@ -179,11 +185,11 @@ public static class FixtureProvider datFont: stack.VitalsDatFont, rowDatFont: stack.LargeDatFont ?? stack.VitalsDatFont, spriteResolve: stack.ResolveChrome); - break; + return null; default: // Unknown layout — no-op; the panel renders structurally. - break; + return null; } } diff --git a/src/AcDream.App/Studio/MockupDesktop.cs b/src/AcDream.App/Studio/MockupDesktop.cs index b25a051a..59e4c72f 100644 --- a/src/AcDream.App/Studio/MockupDesktop.cs +++ b/src/AcDream.App/Studio/MockupDesktop.cs @@ -45,7 +45,8 @@ internal static class MockupDesktop var layout = Import(dats, 0x2100006Cu, stack); if (layout is null) return null; - FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats); + IRetainedPanelController? controller = + FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats); return RetailWindowFrame.Mount( stack.UiHost.Root, @@ -61,6 +62,7 @@ internal static class MockupDesktop ResizeY = false, MinWidth = 40f, ContentClickThrough = false, + Controller = controller, }); } @@ -69,7 +71,8 @@ internal static class MockupDesktop var layout = Import(dats, 0x21000016u, stack); if (layout is null) return null; - FixtureProvider.Populate(0x21000016u, layout, stack, objects, dats); + IRetainedPanelController? controller = + FixtureProvider.Populate(0x21000016u, layout, stack, objects, dats); const int border = RetailChromeSprites.Border; var toolbarRoot = layout.Root; @@ -93,6 +96,7 @@ internal static class MockupDesktop ResizableEdges = ResizeEdges.Bottom, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, ContentClickThrough = true, + Controller = controller, }); var frame = (UiCollapsibleFrame)handle.OuterFrame; @@ -137,7 +141,8 @@ internal static class MockupDesktop var layout = Import(dats, 0x2100002Eu, stack); if (layout is null) return null; - FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats); + IRetainedPanelController? controller = + FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats); return RetailWindowFrame.Mount( stack.UiHost.Root, @@ -155,6 +160,7 @@ internal static class MockupDesktop ResizableEdges = ResizeEdges.Bottom, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, ContentClickThrough = false, + Controller = controller, }); } @@ -163,7 +169,8 @@ internal static class MockupDesktop var layout = Import(dats, 0x21000023u, stack); if (layout is null) return null; - FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats); + IRetainedPanelController? controller = + FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats); var inventoryRoot = layout.Root; float contentW = inventoryRoot.Width; @@ -187,6 +194,7 @@ internal static class MockupDesktop contentH + 2f * RetailChromeSprites.Border, stack.UiHost.Root.Height - 18f), ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + Controller = controller, }); StretchV(layout, 0x100001D0u); diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs index febfae46..a88d463a 100644 --- a/src/AcDream.App/Studio/StudioWindow.cs +++ b/src/AcDream.App/Studio/StudioWindow.cs @@ -57,6 +57,7 @@ public sealed class StudioWindow : IDisposable // Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime // so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly. private AcDream.Core.Items.ClientObjectTable? _objects; + private IRetainedPanelController? _fixtureController; // Headless screenshot mode: set when --screenshot was passed. // True after the first OnRender fires the screenshot (guard against repeat). @@ -184,7 +185,8 @@ public sealed class StudioWindow : IDisposable { uint layoutId = _opts.LayoutId ?? 0x2100006Cu; _objects = SampleData.BuildObjectTable(); - FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects, _dats); + _fixtureController = FixtureProvider.Populate( + layoutId, _source.CurrentLayout, _stack, _objects, _dats); } } @@ -431,7 +433,10 @@ public sealed class StudioWindow : IDisposable return; } - // Remove the existing panel root from the tree. + // Remove the existing panel root from the tree. Fixture controllers + // own subscriptions into the sample table and end with their panel. + _fixtureController?.Dispose(); + _fixtureController = null; if (_panelRoot is not null) { _stack.UiHost.Root.RemoveChild(_panelRoot); @@ -467,6 +472,8 @@ public sealed class StudioWindow : IDisposable private void OnClosing() { + _fixtureController?.Dispose(); + _fixtureController = null; _imgui?.Dispose(); _panelFbo?.Dispose(); _imgui = null; @@ -479,6 +486,8 @@ public sealed class StudioWindow : IDisposable public void Dispose() { + _fixtureController?.Dispose(); + _fixtureController = null; _imgui?.Dispose(); _panelFbo?.Dispose(); _imgui = null; diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index 0eabb5a7..fc267e70 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -62,6 +62,7 @@ internal sealed class AutoWieldController : IDisposable private readonly Action? _sendWield; private readonly Action? _sendPutItemInContainer; private readonly Action? _toast; + private readonly Action? _systemMessage; private PendingSwitch? _pendingSwitch; private bool _disposed; @@ -73,13 +74,15 @@ internal sealed class AutoWieldController : IDisposable Func playerGuid, Action? sendWield, Action? sendPutItemInContainer, - Action? toast) + Action? toast, + Action? systemMessage = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _sendWield = sendWield; _sendPutItemInContainer = sendPutItemInContainer; _toast = toast; + _systemMessage = systemMessage; _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectRemoved; @@ -94,22 +97,53 @@ internal sealed class AutoWieldController : IDisposable /// as retail's inventory-ready gate prevents a second concurrent request. /// public bool TryWield(ClientObject item) + => TryWield(item, EquipMask.None); + + /// + /// Execute AutoWield while retaining the paperdoll's resolved target side. + /// Retail gmPaperDollUI::AcceptDragObject @ 0x004A3B10 supplies this + /// location to CPlayerSystem::AutoWield @ 0x00560A60 rather than + /// bypassing the transaction with a direct wire request. + /// + public bool TryWield(ClientObject item, EquipMask requestedMask) { ArgumentNullException.ThrowIfNull(item); if (_pendingSwitch is not null) return true; - if (item.ValidLocations == EquipMask.None - || item.CurrentlyEquippedLocation != EquipMask.None) + if (item.ValidLocations == EquipMask.None) + return false; + if (requestedMask != EquipMask.None + && (requestedMask & item.ValidLocations) != requestedMask) return false; - EquipMask weaponLocation = item.ValidLocations & WeaponReadyMask; + // gmPaperDollUI::AcceptDragObject can ask AutoWield to move an item + // that is already worn (the classic left-ring -> right-ring drop). + // It is not a blocker transaction: the object itself is the requested + // item, so preserve the paperdoll's explicit destination and let the + // ordinary GetAndWieldItem acknowledgement relocate it. + if (item.CurrentlyEquippedLocation != EquipMask.None) + { + if (requestedMask == EquipMask.None + || requestedMask == item.CurrentlyEquippedLocation) + return false; + if (GetEquippedObjectAtLocation( + requestedMask, priority: 0, item.ObjectId) is { } blocker) + { + return BeginWeaponReplacement(item.ObjectId, blocker, requestedMask); + } + return SendWield(item, requestedMask); + } + + EquipMask weaponLocation = requestedMask == EquipMask.None + ? item.ValidLocations & WeaponReadyMask + : requestedMask & WeaponReadyMask; if (weaponLocation != EquipMask.None) { ClientObject? blocker = GetEquippedObjectAtLocation( WeaponReadyMask, priority: 0, item.ObjectId); if (blocker is not null) - return BeginWeaponReplacement(item.ObjectId, blocker.ObjectId); + return BeginWeaponReplacement(item.ObjectId, blocker, weaponLocation); // Retail checks secondary blockers only after the primary weapon // slot is vacant, then re-enters AutoWield after each confirmed @@ -117,18 +151,20 @@ internal sealed class AutoWieldController : IDisposable if (BlocksUseOfShield(item) && GetEquippedObjectAtLocation( EquipMask.Shield, priority: 0, item.ObjectId) is { } shield) - return BeginWeaponReplacement(item.ObjectId, shield.ObjectId); + return BeginWeaponReplacement(item.ObjectId, shield, weaponLocation); if (item.AmmoType is > 0 && GetEquippedObjectAtLocation( EquipMask.MissileAmmo, priority: 0, item.ObjectId) is { } ammo && ammo.AmmoType != item.AmmoType) - return BeginWeaponReplacement(item.ObjectId, ammo.ObjectId); + return BeginWeaponReplacement(item.ObjectId, ammo, weaponLocation); return SendWield(item, weaponLocation); } - EquipMask mask = BestAvailableEquipMask(item); + EquipMask mask = requestedMask != EquipMask.None + ? requestedMask + : BestAvailableEquipMask(item); if (mask == EquipMask.None) { _toast?.Invoke("That slot is already in use"); @@ -138,7 +174,10 @@ internal sealed class AutoWieldController : IDisposable return SendWield(item, mask); } - private bool BeginWeaponReplacement(uint requestedItemId, uint blockingItemId) + private bool BeginWeaponReplacement( + uint requestedItemId, + ClientObject blockingItem, + EquipMask requestedMask) { if (_sendPutItemInContainer is null) { @@ -150,12 +189,17 @@ internal sealed class AutoWieldController : IDisposable if (player == 0) return false; - _pendingSwitch = new PendingSwitch(requestedItemId, blockingItemId); + _pendingSwitch = new PendingSwitch( + requestedItemId, + blockingItem.ObjectId, + requestedMask); // Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0). // Do not change the local equip projection yet: the server's move event // is the transaction boundary and preserves its stance-specific motion. - _sendPutItemInContainer(blockingItemId, player, 0); + _systemMessage?.Invoke( + $"Moving {blockingItem.GetAppropriateName()} to your backpack"); + _sendPutItemInContainer(blockingItem.ObjectId, player, 0); return true; } @@ -163,7 +207,10 @@ internal sealed class AutoWieldController : IDisposable { if (_sendWield is null) return false; - _pendingSwitch = new PendingSwitch(item.ObjectId, BlockingItemId: 0); + _pendingSwitch = new PendingSwitch( + item.ObjectId, + BlockingItemId: 0, + RequestedMask: mask); if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) { _pendingSwitch = null; @@ -188,7 +235,7 @@ internal sealed class AutoWieldController : IDisposable // vacant ready slot and send GetAndWieldItem for the requested weapon. _pendingSwitch = null; if (_objects.Get(pending.RequestedItemId) is { } requested) - TryWield(requested); + TryWield(requested, pending.RequestedMask); } private void OnWieldConfirmed(ClientObject item) @@ -325,5 +372,6 @@ internal sealed class AutoWieldController : IDisposable private readonly record struct PendingSwitch( uint RequestedItemId, - uint BlockingItemId); + uint BlockingItemId, + EquipMask RequestedMask); } diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 1993fc25..10e0074a 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -44,6 +44,7 @@ public sealed class ItemInteractionController : IDisposable private readonly Func _selectedObjectId; private readonly StackSplitQuantityState? _stackSplitQuantity; private readonly Func _dragOnPlayerOpensSecureTrade; + private readonly Action? _systemMessage; private readonly AutoWieldController _autoWield; private long _lastUseMs = long.MinValue / 2; @@ -76,7 +77,8 @@ public sealed class ItemInteractionController : IDisposable StackSplitQuantityState? stackSplitQuantity = null, Action? sendPutItemInContainer = null, Action? sendGive = null, - Func? dragOnPlayerOpensSecureTrade = null) + Func? dragOnPlayerOpensSecureTrade = null, + Action? systemMessage = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -100,6 +102,7 @@ public sealed class ItemInteractionController : IDisposable _selectedObjectId = selectedObjectId ?? (() => 0u); _stackSplitQuantity = stackSplitQuantity; _dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true); + _systemMessage = systemMessage; _interactionState = interactionState ?? new InteractionState(); _interactionState.Changed += OnInteractionModeChanged; _autoWield = new AutoWieldController( @@ -107,7 +110,8 @@ public sealed class ItemInteractionController : IDisposable _playerGuid, _sendWield, sendPutItemInContainer, - _toast); + _toast, + _systemMessage); } public event Action? StateChanged; @@ -264,6 +268,20 @@ public sealed class ItemInteractionController : IDisposable return ExecuteUseActions(decision.Actions); } + /// + /// Retail paperdoll drop entry point. The paperdoll resolves the exact + /// target side/location; this shared interaction owner performs AutoWield's + /// confirmed blocker transaction before sending GetAndWieldItem. + /// + public bool WieldFromPaperdoll(uint itemGuid, EquipMask targetMask) + { + if (itemGuid == 0u || targetMask == EquipMask.None) + return false; + if (_objects.Get(itemGuid) is not { } item) + return false; + return _autoWield.TryWield(item, targetMask); + } + public bool AcquireTarget(uint targetGuid) { if (!IsTargetModeActive || targetGuid == 0) return false; diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index bab17b87..83f2daa1 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -48,8 +48,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo private readonly Func _playerGuid; private readonly Func _iconIds; private readonly Func? _dragIconIds; - private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A - private readonly ItemInteractionController? _itemInteraction; + private readonly ItemInteractionController _itemInteraction; + private readonly bool _ownsItemInteraction; private readonly SelectionState _selection; private readonly PaperdollClickMap? _clickMap; private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); @@ -65,15 +65,17 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo private PaperdollController( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, Func iconIds, SelectionState selection, - Action? sendWield, - uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction, + ItemInteractionController itemInteraction, + uint emptySlotSprite, UiDatFont? datFont, PaperdollClickMap? clickMap, Func? dragIconIds, - IReadOnlyDictionary? emptySlotSprites) + IReadOnlyDictionary? emptySlotSprites, + bool ownsItemInteraction) { - _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; + _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _dragIconIds = dragIconIds; - _itemInteraction = itemInteraction; + _itemInteraction = itemInteraction ?? throw new ArgumentNullException(nameof(itemInteraction)); + _ownsItemInteraction = ownsItemInteraction; _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _clickMap = clickMap; @@ -172,15 +174,15 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo public static PaperdollController Bind( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, Func iconIds, SelectionState selection, - Action? sendWield = null, + ItemInteractionController itemInteraction, uint emptySlotSprite = 0u, UiDatFont? datFont = null, - ItemInteractionController? itemInteraction = null, PaperdollClickMap? clickMap = null, Func? dragIconIds = null, - IReadOnlyDictionary? emptySlotSprites = null) + IReadOnlyDictionary? emptySlotSprites = null, + bool ownsItemInteraction = false) => new PaperdollController( - layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite, - datFont, itemInteraction, clickMap, dragIconIds, emptySlotSprites); + layout, objects, playerGuid, iconIds, selection, itemInteraction, emptySlotSprite, + datFont, clickMap, dragIconIds, emptySlotSprites, ownsItemInteraction); private void HandleDollClick(int x, int y) { @@ -311,9 +313,10 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo : ItemDragAcceptance.Reject; } - /// Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A. + /// Wield the dropped item through the shared retail AutoWield transaction. /// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the - /// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem. + /// chosen finger). Retail: gmPaperDollUI::HandleDropRelease @ 0x004A4D80 to + /// AcceptDragObject @ 0x004A3B10 to CPlayerSystem::AutoWield @ 0x00560A60. public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { // gmPaperDollUI::HandleDropRelease @ 0x004A4D80 applies the same flag @@ -324,8 +327,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo if (item is null) return; EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList)); if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected) - _objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask); - _sendWield?.Invoke(payload.ObjId, (uint)wieldMask); + _itemInteraction.WieldFromPaperdoll(payload.ObjId, wieldMask); } /// @@ -362,5 +364,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo element.OnClickAt = null; break; } + if (_ownsItemInteraction) + _itemInteraction.Dispose(); } } diff --git a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs index 839ab8f4..9ad215ee 100644 --- a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs +++ b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs @@ -19,9 +19,9 @@ public sealed class RadarSnapshotProvider private static readonly Vector2 ProductionCenter = new(60f, 60f); private readonly ClientObjectTable _objects; - private readonly IReadOnlyDictionary _worldEntities; - private readonly IReadOnlyDictionary _playerEntities; - private readonly IReadOnlyDictionary _spawns; + private readonly Func> _worldEntities; + private readonly Func> _playerEntities; + private readonly Func> _spawns; private readonly Func _playerGuid; private readonly Func _playerYawRadians; private readonly Func _playerCellId; @@ -32,8 +32,8 @@ public sealed class RadarSnapshotProvider public RadarSnapshotProvider( ClientObjectTable objects, - IReadOnlyDictionary worldEntities, - IReadOnlyDictionary spawns, + Func> worldEntities, + Func> spawns, Func playerGuid, Func playerYawRadians, Func playerCellId, @@ -41,7 +41,7 @@ public sealed class RadarSnapshotProvider Func coordinatesOnRadar, Func uiLocked, Func? relationshipFor = null, - IReadOnlyDictionary? playerEntities = null) + Func>? playerEntities = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities)); @@ -58,9 +58,17 @@ public sealed class RadarSnapshotProvider public UiRadarSnapshot BuildSnapshot() { + // The retained UI mounts before LiveEntityRuntime during GameWindow + // startup. Resolve these canonical projections per snapshot so the + // provider observes that runtime once it is installed, rather than + // retaining the bootstrap empty-map sentinels forever. + IReadOnlyDictionary worldEntities = _worldEntities(); + IReadOnlyDictionary playerEntities = _playerEntities(); + IReadOnlyDictionary spawns = _spawns(); + bool uiLocked = _uiLocked(); uint playerGuid = _playerGuid(); - if (playerGuid == 0u || !_playerEntities.TryGetValue(playerGuid, out var playerEntity)) + if (playerGuid == 0u || !playerEntities.TryGetValue(playerGuid, out var playerEntity)) return UiRadarSnapshot.Empty with { UiLocked = uiLocked }; float heading = MoveToMath.HeadingFromYaw(_playerYawRadians()); @@ -70,15 +78,15 @@ public sealed class RadarSnapshotProvider ? playerCoordinates.CombinedText : null; - uint playerPwd = _spawns.TryGetValue(playerGuid, out var playerSpawn) + uint playerPwd = spawns.TryGetValue(playerGuid, out var playerSpawn) ? playerSpawn.ObjectDescriptionFlags ?? 0u : 0u; var playerTraits = RadarObjectTraits.FromPublicWeenieDescription( (uint)(_objects.Get(playerGuid)?.Type ?? ItemType.None), playerPwd); float range = RetailRadar.GetRangeMeters(isOutside); - var blips = new List(_worldEntities.Count); - foreach (var pair in _worldEntities) + var blips = new List(worldEntities.Count); + foreach (var pair in worldEntities) { uint guid = pair.Key; if (guid == playerGuid) @@ -86,7 +94,7 @@ public sealed class RadarSnapshotProvider var entity = pair.Value; var clientObject = _objects.Get(guid); - _spawns.TryGetValue(guid, out var spawn); + spawns.TryGetValue(guid, out var spawn); byte? rawBehavior = clientObject?.RadarBehavior ?? spawn.RadarBehavior; if (rawBehavior is null diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 84e72f36..dee43b65 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -88,7 +88,6 @@ public sealed record InventoryRuntimeBindings( Action? SendPutItemInContainer, Action? SendStackableSplitToContainer, Action? SendStackableMerge, - Action? SendWield, ItemInteractionController ItemInteraction, SelectionState Selection); @@ -934,8 +933,8 @@ public sealed class RetailUiRuntime : IDisposable StackSplitQuantity, b.ResolveDragIcon); PaperdollController paperdoll = PaperdollController.Bind( - layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield, - contents, _bindings.Assets.DefaultFont, b.ItemInteraction, paperdollClickMap, + layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction, + contents, _bindings.Assets.DefaultFont, paperdollClickMap, b.ResolveDragIcon, paperdollEmptySprites); Host.WindowManager.AttachController( WindowNames.Inventory, diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index 9b8d3f3b..41e30b13 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -42,6 +42,7 @@ public sealed class ParticleSystem : IParticleSystem int handle = _nextHandle++; var emitter = new ParticleEmitter { + Handle = handle, Desc = desc, AnchorPos = anchor, AnchorRot = rot ?? Quaternion.Identity, diff --git a/src/AcDream.Core/Vfx/VfxModel.cs b/src/AcDream.Core/Vfx/VfxModel.cs index 73c97ae1..94f73754 100644 --- a/src/AcDream.Core/Vfx/VfxModel.cs +++ b/src/AcDream.Core/Vfx/VfxModel.cs @@ -176,6 +176,12 @@ public struct Particle /// public sealed class ParticleEmitter { + /// + /// Stable logical identity assigned by . + /// Render owners use it to keep DAT mesh references balanced with the + /// emitter's logical lifetime; it is not a GPU resource identifier. + /// + public int Handle { get; init; } public EmitterDesc Desc { get; init; } = null!; public Vector3 AnchorPos { get; set; } public Quaternion AnchorRot { get; set; } = Quaternion.Identity; diff --git a/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs b/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs new file mode 100644 index 00000000..a53af9e4 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs @@ -0,0 +1,175 @@ +using AcDream.App.Rendering; +using AcDream.Content.Vfx; +using AcDream.Core.Meshing; +using AcDream.Core.Vfx; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using Xunit.Sdk; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RetailParticleGeometryClassifierTests +{ + [Theory] + [InlineData(null, RetailParticleGeometryKind.FullMesh)] + [InlineData(1, RetailParticleGeometryKind.FullMesh)] + [InlineData(0, RetailParticleGeometryKind.Billboard)] + [InlineData(2, RetailParticleGeometryKind.Billboard)] + [InlineData(3, RetailParticleGeometryKind.Billboard)] + public void Classify_matchesRetailAlways2D( + int? firstDegradeMode, + RetailParticleGeometryKind expected) + { + Assert.Equal( + expected, + RetailParticleGeometryClassifier.Classify( + firstDegradeMode is int mode ? (uint)mode : null)); + } + + [Fact] + public void Classify_unknownUnsignedMode_isBillboardWithoutOverflow() + => Assert.Equal( + RetailParticleGeometryKind.Billboard, + RetailParticleGeometryClassifier.Classify(uint.MaxValue)); + + [Fact] + public void SubmissionOrdering_interleavesGeometryByDistanceAndPreservesEqualDistanceSequence() + { + var submissions = new List + { + new(ParticleSubmissionKind.Mesh, 0, 4f, 0), + new(ParticleSubmissionKind.Billboard, 0, 9f, 1), + new(ParticleSubmissionKind.Mesh, 1, 9f, 2), + new(ParticleSubmissionKind.Billboard, 1, 1f, 3), + }; + + ParticleSubmissionOrdering.Sort(submissions); + + Assert.Equal( + new[] + { + (ParticleSubmissionKind.Billboard, 1), + (ParticleSubmissionKind.Mesh, 2), + (ParticleSubmissionKind.Mesh, 0), + (ParticleSubmissionKind.Billboard, 3), + }, + submissions.Select(static item => (item.Kind, item.Sequence))); + } + + [Fact] + public void MeshReferenceTracker_balancesDuplicateReleaseAndDispose() + { + var increments = new List(); + var decrements = new List(); + var tracker = new ParticleMeshReferenceTracker(increments.Add, decrements.Add); + + tracker.Register(7, 0x01001666u); + tracker.Register(7, 0x01001666u); + tracker.Register(8, 0x01001667u); + tracker.Release(7); + tracker.Release(7); + tracker.Dispose(); + tracker.Dispose(); + + Assert.Equal(new[] { 0x01001666u, 0x01001667u }, increments); + Assert.Equal(new[] { 0x01001666u, 0x01001667u }, decrements); + } + + [Fact] + public void BlendResolver_corruptSurfaceFallsBackAndReportsItsDid() + { + var diagnostics = new List(); + + TranslucencyKind blend = RetailParticleBlendResolver.Resolve( + 0x08001234u, + batchIsAdditive: true, + _ => throw new InvalidDataException("bad surface"), + diagnostics.Add); + + Assert.Equal(TranslucencyKind.Additive, blend); + string diagnostic = Assert.Single(diagnostics); + Assert.Contains("0x08001234", diagnostic, StringComparison.Ordinal); + Assert.Contains("bad surface", diagnostic, StringComparison.Ordinal); + } + + [Fact] + public void InstalledArmorSelfShieldPieces_areModeOneMeshesWithAuthoredApexFaces() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + throw SkipException.ForSkip( + "Installed client_portal.dat is required for the Armor Self asset-chain gate."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var tableResolver = new PhysicsScriptTableResolver( + id => dats.Get(id)); + uint? scriptDid = tableResolver.Resolve( + 0x34000004u, + rawScriptType: 0x37u, // PS_ShieldUpGrey / TargetEffect 55 + intensity: 1f); + Assert.Equal(0x3300068Fu, scriptDid); + + var scriptLoader = new RetailPhysicsScriptLoader(dats); + DatReaderWriter.DBObjs.PhysicsScript script = Assert.IsType( + scriptLoader.LoadPhysicsScript(scriptDid!.Value)); + uint[] emitterIds = script.ScriptData + .Select(static item => item.Hook) + .OfType() + .Select(static hook => hook.EmitterInfoId.DataId) + .ToArray(); + Assert.Equal( + new[] { 0x32000350u, 0x32000351u, 0x32000355u, 0x32000356u, 0x32000379u }, + emitterIds); + + var emitters = new EmitterDescRegistry(dats); + uint[] greyShieldPieces = emitterIds + .Select(id => emitters.Get(id).HwGfxObjId) + .Take(4) + .ToArray(); + Assert.Equal( + new[] { 0x01001666u, 0x01001667u, 0x01001668u, 0x01001669u }, + greyShieldPieces); + Assert.Equal(0x01001098u, emitters.Get(emitterIds[4]).HwGfxObjId); + + foreach (uint gfxObjId in greyShieldPieces) + { + GfxObj gfx = Assert.IsType(dats.Get(gfxObjId)); + Assert.Equal(5, gfx.VertexArray.Vertices.Count); + Assert.True(gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)); + + GfxObjDegradeInfo degrade = Assert.IsType( + dats.Get(gfx.DIDDegrade)); + Assert.NotEmpty(degrade.Degrades); + Assert.Equal(1u, degrade.Degrades[0].DegradeMode); + + var apex = gfx.VertexArray.Vertices.MaxBy(static pair => pair.Value.Origin.Z); + Assert.InRange(apex.Value.Origin.Z, 2.31f, 2.33f); + Assert.Contains( + gfx.Polygons.Values, + polygon => polygon.VertexIds.Any(id => Convert.ToUInt16(id) == apex.Key)); + } + } + + private static string? ResolveDatDir() + { + string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(configured) + && File.Exists(Path.Combine(configured, "client_portal.dat"))) + return configured; + + const string installed = @"C:\Turbine\Asheron's Call"; + if (File.Exists(Path.Combine(installed, "client_portal.dat"))) + return installed; + + string conventional = Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return File.Exists(Path.Combine(conventional, "client_portal.dat")) + ? conventional + : null; + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index e3d2cb10..ebaf9b35 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -23,6 +23,7 @@ public sealed class ItemInteractionControllerTests public readonly List<(uint Item, uint Amount)> SplitDrops = new(); public readonly List<(uint Target, uint Item, uint Amount)> Gives = new(); public readonly List Toasts = new(); + public readonly List SystemMessages = new(); public readonly StackSplitQuantityState SplitQuantity = new(); public uint SelectedObject; public bool NonCombatMode; @@ -63,7 +64,8 @@ public sealed class ItemInteractionControllerTests sendPutItemInContainer: (item, container, placement) => Puts.Add((item, container, placement)), sendGive: (target, item, amount) => Gives.Add((target, item, amount)), - dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade); + dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade, + systemMessage: SystemMessages.Add); } public ItemInteractionController Controller { get; } @@ -661,6 +663,209 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); } + [Fact] + public void PaperdollWeaponSwitch_toSword_keepsEquippedArrows() + { + var h = new Harness(); + const uint bow = 0x50000B51u; + const uint arrows = 0x50000B52u; + const uint sword = 0x50000B53u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = bow, + Name = "Shortbow", + Type = ItemType.MissileWeapon, + CombatUse = 2, + AmmoType = 1, + ValidLocations = EquipMask.MissileWeapon, + }); + h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = arrows, + Name = "Arrows", + Type = ItemType.MissileWeapon, + CombatUse = 3, + AmmoType = 1, + ValidLocations = EquipMask.MissileAmmo, + }); + h.Objects.MoveItem(arrows, Player, -1, EquipMask.MissileAmmo); + h.AddContained(sword, item => + { + item.Name = "Katar"; + item.Type = ItemType.MeleeWeapon; + item.CombatUse = 1; + item.ValidLocations = EquipMask.MeleeWeapon; + }); + + Assert.True(h.Controller.WieldFromPaperdoll(sword, EquipMask.MeleeWeapon)); + Assert.Equal(new[] { (bow, Player, 0) }, h.Puts); + Assert.Empty(h.Wields); + Assert.Equal(new[] { "Moving Shortbow to your backpack" }, h.SystemMessages); + + h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + + Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields); + Assert.Equal(EquipMask.MissileAmmo, + h.Objects.Get(arrows)!.CurrentlyEquippedLocation); + } + + [Fact] + public void PaperdollWeaponSwitch_fromWandToSword_movesHeldCasterFirst() + { + var h = new Harness(); + const uint wand = 0x50000B54u; + const uint sword = 0x50000B55u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = wand, + Name = "Wand", + Type = ItemType.Caster, + ValidLocations = EquipMask.Held, + }); + h.Objects.MoveItem(wand, Player, -1, EquipMask.Held); + h.AddContained(sword, item => + { + item.Name = "Katar"; + item.Type = ItemType.MeleeWeapon; + item.CombatUse = 1; + item.ValidLocations = EquipMask.MeleeWeapon; + }); + + Assert.True(h.Controller.WieldFromPaperdoll(sword, EquipMask.MeleeWeapon)); + Assert.Equal(new[] { (wand, Player, 0) }, h.Puts); + Assert.Empty(h.Wields); + Assert.Equal(new[] { "Moving Wand to your backpack" }, h.SystemMessages); + + h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + + Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields); + } + + [Fact] + public void PaperdollWeaponSwitch_toCrossbow_movesIncompatibleArrowsAfterBow() + { + var h = new Harness(); + const uint bow = 0x50000B61u; + const uint arrows = 0x50000B62u; + const uint crossbow = 0x50000B63u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = bow, + Name = "Shortbow", + Type = ItemType.MissileWeapon, + CombatUse = 2, + AmmoType = 1, + ValidLocations = EquipMask.MissileWeapon, + }); + h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = arrows, + Name = "Arrows", + Type = ItemType.MissileWeapon, + CombatUse = 3, + AmmoType = 1, + ValidLocations = EquipMask.MissileAmmo, + }); + h.Objects.MoveItem(arrows, Player, -1, EquipMask.MissileAmmo); + h.AddContained(crossbow, item => + { + item.Name = "Heavy Crossbow"; + item.Type = ItemType.MissileWeapon; + item.CombatUse = 2; + item.AmmoType = 2; + item.ValidLocations = EquipMask.MissileWeapon; + }); + + Assert.True(h.Controller.WieldFromPaperdoll( + crossbow, EquipMask.MissileWeapon)); + Assert.Equal(new[] { (bow, Player, 0) }, h.Puts); + + h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + + Assert.Equal( + new[] { (bow, Player, 0), (arrows, Player, 0) }, + h.Puts); + Assert.Empty(h.Wields); + + h.Objects.MoveItem(arrows, Player, 0, EquipMask.None); + + Assert.Equal( + new[] { (crossbow, (uint)EquipMask.MissileWeapon) }, + h.Wields); + Assert.Equal( + new[] + { + "Moving Shortbow to your backpack", + "Moving Arrows to your backpack", + }, + h.SystemMessages); + } + + [Fact] + public void PaperdollDrop_relocatesAlreadyEquippedItemToExplicitCompatibleSlot() + { + var h = new Harness(); + const uint ring = 0x50000B71u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = ring, + Name = "Ring", + Type = ItemType.Jewelry, + ValidLocations = EquipMask.FingerWearLeft | EquipMask.FingerWearRight, + }); + h.Objects.MoveItem(ring, Player, -1, EquipMask.FingerWearLeft); + + Assert.True(h.Controller.WieldFromPaperdoll(ring, EquipMask.FingerWearRight)); + + Assert.Equal( + new[] { (ring, (uint)EquipMask.FingerWearRight) }, + h.Wields); + Assert.Equal( + EquipMask.FingerWearRight, + h.Objects.Get(ring)!.CurrentlyEquippedLocation); + } + + [Fact] + public void PaperdollDrop_movesExplicitDestinationBlockerBeforeRelocatingEquippedItem() + { + var h = new Harness(); + const uint leftRing = 0x50000B72u; + const uint rightRing = 0x50000B73u; + EquipMask valid = EquipMask.FingerWearLeft | EquipMask.FingerWearRight; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = leftRing, + Name = "Left Ring", + Type = ItemType.Jewelry, + ValidLocations = valid, + }); + h.Objects.MoveItem(leftRing, Player, -1, EquipMask.FingerWearLeft); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = rightRing, + Name = "Right Ring", + Type = ItemType.Jewelry, + ValidLocations = valid, + }); + h.Objects.MoveItem(rightRing, Player, -1, EquipMask.FingerWearRight); + + Assert.True(h.Controller.WieldFromPaperdoll(leftRing, EquipMask.FingerWearRight)); + Assert.Equal(new[] { (rightRing, Player, 0) }, h.Puts); + Assert.Empty(h.Wields); + Assert.Equal(new[] { "Moving Right Ring to your backpack" }, h.SystemMessages); + + h.Objects.MoveItem(rightRing, Player, 0, EquipMask.None); + + Assert.Equal( + new[] { (leftRing, (uint)EquipMask.FingerWearRight) }, + h.Wields); + Assert.Equal( + EquipMask.FingerWearRight, + h.Objects.Get(leftRing)!.CurrentlyEquippedLocation); + } + [Fact] public void BlocksUseOfShield_matchesRetailCombatUseAndAmmoRules() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index c5afbbbe..d17f1f8d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -44,13 +44,28 @@ public class PaperdollControllerTests private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects, List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u, SelectionState? selection = null, - IReadOnlyDictionary? emptySlotSprites = null) - => PaperdollController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0x1234u, + IReadOnlyDictionary? emptySlotSprites = null, + List<(uint item, uint container, int placement)>? puts = null, + List? systemMessages = null) + { + var itemInteraction = new ItemInteractionController( + objects, + () => Player, + sendUse: null, + sendUseWithTarget: null, sendWield: wields is null ? null : (i, m) => wields.Add((i, m)), + sendDrop: null, + sendPutItemInContainer: puts is null + ? null + : (item, container, placement) => puts.Add((item, container, placement)), + systemMessage: systemMessages is null ? null : systemMessages.Add); + return PaperdollController.Bind(layout, objects, () => Player, + iconIds: (_, _, _, _, _) => 0x1234u, + itemInteraction: itemInteraction, emptySlotSprite: emptySlot, selection: selection ?? new SelectionState(), emptySlotSprites: emptySlotSprites); + } private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc) { @@ -225,6 +240,46 @@ public class PaperdollControllerTests Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire } + [Fact] + public void HandleDropRelease_weaponSlot_delegatesToConfirmedAutoWieldTransaction() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + const uint bow = 0xD11u; + const uint sword = 0xD12u; + SeedEquipped(objects, bow, EquipMask.MissileWeapon); + objects.Get(bow)!.Name = "Shortbow"; + SeedPackItem(objects, sword, EquipMask.MeleeWeapon); + objects.Get(sword)!.Name = "Katar"; + var wields = new List<(uint item, uint mask)>(); + var puts = new List<(uint item, uint container, int placement)>(); + var messages = new List(); + var ctrl = Bind( + layout, + objects, + wields, + puts: puts, + systemMessages: messages); + var payload = new ItemDragPayload( + sword, + ItemDragSource.Inventory, + 0, + lists[WeaponSlot].Cell); + + ctrl.HandleDropRelease(lists[WeaponSlot], lists[WeaponSlot].Cell, payload); + + Assert.Equal(new[] { (bow, Player, 0) }, puts); + Assert.Empty(wields); + Assert.Equal(EquipMask.None, objects.Get(sword)!.CurrentlyEquippedLocation); + Assert.Equal(new[] { "Moving Shortbow to your backpack" }, messages); + + objects.MoveItem(bow, Player, 0, EquipMask.None); + + Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields); + Assert.Equal(EquipMask.MeleeWeapon, + objects.Get(sword)!.CurrentlyEquippedLocation); + } + [Fact] public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring() { diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs index bc183a48..343705f9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Net; @@ -35,7 +36,7 @@ public sealed class RadarSnapshotProviderTests uint? selected = monster; var provider = new RadarSnapshotProvider( - objects, entities, spawns, + objects, () => entities, () => spawns, playerGuid: () => player, playerYawRadians: () => MathF.PI / 2f, // retail heading 0 degrees playerCellId: () => 0xA9B40001u, @@ -81,7 +82,7 @@ public sealed class RadarSnapshotProviderTests [hidden] = Spawn(hidden), }; var provider = new RadarSnapshotProvider( - objects, entities, spawns, + objects, () => entities, () => spawns, playerGuid: () => player, playerYawRadians: () => 0f, playerCellId: () => 0xA9B40100u, // EnvCell: no landscape coordinate @@ -120,15 +121,15 @@ public sealed class RadarSnapshotProviderTests }; var provider = new RadarSnapshotProvider( objects, - interactionVisible, - spawns, + () => interactionVisible, + () => spawns, playerGuid: () => player, playerYawRadians: () => 0f, playerCellId: () => 0xA9B40001u, selectedGuid: () => hiddenTarget, coordinatesOnRadar: () => true, uiLocked: () => false, - playerEntities: canonical); + playerEntities: () => canonical); var snapshot = provider.BuildSnapshot(); @@ -136,6 +137,56 @@ public sealed class RadarSnapshotProviderTests Assert.Empty(snapshot.Blips); } + [Fact] + public void BuildSnapshot_ResolvesLiveViewsAfterRuntimeBootstraps() + { + const uint player = 30u; + const uint monster = 31u; + var objects = new ClientObjectTable(); + objects.Ingest(Weenie(player, "Player", ItemType.Creature)); + objects.Ingest(Weenie(monster, "Drudge", ItemType.Creature) with + { + RadarBehavior = (byte)RadarBehavior.ShowAlways, + }); + + IReadOnlyDictionary visible = + new Dictionary(); + IReadOnlyDictionary canonical = + new Dictionary(); + IReadOnlyDictionary spawns = + new Dictionary(); + var provider = new RadarSnapshotProvider( + objects, + () => visible, + () => spawns, + playerGuid: () => player, + playerYawRadians: () => MathF.PI / 2f, + playerCellId: () => 0xA9B40001u, + selectedGuid: () => null, + coordinatesOnRadar: () => true, + uiLocked: () => false, + playerEntities: () => canonical); + + Assert.Null(provider.BuildSnapshot().CoordinatesText); + + visible = new Dictionary + { + [player] = Entity(player, Vector3.Zero, Quaternion.Identity), + [monster] = Entity(monster, new Vector3(0f, 15f, 0f), Quaternion.Identity), + }; + canonical = visible; + spawns = new Dictionary + { + [player] = Spawn(player), + [monster] = Spawn(monster) with { ObjectDescriptionFlags = 0x00000010u }, + }; + + UiRadarSnapshot snapshot = provider.BuildSnapshot(); + + Assert.NotNull(snapshot.CoordinatesText); + Assert.Equal(monster, Assert.Single(snapshot.Blips).ObjectId); + } + private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new() { Id = guid, diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 85621e6e..b9cd46aa 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -194,16 +194,26 @@ public sealed class RetailUiInteractionFlowTests public void BindPaperdoll( ItemInteractionController? itemInteraction = null, PaperdollClickMap? clickMap = null) - => PaperdollController.Bind( + { + itemInteraction ??= new ItemInteractionController( + Objects, + () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: (item, mask) => Wields.Add((item, mask)), + sendDrop: null, + sendPutItemInContainer: (item, container, placement) => + Puts.Add((item, container, placement))); + PaperdollController.Bind( Layout, Objects, playerGuid: () => Player, iconIds: static (_, _, _, _, _) => 0x1234u, selection: Selection, - sendWield: (item, mask) => Wields.Add((item, mask)), - emptySlotSprite: 0x06004D20u, itemInteraction: itemInteraction, + emptySlotSprite: 0x06004D20u, clickMap: clickMap ?? SolidClickMap(0x00, 0x00, 0xFF)); + } public static PaperdollClickMap SolidClickMap(byte r, byte g, byte b) { diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs index 3ae2c8f5..0e8b6953 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs @@ -75,9 +75,12 @@ public sealed class ParticleSystemTests public void SpawnEmitter_ReturnsPositiveHandle_AndTracksEmitter() { var sys = MakeSystem(); - int h = sys.SpawnEmitter(MakeDesc(), Vector3.Zero); + int h = sys.SpawnEmitter( + MakeInitialParticleDesc(ParticleType.Still, Vector3.Zero, Vector3.Zero, Vector3.Zero), + Vector3.Zero); Assert.True(h > 0); Assert.Equal(1, sys.ActiveEmitterCount); + Assert.Equal(h, Assert.Single(sys.EnumerateLive().ToList()).Emitter.Handle); } [Fact]