fix(ui): restore radar, retail wield switching, and protection meshes
Late-bind radar snapshots to the canonical LiveEntityRuntime maps so the retained radar survives bootstrap and session replacement instead of capturing empty sentinels. Route paperdoll drops through the retail AutoWield blocker transaction. Move conflicting held weapons, shields, explicit jewelry destinations, and mismatched ammo to the backpack one authoritative confirmation at a time; preserve compatible arrows when switching to melee. Render mode-1 and no-degrade particle GfxObjs as their authored modern-pipeline meshes, retain Always2D billboards, interleave both paths back-to-front, balance emitter mesh ownership, and fail safely on corrupt DAT material metadata. This restores the closed apex on Armor Self/protection effects. Retain Studio fixture controller lifetimes, add installed-DAT and adversarial regression coverage, synchronize retail research/divergence bookkeeping, and pass all three review tracks plus the full Release suite. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
8d63e5c28a
commit
b26f84cc69
26 changed files with 1361 additions and 141 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <blocker name> 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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
|
||||
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
|
||||
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
|
||||
private readonly ParticleMeshReferenceTracker? _meshReferences;
|
||||
private readonly HashSet<uint> _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<ParticleDraw> _drawListScratch = new(64);
|
||||
private readonly List<ParticleInstance> _runScratch = new(64);
|
||||
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
|
||||
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
|
||||
private readonly List<ParticleSubmission> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void BeginFrame()
|
||||
=> _meshLoadRequestedThisFrame.Clear();
|
||||
|
||||
public void Draw(
|
||||
ParticleSystem particles,
|
||||
ICamera camera,
|
||||
Vector3 cameraWorldPos,
|
||||
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
|
||||
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? 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<ParticleDraw> 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<ParticleInstance> 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<MeshParticleInstance> 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<Surface>(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<GfxObj>(gfxObjId) is { } gfx
|
||||
&& gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)
|
||||
&& gfx.DIDDegrade != 0
|
||||
&& _dats.Get<GfxObjDegradeInfo>(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(
|
||||
|
|
|
|||
82
src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs
Normal file
82
src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs
Normal file
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class ParticleSubmissionOrdering
|
||||
{
|
||||
public static void Sort(List<ParticleSubmission> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class ParticleMeshReferenceTracker : IDisposable
|
||||
{
|
||||
private readonly Action<uint> _increment;
|
||||
private readonly Action<uint> _decrement;
|
||||
private readonly Dictionary<int, uint> _gfxByEmitter = new();
|
||||
private bool _disposed;
|
||||
|
||||
public ParticleMeshReferenceTracker(Action<uint> increment, Action<uint> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using System;
|
||||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Retail particle geometry selection from <c>CPhysicsPart::Always2D</c>
|
||||
/// (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.
|
||||
/// </summary>
|
||||
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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class RetailParticleBlendResolver
|
||||
{
|
||||
public static TranslucencyKind Resolve(
|
||||
uint surfaceId,
|
||||
bool batchIsAdditive,
|
||||
Func<uint, Surface?>? loadSurface,
|
||||
Action<string>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/AcDream.App/Rendering/Shaders/particle_mesh.frag
Normal file
17
src/AcDream.App/Rendering/Shaders/particle_mesh.frag
Normal file
|
|
@ -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;
|
||||
}
|
||||
18
src/AcDream.App/Rendering/Shaders/particle_mesh.vert
Normal file
18
src/AcDream.App/Rendering/Shaders/particle_mesh.vert
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ public static class FixtureProvider
|
|||
/// <see cref="SampleData.BuildObjectTable"/>.</param>
|
||||
/// <param name="dats">The live DAT collection used to resolve per-list empty-slot sprites
|
||||
/// (same lookup GameWindow.OnLoad performs for the production binding).</param>
|
||||
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<ShortcutEntry>(),
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ internal sealed class AutoWieldController : IDisposable
|
|||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Action<string>? _systemMessage;
|
||||
|
||||
private PendingSwitch? _pendingSwitch;
|
||||
private bool _disposed;
|
||||
|
|
@ -73,13 +74,15 @@ internal sealed class AutoWieldController : IDisposable
|
|||
Func<uint> playerGuid,
|
||||
Action<uint, uint>? sendWield,
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
Action<string>? toast)
|
||||
Action<string>? toast,
|
||||
Action<string>? 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.
|
||||
/// </summary>
|
||||
public bool TryWield(ClientObject item)
|
||||
=> TryWield(item, EquipMask.None);
|
||||
|
||||
/// <summary>
|
||||
/// Execute AutoWield while retaining the paperdoll's resolved target side.
|
||||
/// Retail <c>gmPaperDollUI::AcceptDragObject @ 0x004A3B10</c> supplies this
|
||||
/// location to <c>CPlayerSystem::AutoWield @ 0x00560A60</c> rather than
|
||||
/// bypassing the transaction with a direct wire request.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Func<uint> _selectedObjectId;
|
||||
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
||||
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
|
||||
private readonly Action<string>? _systemMessage;
|
||||
private readonly AutoWieldController _autoWield;
|
||||
|
||||
private long _lastUseMs = long.MinValue / 2;
|
||||
|
|
@ -76,7 +77,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
StackSplitQuantityState? stackSplitQuantity = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
Action<uint, uint, uint>? sendGive = null,
|
||||
Func<bool>? dragOnPlayerOpensSecureTrade = null)
|
||||
Func<bool>? dragOnPlayerOpensSecureTrade = null,
|
||||
Action<string>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
|
||||
private readonly Action<uint, uint>? _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<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
|
||||
Action<uint, uint>? sendWield,
|
||||
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction,
|
||||
ItemInteractionController itemInteraction,
|
||||
uint emptySlotSprite, UiDatFont? datFont,
|
||||
PaperdollClickMap? clickMap,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds,
|
||||
IReadOnlyDictionary<uint, uint>? emptySlotSprites)
|
||||
IReadOnlyDictionary<uint, uint>? 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<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
|
||||
Action<uint, uint>? sendWield = null,
|
||||
ItemInteractionController itemInteraction,
|
||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
PaperdollClickMap? clickMap = null,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null,
|
||||
IReadOnlyDictionary<uint, uint>? emptySlotSprites = null)
|
||||
IReadOnlyDictionary<uint, uint>? 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;
|
||||
}
|
||||
|
||||
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
|
||||
/// <summary>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.</summary>
|
||||
/// chosen finger). Retail: gmPaperDollUI::HandleDropRelease @ 0x004A4D80 to
|
||||
/// AcceptDragObject @ 0x004A3B10 to CPlayerSystem::AutoWield @ 0x00560A60.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -362,5 +364,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
element.OnClickAt = null;
|
||||
break;
|
||||
}
|
||||
if (_ownsItemInteraction)
|
||||
_itemInteraction.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ public sealed class RadarSnapshotProvider
|
|||
private static readonly Vector2 ProductionCenter = new(60f, 60f);
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _playerEntities;
|
||||
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
|
||||
private readonly Func<IReadOnlyDictionary<uint, WorldEntity>> _worldEntities;
|
||||
private readonly Func<IReadOnlyDictionary<uint, WorldEntity>> _playerEntities;
|
||||
private readonly Func<IReadOnlyDictionary<uint, WorldSession.EntitySpawn>> _spawns;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<float> _playerYawRadians;
|
||||
private readonly Func<uint> _playerCellId;
|
||||
|
|
@ -32,8 +32,8 @@ public sealed class RadarSnapshotProvider
|
|||
|
||||
public RadarSnapshotProvider(
|
||||
ClientObjectTable objects,
|
||||
IReadOnlyDictionary<uint, WorldEntity> worldEntities,
|
||||
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> spawns,
|
||||
Func<IReadOnlyDictionary<uint, WorldEntity>> worldEntities,
|
||||
Func<IReadOnlyDictionary<uint, WorldSession.EntitySpawn>> spawns,
|
||||
Func<uint> playerGuid,
|
||||
Func<float> playerYawRadians,
|
||||
Func<uint> playerCellId,
|
||||
|
|
@ -41,7 +41,7 @@ public sealed class RadarSnapshotProvider
|
|||
Func<bool> coordinatesOnRadar,
|
||||
Func<bool> uiLocked,
|
||||
Func<uint, RadarRelationshipTraits>? relationshipFor = null,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? playerEntities = null)
|
||||
Func<IReadOnlyDictionary<uint, WorldEntity>>? 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<uint, WorldEntity> worldEntities = _worldEntities();
|
||||
IReadOnlyDictionary<uint, WorldEntity> playerEntities = _playerEntities();
|
||||
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> 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<UiRadarBlip>(_worldEntities.Count);
|
||||
foreach (var pair in _worldEntities)
|
||||
var blips = new List<UiRadarBlip>(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
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ public sealed record InventoryRuntimeBindings(
|
|||
Action<uint, uint, int>? SendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
|
||||
Action<uint, uint, uint>? SendStackableMerge,
|
||||
Action<uint, uint>? 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -176,6 +176,12 @@ public struct Particle
|
|||
/// </summary>
|
||||
public sealed class ParticleEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable logical identity assigned by <see cref="ParticleSystem"/>.
|
||||
/// Render owners use it to keep DAT mesh references balanced with the
|
||||
/// emitter's logical lifetime; it is not a GPU resource identifier.
|
||||
/// </summary>
|
||||
public int Handle { get; init; }
|
||||
public EmitterDesc Desc { get; init; } = null!;
|
||||
public Vector3 AnchorPos { get; set; }
|
||||
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
|
||||
|
|
|
|||
|
|
@ -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<ParticleSubmission>
|
||||
{
|
||||
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<uint>();
|
||||
var decrements = new List<uint>();
|
||||
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<string>();
|
||||
|
||||
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<PhysicsScriptTable>(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<DatReaderWriter.DBObjs.PhysicsScript>(
|
||||
scriptLoader.LoadPhysicsScript(scriptDid!.Value));
|
||||
uint[] emitterIds = script.ScriptData
|
||||
.Select(static item => item.Hook)
|
||||
.OfType<CreateParticleHook>()
|
||||
.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<GfxObj>(dats.Get<GfxObj>(gfxObjId));
|
||||
Assert.Equal(5, gfx.VertexArray.Vertices.Count);
|
||||
Assert.True(gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade));
|
||||
|
||||
GfxObjDegradeInfo degrade = Assert.IsType<GfxObjDegradeInfo>(
|
||||
dats.Get<GfxObjDegradeInfo>(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string> Toasts = new();
|
||||
public readonly List<string> 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<uint, uint>? emptySlotSprites = null)
|
||||
=> PaperdollController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0x1234u,
|
||||
IReadOnlyDictionary<uint, uint>? emptySlotSprites = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null,
|
||||
List<string>? 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<string>();
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<uint, WorldEntity> visible =
|
||||
new Dictionary<uint, WorldEntity>();
|
||||
IReadOnlyDictionary<uint, WorldEntity> canonical =
|
||||
new Dictionary<uint, WorldEntity>();
|
||||
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> spawns =
|
||||
new Dictionary<uint, WorldSession.EntitySpawn>();
|
||||
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<uint, WorldEntity>
|
||||
{
|
||||
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
|
||||
[monster] = Entity(monster, new Vector3(0f, 15f, 0f), Quaternion.Identity),
|
||||
};
|
||||
canonical = visible;
|
||||
spawns = new Dictionary<uint, WorldSession.EntitySpawn>
|
||||
{
|
||||
[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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue