feat(chat): port retail client command families
Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
5a45a7ac7f
commit
9ea579bdd0
47 changed files with 6659 additions and 99 deletions
|
|
@ -48,7 +48,7 @@ Copy this block when adding a new issue:
|
|||
|
||||
## #213 — Retail client commands were sent to ACE as chat text
|
||||
|
||||
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending live gate
|
||||
**Status:** IN-PROGRESS — full named-retail command-family port complete 2026-07-13, pending live gate
|
||||
**Severity:** MEDIUM
|
||||
**Component:** retained UI / chat commands / net
|
||||
|
||||
|
|
@ -63,23 +63,29 @@ as Talk text.
|
|||
|
||||
**Resolution:** Added an immutable named-retail client-command catalog and
|
||||
three explicit routes: typed retail client action, ACE server command, and
|
||||
ordinary chat. `/lifestone`, `/lif`, and `/ls` now publish
|
||||
`ExecuteClientCommandCmd`; the App-layer `ClientCommandController` invokes
|
||||
`WorldSession.SendTeleportToLifestone`, which sends retail game action `0x0063`.
|
||||
Unknown `/` commands publish `SendServerCommandCmd` in canonical `@` form, so
|
||||
commands such as `/ci` continue to reach ACE. Both chat backends use the same
|
||||
router.
|
||||
ordinary chat. The catalog now owns recall/house/PK travel, age/birth,
|
||||
framerate/lock/version/location/corpse/die, clear and named/automatic UI
|
||||
layouts, AFK/consent, emotes, friends, squelch/filter/message types, and
|
||||
fill-components. `ClientCommandController` keeps the panel layer independent
|
||||
of App/network services; `WorldSession` sends the exact game-action or control
|
||||
message for server-backed families. Friends and squelch databases are parsed
|
||||
into authoritative Core state, confirmation requests use the imported retail
|
||||
dialog, and unknown `/` commands still publish `SendServerCommandCmd` in
|
||||
canonical `@` form so ACE administrator commands continue to work. Both chat
|
||||
backends use the same router.
|
||||
|
||||
**Research:**
|
||||
`docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`;
|
||||
`docs/research/2026-07-13-retail-client-command-families-pseudocode.md`;
|
||||
`ClientCommunicationSystem::OnChatCommand @ 0x00581320`;
|
||||
`ClientCommunicationSystem::DoLifestone @ 0x0056FC70`;
|
||||
`CM_Character::Event_TeleToLifestone @ 0x006A1B90`.
|
||||
|
||||
**Acceptance:** In the connected Release client, `/ls`, `/lif`, and
|
||||
`/lifestone` begin recall to the bound lifestone without appearing as speech or
|
||||
an ACE unknown-command error. `/ls now` shows local usage and sends nothing.
|
||||
An ACE command such as `/ci 629 1` still works.
|
||||
**Acceptance:** In the connected Release client, representative local and
|
||||
server-backed commands execute without appearing as speech: `/loc`,
|
||||
`/framerate`, `/saveui test`, `/loadui test`, `/age`, `/friends`, `/afk on`,
|
||||
and `/marketplace`. `/ls now` shows local usage and sends nothing. An ACE
|
||||
command such as `/ci 629 1` still works.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -5132,18 +5138,20 @@ rendering.
|
|||
|
||||
---
|
||||
|
||||
## #L.6 — UI layout save/load (saveui / loadui / lockui)
|
||||
## #L.6 — [DONE 2026-07-13] UI layout save/load (saveui / loadui / lockui)
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** DONE
|
||||
**Severity:** LOW
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** ui
|
||||
|
||||
**Description:** Retail had `@saveui <name>`, `@loadui <name>`,
|
||||
`@lockui` commands for persisting ImGui-style window layouts. ImGui
|
||||
has built-in `LoadIniSettingsFromMemory` /
|
||||
`SaveIniSettingsToMemory` — wire these to per-named-layout files,
|
||||
plus chat-command parsing for the `@` prefixes.
|
||||
**Resolution:** The retained production UI now implements `@saveui [name]`,
|
||||
`@loadui [name]`, `@saveautoui`, `@loadautoui`, and `@lockui`. Named profiles
|
||||
persist all mounted retail windows independently of character/resolution;
|
||||
automatic profiles continue to use the existing character-and-resolution
|
||||
layout store. Both `/` and `@` prefixes route through the shared typed retail
|
||||
command catalog. The JSON storage remains the documented IA-15 modern
|
||||
adaptation of retail's `UIElement_Position` serialization.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
||||
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
||||
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
||||
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, and schema-v2 per-character/per-resolution layout persistence; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
||||
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, confirmation dialogs `0x2100003C`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
||||
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
||||
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
|
||||
|
|
@ -207,7 +207,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
|
||||
| ~~AP-101~~ | **RETIRED 2026-07-11 (Wave 4.4e)** — toolbar control coverage is complete: DAT panel launchers, combat, Use/Examine, selected-object strip, shortcuts, and exact thrown-weapon/separate-ammo count resolution on authored element `0x10000194`. | `src/AcDream.Core/Items/ToolbarAmmoPolicy.cs`; `src/AcDream.App/UI/Layout/ToolbarController.cs` | — | — | `gmToolbarUI::UpdateAmmoID @ 0x004BF210`; `UpdateAmmoNumber @ 0x004BE9E0` |
|
||||
| AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` |
|
||||
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
|
||||
| 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` |
|
||||
|
|
@ -249,7 +249,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
|
||||
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
|
||||
| TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
||||
| TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
||||
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
|
||||
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) |
|
||||
| TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` |
|
||||
|
|
@ -262,7 +262,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
||||
| TS-47 | The typed retail client-command catalog currently ports only `lifestone`/`lif`/`ls`; all other retail-owned command verbs fall through to ACE as server commands until ported one family at a time | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs` | The catalog/router/controller architecture is the first vertical slice; each remaining family needs its own named-decomp pseudocode, typed action, and conformance tests rather than a guessed bulk alias list | Retail commands such as `/marketplace`, `/house`, or `/friends` may produce ACE unknown-command output or server-specific behavior instead of the retail client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md` |
|
||||
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
||||
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
|
||||
- **Retail client command families implemented 2026-07-13; live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets, and confirmation requests mount imported LayoutDesc `0x2100003C`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`.
|
||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||
|
|
|
|||
|
|
@ -484,6 +484,14 @@ include dungeons.
|
|||
automatic candidates to hostile non-player monsters; live gate passed
|
||||
2026-07-12. See
|
||||
`docs/research/2026-07-12-death-and-auto-target-pseudocode.md`.
|
||||
- **Retail client command families (implemented 2026-07-13; live gate pending)** —
|
||||
both chat surfaces now route a named-decomp command catalog into an App-layer
|
||||
controller, keeping ACE administrator verbs on the explicit server-command
|
||||
path. The implemented set covers travel, character/time queries, local UI and
|
||||
location commands, named/automatic layout persistence, AFK/consent, emotes,
|
||||
friends, squelch/filter, and fill-components, with exact Core.Net messages and
|
||||
authoritative social-state parsing. See
|
||||
`docs/research/2026-07-13-retail-client-command-families-pseudocode.md`.
|
||||
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
|
||||
|
||||
**Freeze on landing:**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,343 @@
|
|||
# Retail client command families
|
||||
|
||||
Date: 2026-07-13
|
||||
|
||||
## Scope
|
||||
|
||||
This note extends the first lifestone vertical slice with the exact command
|
||||
families approved for porting. The named September 2013 retail client is the
|
||||
behavioral oracle; ACClientLib is used as an independent wire-name and payload
|
||||
cross-check.
|
||||
|
||||
## Sources
|
||||
|
||||
- `ClientCommunicationSystem::DoMarketplace @ 0x0056FCE0`
|
||||
- `ClientCommunicationSystem::DoLockUI @ 0x005703B0`
|
||||
- `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`
|
||||
- `ClientCommunicationSystem::DoPKArena @ 0x005788D0`
|
||||
- `ClientCommunicationSystem::DoPKLArena @ 0x005789D0`
|
||||
- `ClientCommunicationSystem::DoAge @ 0x0057C5A0`
|
||||
- `ClientCommunicationSystem::DoBirth @ 0x0056E5F0`
|
||||
- `CM_Character::Event_QueryAge @ 0x006A1620`
|
||||
- `CM_Character::Event_QueryBirth @ 0x006A16F0`
|
||||
- `CM_Character::Event_TeleToMarketplace @ 0x006A1C20`
|
||||
- `CM_Character::Event_TeleToPKArena @ 0x006A1CB0`
|
||||
- `CM_Character::Event_TeleToPKLArena @ 0x006A1D40`
|
||||
- `CM_House::Event_TeleToHouse_Event @ 0x006AAFA0`
|
||||
- `CM_House::Event_TeleToMansion_Event @ 0x006AB030`
|
||||
- `CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40`
|
||||
- `ClientCommunicationSystem::Handle_Character__QueryAgeResponse @ 0x005711D0`
|
||||
- retail command-table construction at `0x00581900..0x00585100`
|
||||
|
||||
Independent protocol cross-check:
|
||||
|
||||
- `references/acclientlib/UtilityBelt.Common/MessageTypes.cs`
|
||||
- `references/acclientlib/UtilityBelt.Common/Enums/Enums.cs`
|
||||
- `references/acclientlib/UtilityBelt.Scripting/Enums/BusyAction.cs`
|
||||
- ACE `Source/ACE.Server/Network/GameAction/Actions/` handlers for
|
||||
`SetAFKMode`, `ModifyCharacterSquelch`, `QueryAge`, `ConfirmationResponse`,
|
||||
`SetDesiredComponentLevel`, `AddFriend`, and the teleport families
|
||||
- ACE `GameEventConfirmationRequest`, `GameEventFriendsListUpdate`, and
|
||||
`Network/Structure/SquelchDB`/`SquelchInfo` writers
|
||||
|
||||
ACE's self age/birth handlers describe the trailing four zero bytes as an
|
||||
empty aligned `String16L`; named retail describes the same self-command bytes
|
||||
as object id zero. The wire bytes are identical for this path, and the named
|
||||
retail interpretation remains authoritative.
|
||||
|
||||
## Packet pseudocode
|
||||
|
||||
All actions use the normal `0xF7B1` game-action envelope and the next UI
|
||||
sequence number.
|
||||
|
||||
```text
|
||||
send_parameterless_action(opcode):
|
||||
packet = [0xF7B1, next_sequence(), opcode]
|
||||
send packet to the weenie server
|
||||
|
||||
marketplace(): send_parameterless_action(0x028D)
|
||||
pk_arena(): send_parameterless_action(0x0027)
|
||||
pkl_arena(): send_parameterless_action(0x0026)
|
||||
house_recall(): send_parameterless_action(0x0262)
|
||||
mansion_recall(): send_parameterless_action(0x0278)
|
||||
```
|
||||
|
||||
```text
|
||||
send_character_query(opcode, object_id):
|
||||
packet = [0xF7B1, next_sequence(), opcode, object_id]
|
||||
send packet to the weenie server
|
||||
|
||||
query_age(): send_character_query(0x01C2, selected_player_or_zero)
|
||||
query_birth(): send_character_query(0x01C4, selected_player_or_zero)
|
||||
```
|
||||
|
||||
For the ordinary self-command, `selected_player_or_zero` is zero. Retail's
|
||||
privileged PSR path may use the currently selected player; acdream does not
|
||||
claim that privileged mode.
|
||||
|
||||
## Command pseudocode
|
||||
|
||||
```text
|
||||
marketplace(arguments):
|
||||
if no arguments: send marketplace action
|
||||
else: display local usage
|
||||
```
|
||||
|
||||
```text
|
||||
pk_arena(arguments):
|
||||
if arguments: display local usage
|
||||
else if local player is not full PK: display retail failure 0x055F
|
||||
else: send PK-arena action
|
||||
|
||||
pkl_arena(arguments):
|
||||
if arguments: display local usage
|
||||
else if local player is not PKLite: display retail failure 0x0560
|
||||
else: send PKLite-arena action
|
||||
```
|
||||
|
||||
Retail derives both eligibility checks from the local player's
|
||||
`PublicWeenieDesc` bits (`IsPK` / `IsPKLite`), not from a guessed server
|
||||
capability.
|
||||
|
||||
```text
|
||||
framerate(arguments):
|
||||
if arguments: display local usage
|
||||
else:
|
||||
show_framerate = !show_framerate
|
||||
notify UI so the display changes immediately
|
||||
|
||||
lockui(arguments):
|
||||
if arguments: display local usage
|
||||
else:
|
||||
ui_locked = !ui_locked
|
||||
broadcast the UI-lock state change
|
||||
```
|
||||
|
||||
```text
|
||||
query_age_response(name, duration):
|
||||
if name is empty: display "You have played for {duration}."
|
||||
else: display "{name} has played for {duration}."
|
||||
```
|
||||
|
||||
The age-response payload is two aligned CP-1252 `String16L` values: target
|
||||
name (empty for self), then the already-formatted duration. Birth has no
|
||||
dedicated response event in the retail registry; the server supplies its
|
||||
result through the existing communication/system-text path.
|
||||
|
||||
## Location and corpse pseudocode
|
||||
|
||||
Sources:
|
||||
|
||||
- `ClientCommunicationSystem::DoLoc @ 0x0057A250`
|
||||
- `Position::ToString @ 0x005A9330`
|
||||
- `ClientCommunicationSystem::DoCorpse @ 0x00578220`
|
||||
- `LandDefs::CellidToCoordinateString @ 0x005A9CB0`
|
||||
|
||||
```text
|
||||
loc(arguments):
|
||||
if arguments: display local usage
|
||||
else if player position has no cell: display "Not in valid cell!"
|
||||
else:
|
||||
position_text = format(
|
||||
"0x%08X [%f %f %f] %f %f %f %f",
|
||||
cell, x, y, z, quaternion_w, quaternion_x,
|
||||
quaternion_y, quaternion_z)
|
||||
display "Your location is: {position_text}"
|
||||
```
|
||||
|
||||
```text
|
||||
corpse(arguments):
|
||||
// Retail ignores arguments for this command.
|
||||
last_outside = player quality PositionType.LastOutsideDeath (0x0E)
|
||||
if absent:
|
||||
display "We're sorry, but we have no record of your last outside corpse location."
|
||||
else:
|
||||
coordinates = cell_id_to_coordinate_string(last_outside.cell)
|
||||
display "The last time you died outside, your corpse was located at ({coordinates})."
|
||||
```
|
||||
|
||||
The corpse command reads the position already carried by PlayerDescription;
|
||||
it does not infer the corpse location from a nearby corpse object or the most
|
||||
recent death chat line.
|
||||
|
||||
## Chat-local and UI-layout pseudocode
|
||||
|
||||
Sources:
|
||||
|
||||
- `ClientCommunicationSystem::DoClear @ 0x0056E600`
|
||||
- `ClientCommunicationSystem::DoSaveUI @ 0x0056FFF0`
|
||||
- `ClientCommunicationSystem::DoLoadUI @ 0x00570150`
|
||||
- `ClientCommunicationSystem::DoSaveAutoUI @ 0x005702B0`
|
||||
- `ClientCommunicationSystem::DoLoadAutoUI @ 0x00570330`
|
||||
|
||||
```text
|
||||
clear(arguments):
|
||||
source = current_chat_source
|
||||
if first argument equals "all", ignoring case: source = all_sources
|
||||
clear(source)
|
||||
```
|
||||
|
||||
```text
|
||||
save_ui(arguments):
|
||||
reject more than one argument with the retail usage message
|
||||
name = first argument, or the empty/default profile
|
||||
reject names longer than 16 characters
|
||||
save every attached window's current placement and visibility to name
|
||||
|
||||
load_ui(arguments):
|
||||
apply the same argument rules as save_ui
|
||||
restore every attached window from name
|
||||
|
||||
save_auto_ui(arguments):
|
||||
reject arguments
|
||||
save every attached window under the current character and resolution
|
||||
|
||||
load_auto_ui(arguments):
|
||||
reject arguments
|
||||
restore every attached window for the current character and resolution
|
||||
```
|
||||
|
||||
The original serializes `UIElement_Position` records. acdream stores the same
|
||||
observable placement/visibility values in its typed layout store; the storage
|
||||
format is deliberately modern and remains tracked as divergence IA-15.
|
||||
|
||||
## Friends pseudocode
|
||||
|
||||
Sources:
|
||||
|
||||
- `ClientCommunicationSystem::DoFriends @ 0x0057BC00`
|
||||
- `ClientCommunicationSystem::DoFriendsAdd @ 0x00578FB0`
|
||||
- `ClientCommunicationSystem::DoFriendsRemove @ 0x00579080`
|
||||
- `gmFriendsUI::Request_AddFriend @ 0x0048D240`
|
||||
- `CM_Login::AddFriend @ 0x006A3020`
|
||||
- `CM_Login::RemoveFriend @ 0x006A30A0`
|
||||
- `CM_Login::ClearFriends @ 0x006A3110`
|
||||
|
||||
```text
|
||||
friends(arguments):
|
||||
no arguments: display all friends
|
||||
"online": display only online friends
|
||||
"add <name>": add_friend(name)
|
||||
"remove <name>": remove_friend(name)
|
||||
"old": request the legacy server friends listing
|
||||
otherwise: display "Invalid friends command specified."
|
||||
|
||||
add_friend(name):
|
||||
require a non-empty name
|
||||
if local list already has 50 entries: display WeenieError 0x0561
|
||||
else send opcode 0x0018 followed by String16L name
|
||||
|
||||
remove_friend(name):
|
||||
require a non-empty name
|
||||
if name equals "-all":
|
||||
send opcode 0x0025 and clear the local list
|
||||
else if a case-insensitive local match exists:
|
||||
send opcode 0x0017 followed by the friend's object id
|
||||
else display WeenieError 0x0563
|
||||
```
|
||||
|
||||
The server's `FriendsListUpdate` event is authoritative. Full updates replace
|
||||
the list; add/remove/login-state updates mutate it by object id. Display order
|
||||
is the received retail list order.
|
||||
|
||||
## Away, consent, and message-filter pseudocode
|
||||
|
||||
Sources:
|
||||
|
||||
- `ClientCommunicationSystem::DoAFK @ 0x0057B3F0`
|
||||
- `ClientCommunicationSystem::DoConsent @ 0x0057DDA0`
|
||||
- `ClientCommunicationSystem::ProcessSquelchArgs @ 0x00579C30`
|
||||
- `ClientCommunicationSystem::DoSquelch @ 0x0057BF50`
|
||||
- `ClientCommunicationSystem::DoUnSquelch @ 0x0057C070`
|
||||
- `ClientCommunicationSystem::DoFilter @ 0x0057C860`
|
||||
- `ClientCommunicationSystem::DoUnFilter @ 0x0057C880`
|
||||
- `ClientCommunicationSystem::PerformGlobalSquelchMod @ 0x0057C2D0`
|
||||
- `LogTextTypeEnumMapper::IsLegalChannel @ 0x006AFF40`
|
||||
|
||||
```text
|
||||
afk(arguments):
|
||||
no arguments or "on": send opcode 0x000F with true, unless already away
|
||||
"off": send opcode 0x000F with false, unless already present
|
||||
"msg <text>": trim/join text, enforce retail's 191-character limit,
|
||||
send opcode 0x0010 with String16L text, and echo the new text
|
||||
otherwise: display AFK usage
|
||||
```
|
||||
|
||||
```text
|
||||
consent(arguments):
|
||||
"on" / "off": toggle the local accept-loot-permits option and echo it
|
||||
"who": send opcode 0x0217
|
||||
"clear": send opcode 0x0216
|
||||
"remove <name>": send opcode 0x0218 followed by String16L name
|
||||
otherwise: display the retail invalid-command text
|
||||
```
|
||||
|
||||
The option is CharacterOption bit `0x00080000`; retail's default mask
|
||||
`0x50C4A54A` leaves it clear.
|
||||
|
||||
```text
|
||||
parse_squelch(arguments):
|
||||
default category = All
|
||||
consume options before the target:
|
||||
-reply uses the most recent teller
|
||||
-account chooses account-wide storage
|
||||
-<message type> chooses a legal LogTextType
|
||||
remaining joined text is the target name
|
||||
|
||||
squelch / unsquelch:
|
||||
with no arguments: display the authoritative local squelch database
|
||||
account target: send opcode 0x0059, add/remove flag, String16L name
|
||||
character target: send opcode 0x0058, add/remove flag, zero guid,
|
||||
String16L name, category
|
||||
|
||||
filter / unfilter:
|
||||
with no arguments: display global filters
|
||||
require exactly a legal -<message type> option
|
||||
send opcode 0x005B, add/remove flag, category
|
||||
```
|
||||
|
||||
Legal retail filter types are Speech, Tell, Combat, Magic, Emote, Appraisal,
|
||||
Spellcasting, Allegiance, Fellowship, Combat_Enemy, Combat_Self, Recall,
|
||||
Craft, and Salvaging. The inbound `SetSquelchDB` packet replaces the local
|
||||
database; command output reads that state rather than inventing client-only
|
||||
state.
|
||||
|
||||
## Emote and spell-component pseudocode
|
||||
|
||||
Sources:
|
||||
|
||||
- `ClientCommunicationSystem::DoEmote @ 0x00578AD0`
|
||||
- `CM_Communication::Event_Emote @ 0x006A4120`
|
||||
- `ClientCommunicationSystem::DoFillComponents @ 0x0056FD50`
|
||||
- `gmVendorUI::FillComponentList @ 0x004C4540`
|
||||
- `MagicEnumMapper::SpellComponentCategoryFromString @ 0x0056E290`
|
||||
|
||||
```text
|
||||
emote(arguments):
|
||||
text = join all arguments
|
||||
if text is non-empty: send opcode 0x01DF followed by String16L text
|
||||
|
||||
emotes(arguments):
|
||||
if arguments: display usage
|
||||
else: display the retail standard-emote help list
|
||||
```
|
||||
|
||||
```text
|
||||
fillcomps(arguments):
|
||||
reject more than two arguments
|
||||
if first argument is "clear":
|
||||
send opcode 0x0224, component id 0, amount 0xFFFFFFFF
|
||||
clear desired component levels and echo "Component list cleared."
|
||||
return
|
||||
parse optional component category and optional positive maximum price
|
||||
if no vendor is open: display "You need an open vendor."
|
||||
else for each desired component in the chosen category:
|
||||
needed = desired level - currently held count
|
||||
find the component in vendor stock and add min(needed, stock) to buy list
|
||||
stop before exceeding the maximum price and report the limit
|
||||
```
|
||||
|
||||
Accepted category spellings are Scarab(s), Herb(s), PowderedGem(s) or
|
||||
Powder(s), AlchemicalSubstance(s) or Potion(s), Talisman(s), Taper(s), and
|
||||
Pea(s). Component buying remains an operation on the vendor controller; the
|
||||
command parser does not duplicate vendor inventory or price logic.
|
||||
|
|
@ -136,6 +136,7 @@ public sealed class PlayerMovementController
|
|||
public Vector3 Position => _body.Position;
|
||||
public Vector3 RenderPosition => ComputeRenderPosition();
|
||||
public uint CellId { get; private set; }
|
||||
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
|
||||
|
||||
/// <summary>
|
||||
/// Local-player entity id used to skip self-collision in the
|
||||
|
|
|
|||
|
|
@ -730,6 +730,10 @@ public sealed class GameWindow : IDisposable
|
|||
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
|
||||
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
||||
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
|
||||
public readonly AcDream.Core.Social.FriendsState Friends = new();
|
||||
public readonly AcDream.Core.Social.SquelchState Squelch = new();
|
||||
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; }
|
||||
= System.Array.Empty<(uint Id, uint Amount)>();
|
||||
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
||||
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
|
||||
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
|
||||
|
|
@ -757,6 +761,7 @@ public sealed class GameWindow : IDisposable
|
|||
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
|
||||
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
|
||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
|
||||
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.UiHost? _uiHost;
|
||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
||||
|
|
@ -2114,6 +2119,7 @@ public sealed class GameWindow : IDisposable
|
|||
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
||||
uiLocked: () => _persistedGameplay.LockUI);
|
||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||
_retailChatVm = retailChatVm;
|
||||
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
|
||||
? null
|
||||
: new AcDream.App.UI.RetailUiPersistenceBindings(
|
||||
|
|
@ -2642,7 +2648,17 @@ public sealed class GameWindow : IDisposable
|
|||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
onUseDone: error => _itemInteractionController?.CompleteUse(error),
|
||||
itemMana: ItemMana);
|
||||
itemMana: ItemMana,
|
||||
onConfirmationRequest: request =>
|
||||
_retailUiRuntime?.ConfirmationDialogs?.Show(
|
||||
request.Message,
|
||||
accepted => session.SendConfirmationResponse(
|
||||
request.Type,
|
||||
request.ContextId,
|
||||
accepted)),
|
||||
friends: Friends,
|
||||
squelch: Squelch,
|
||||
onDesiredComponents: components => DesiredComponents = components);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
|
|
@ -2717,7 +2733,62 @@ public sealed class GameWindow : IDisposable
|
|||
var turbineChat = TurbineChat;
|
||||
uint playerGuid = _playerServerGuid;
|
||||
var clientCommandController = new AcDream.App.UI.ClientCommandController(
|
||||
liveSession.SendTeleportToLifestone);
|
||||
new AcDream.App.UI.ClientCommandController.Bindings(
|
||||
TeleportToLifestone: liveSession.SendTeleportToLifestone,
|
||||
TeleportToMarketplace: liveSession.SendTeleportToMarketplace,
|
||||
TeleportToPkArena: liveSession.SendTeleportToPkArena,
|
||||
TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena,
|
||||
TeleportToHouse: liveSession.SendTeleportToHouse,
|
||||
TeleportToMansion: liveSession.SendTeleportToMansion,
|
||||
QueryAge: liveSession.SendQueryAge,
|
||||
QueryBirth: liveSession.SendQueryBirth,
|
||||
ToggleFrameRate: ToggleRetailFrameRate,
|
||||
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
||||
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
|
||||
ShowWeenieError: code => chat.OnWeenieError(code, null),
|
||||
PlayerPublicWeenieBitfield: () =>
|
||||
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
||||
ClientVersion: () =>
|
||||
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown",
|
||||
CurrentPosition: () => _playerController?.CellPosition,
|
||||
LastOutsideCorpsePosition: () =>
|
||||
LocalPlayer.GetPosition(0x0Eu),
|
||||
ShowConfirmation: (message, completed) =>
|
||||
_retailUiRuntime?.ConfirmationDialogs?.Show(message, completed),
|
||||
Suicide: liveSession.SendSuicide,
|
||||
ClearChat: _ => chat.Clear(),
|
||||
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
||||
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
||||
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
||||
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
||||
IsAway: () =>
|
||||
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
||||
SetAway: away => SetRetailAway(liveSession, away),
|
||||
SetAwayMessage: liveSession.SendSetAfkMessage,
|
||||
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
||||
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
||||
DisplayConsent: liveSession.SendDisplayConsent,
|
||||
ClearConsent: liveSession.SendClearConsent,
|
||||
RemoveConsent: liveSession.SendRemoveConsent,
|
||||
SendEmote: liveSession.SendEmote,
|
||||
Friends: Friends,
|
||||
AddFriend: liveSession.SendAddFriend,
|
||||
RemoveFriend: liveSession.SendRemoveFriend,
|
||||
ClearFriends: liveSession.SendClearFriends,
|
||||
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
|
||||
Squelch: Squelch,
|
||||
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
|
||||
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
||||
ClearDesiredComponents: () =>
|
||||
{
|
||||
liveSession.SendClearDesiredComponents();
|
||||
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
|
||||
},
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { }));
|
||||
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
|
||||
clientCommandController.Execute);
|
||||
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
|
||||
|
|
@ -11386,6 +11457,46 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
|
||||
{
|
||||
session.SendSetAfkMode(away);
|
||||
}
|
||||
|
||||
private void SetRetailAcceptLootPermits(bool enabled)
|
||||
{
|
||||
_persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled };
|
||||
_settingsVm?.SetGameplay(
|
||||
_settingsVm.GameplayDraft with { AcceptLootPermits = enabled });
|
||||
_settingsStore?.SaveGameplay(_persistedGameplay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>:
|
||||
/// flip the live display flag and persist it through the same settings
|
||||
/// path used by the Display panel.
|
||||
/// </summary>
|
||||
private void ToggleRetailFrameRate()
|
||||
{
|
||||
_persistedDisplay = _persistedDisplay with
|
||||
{
|
||||
ShowFps = !_persistedDisplay.ShowFps,
|
||||
};
|
||||
if (_settingsVm is not null)
|
||||
_settingsVm.SetDisplay(_settingsVm.DisplayDraft with
|
||||
{
|
||||
ShowFps = _persistedDisplay.ShowFps,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
_settingsStore?.SaveDisplay(_persistedDisplay);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: framerate display save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetRetailCombatGameplay(
|
||||
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,20 +1,69 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Application-layer executor for typed retail client commands. Chat panels
|
||||
/// remain backend- and network-agnostic; this controller translates their
|
||||
/// intent to the live-session action supplied by the composition root.
|
||||
/// remain backend- and network-agnostic; this controller owns the boundary
|
||||
/// between verified command behavior and live session/UI services.
|
||||
/// </summary>
|
||||
public sealed class ClientCommandController
|
||||
{
|
||||
private readonly Action _teleportToLifestone;
|
||||
public sealed record Bindings(
|
||||
Action TeleportToLifestone,
|
||||
Action TeleportToMarketplace,
|
||||
Action TeleportToPkArena,
|
||||
Action TeleportToPkLiteArena,
|
||||
Action TeleportToHouse,
|
||||
Action TeleportToMansion,
|
||||
Action QueryAge,
|
||||
Action QueryBirth,
|
||||
Action ToggleFrameRate,
|
||||
Action ToggleUiLock,
|
||||
Action<string> ShowSystemMessage,
|
||||
Action<uint> ShowWeenieError,
|
||||
Func<uint?> PlayerPublicWeenieBitfield,
|
||||
Func<string> ClientVersion,
|
||||
Func<Position?> CurrentPosition,
|
||||
Func<Position?> LastOutsideCorpsePosition,
|
||||
Action<string, Action<bool>> ShowConfirmation,
|
||||
Action Suicide,
|
||||
Action<bool> ClearChat,
|
||||
Action<string> SaveUi,
|
||||
Action<string> LoadUi,
|
||||
Action SaveAutoUi,
|
||||
Action LoadAutoUi,
|
||||
Func<bool> IsAway,
|
||||
Action<bool> SetAway,
|
||||
Action<string> SetAwayMessage,
|
||||
Func<bool> AcceptLootPermits,
|
||||
Action<bool> SetAcceptLootPermits,
|
||||
Action DisplayConsent,
|
||||
Action ClearConsent,
|
||||
Action<string> RemoveConsent,
|
||||
Action<string> SendEmote,
|
||||
FriendsState Friends,
|
||||
Action<string> AddFriend,
|
||||
Action<uint> RemoveFriend,
|
||||
Action ClearFriends,
|
||||
Action RequestLegacyFriends,
|
||||
SquelchState Squelch,
|
||||
Action<bool, uint, string, uint> ModifyCharacterSquelch,
|
||||
Action<bool, string> ModifyAccountSquelch,
|
||||
Action<bool, uint> ModifyGlobalSquelch,
|
||||
Func<string?> LastTeller,
|
||||
Action ClearDesiredComponents,
|
||||
Func<bool> HasOpenVendor,
|
||||
Action<uint?, uint> FillComponentBuyList);
|
||||
|
||||
public ClientCommandController(Action teleportToLifestone)
|
||||
private readonly Bindings _bindings;
|
||||
|
||||
public ClientCommandController(Bindings bindings)
|
||||
{
|
||||
_teleportToLifestone = teleportToLifestone
|
||||
?? throw new ArgumentNullException(nameof(teleportToLifestone));
|
||||
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
||||
}
|
||||
|
||||
public void Execute(ExecuteClientCommandCmd command)
|
||||
|
|
@ -23,14 +72,595 @@ public sealed class ClientCommandController
|
|||
|
||||
switch (command.Command)
|
||||
{
|
||||
// Retail: ClientCommunicationSystem::DoLifestone @ 0x0056FC70
|
||||
// -> CM_Character::Event_TeleToLifestone @ 0x006A1B90.
|
||||
// ClientCommunicationSystem::DoLifestone @ 0x0056FC70.
|
||||
case ClientCommandId.LifestoneRecall:
|
||||
_teleportToLifestone();
|
||||
_bindings.TeleportToLifestone();
|
||||
break;
|
||||
// ClientCommunicationSystem::DoMarketplace @ 0x0056FCE0.
|
||||
case ClientCommandId.MarketplaceRecall:
|
||||
_bindings.TeleportToMarketplace();
|
||||
break;
|
||||
// DoPKArena @ 0x005788D0 gates on ACCWeenieObject::IsPK.
|
||||
case ClientCommandId.PkArenaRecall:
|
||||
if (HasPlayerFlag(EntityCollisionFlags.IsPK) == false)
|
||||
_bindings.ShowWeenieError(0x055Fu);
|
||||
else
|
||||
_bindings.TeleportToPkArena();
|
||||
break;
|
||||
// DoPKLArena @ 0x005789D0 gates on ACCWeenieObject::IsPKLite.
|
||||
case ClientCommandId.PkLiteArenaRecall:
|
||||
if (HasPlayerFlag(EntityCollisionFlags.IsPKLite) == false)
|
||||
_bindings.ShowWeenieError(0x0560u);
|
||||
else
|
||||
_bindings.TeleportToPkLiteArena();
|
||||
break;
|
||||
case ClientCommandId.HouseRecall:
|
||||
_bindings.TeleportToHouse();
|
||||
break;
|
||||
case ClientCommandId.MansionRecall:
|
||||
_bindings.TeleportToMansion();
|
||||
break;
|
||||
case ClientCommandId.QueryAge:
|
||||
_bindings.QueryAge();
|
||||
break;
|
||||
case ClientCommandId.QueryBirth:
|
||||
_bindings.QueryBirth();
|
||||
break;
|
||||
// DoFrameRate @ 0x005707D0 toggles the display flag; it does
|
||||
// not print a one-shot FPS sample into chat.
|
||||
case ClientCommandId.ToggleFrameRate:
|
||||
_bindings.ToggleFrameRate();
|
||||
break;
|
||||
// DoLockUI @ 0x005703B0 toggles PlayerModule::LockUI and
|
||||
// broadcasts the new state to every UI element.
|
||||
case ClientCommandId.ToggleUiLock:
|
||||
_bindings.ToggleUiLock();
|
||||
break;
|
||||
case ClientCommandId.ShowVersion:
|
||||
_bindings.ShowSystemMessage($"Client version {_bindings.ClientVersion()}");
|
||||
break;
|
||||
case ClientCommandId.ShowLocation:
|
||||
Position? position = _bindings.CurrentPosition();
|
||||
_bindings.ShowSystemMessage(position is { ObjCellId: not 0u }
|
||||
? $"Your location is: {RetailPositionFormatter.Format(position.Value)}"
|
||||
: "Not in valid cell!");
|
||||
break;
|
||||
case ClientCommandId.ShowLastCorpseLocation:
|
||||
Position? corpse = _bindings.LastOutsideCorpsePosition();
|
||||
string? coordinates = corpse is null
|
||||
? null
|
||||
: RetailPositionFormatter.FormatOutdoorCell(corpse.Value.ObjCellId);
|
||||
_bindings.ShowSystemMessage(coordinates is null
|
||||
? "We're sorry, but we have no record of your last outside corpse location."
|
||||
: $"The last time you died outside, your corpse was located at ({coordinates}).");
|
||||
break;
|
||||
// ClientCommunicationSystem::DoDie @ 0x00580050 and
|
||||
// DieDialogCallback @ 0x0057BA70.
|
||||
case ClientCommandId.Die:
|
||||
_bindings.ShowConfirmation(
|
||||
"Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
|
||||
accepted =>
|
||||
{
|
||||
if (accepted)
|
||||
_bindings.Suicide();
|
||||
});
|
||||
break;
|
||||
case ClientCommandId.ClearChat:
|
||||
_bindings.ClearChat(FirstArgument(command.Arguments)
|
||||
.Equals("all", StringComparison.OrdinalIgnoreCase));
|
||||
break;
|
||||
case ClientCommandId.SaveUi:
|
||||
ExecuteUiProfile(command.Arguments, save: true);
|
||||
break;
|
||||
case ClientCommandId.LoadUi:
|
||||
ExecuteUiProfile(command.Arguments, save: false);
|
||||
break;
|
||||
case ClientCommandId.SaveAutoUi:
|
||||
if (RequireNoArguments(command.Arguments, "/saveautoui"))
|
||||
_bindings.SaveAutoUi();
|
||||
break;
|
||||
case ClientCommandId.LoadAutoUi:
|
||||
if (RequireNoArguments(command.Arguments, "/loadautoui"))
|
||||
_bindings.LoadAutoUi();
|
||||
break;
|
||||
case ClientCommandId.Away:
|
||||
ExecuteAway(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.Consent:
|
||||
ExecuteConsent(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.Emote:
|
||||
if (!string.IsNullOrWhiteSpace(command.Arguments))
|
||||
_bindings.SendEmote(command.Arguments.Trim());
|
||||
break;
|
||||
case ClientCommandId.ListEmotes:
|
||||
_bindings.ShowSystemMessage(StandardEmotes);
|
||||
break;
|
||||
case ClientCommandId.Friends:
|
||||
ExecuteFriends(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.FriendsAdd:
|
||||
AddFriend(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.FriendsRemove:
|
||||
RemoveFriend(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.Squelch:
|
||||
ExecuteSquelch(command.Arguments, add: true);
|
||||
break;
|
||||
case ClientCommandId.Unsquelch:
|
||||
ExecuteSquelch(command.Arguments, add: false);
|
||||
break;
|
||||
case ClientCommandId.Filter:
|
||||
ExecuteGlobalFilter(command.Arguments, add: true);
|
||||
break;
|
||||
case ClientCommandId.Unfilter:
|
||||
ExecuteGlobalFilter(command.Arguments, add: false);
|
||||
break;
|
||||
case ClientCommandId.ListMessageTypes:
|
||||
_bindings.ShowSystemMessage(
|
||||
"Squelch channels are as follows:\n "
|
||||
+ string.Join(", ", MessageTypes.Values));
|
||||
break;
|
||||
case ClientCommandId.FillComponents:
|
||||
ExecuteFillComponents(command.Arguments);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(command), command.Command, "Unknown retail client command.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteUiProfile(string arguments, bool save)
|
||||
{
|
||||
string[] parts = SplitArguments(arguments);
|
||||
string command = save ? "saveui" : "loadui";
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
_bindings.ShowSystemMessage($"Please use @help {command} for proper usage.");
|
||||
return;
|
||||
}
|
||||
|
||||
string name = parts.Length == 0 ? string.Empty : parts[0];
|
||||
if (name.Length > 16)
|
||||
{
|
||||
_bindings.ShowSystemMessage("The file name must be 16 characters or less.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (save) _bindings.SaveUi(name);
|
||||
else _bindings.LoadUi(name);
|
||||
}
|
||||
|
||||
private bool RequireNoArguments(string arguments, string usage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments)) return true;
|
||||
_bindings.ShowSystemMessage($"Usage: {usage}");
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ExecuteAway(string arguments)
|
||||
{
|
||||
string first = FirstArgument(arguments);
|
||||
if (first.Length == 0 || first.Equals("on", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!_bindings.IsAway()) _bindings.SetAway(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_bindings.IsAway()) _bindings.SetAway(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.Equals("msg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string message = RemainderAfterFirstArgument(arguments).Trim(' ');
|
||||
if (message.Length > 191) message = message[..191];
|
||||
if (message.Length > 0 && !message.Contains('\n')) message += "\n";
|
||||
_bindings.SetAwayMessage(message);
|
||||
_bindings.ShowSystemMessage(message.Length == 0
|
||||
? "New AFK message set: I am currently away from the keyboard."
|
||||
: $"New AFK message set: {message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.ShowSystemMessage(AwayHelp);
|
||||
}
|
||||
|
||||
private void ExecuteConsent(string arguments)
|
||||
{
|
||||
string first = FirstArgument(arguments);
|
||||
if (first.Equals("on", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(true);
|
||||
_bindings.ShowSystemMessage(
|
||||
"You can now accept corpse looting permissions from other players.");
|
||||
}
|
||||
else if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(false);
|
||||
_bindings.ShowSystemMessage(
|
||||
"You are no longer accepting corpse looting permissions from other players.");
|
||||
}
|
||||
else if (first.Equals("who", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.DisplayConsent();
|
||||
}
|
||||
else if (first.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.ClearConsent();
|
||||
}
|
||||
else if (first.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string name = RemainderAfterFirstArgument(arguments).Trim();
|
||||
if (name.Length == 0)
|
||||
_bindings.ShowSystemMessage(
|
||||
"Please specify a person to remove from your consent list.");
|
||||
else
|
||||
_bindings.RemoveConsent(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_bindings.ShowSystemMessage("Please specify a valid consent command.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteFriends(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Length == 0)
|
||||
{
|
||||
DisplayFriends(onlineOnly: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("online", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
DisplayFriends(onlineOnly: true);
|
||||
return;
|
||||
}
|
||||
|
||||
string remainder = RemainderAfterFirstArgument(arguments);
|
||||
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
|
||||
AddFriend(remainder);
|
||||
else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
RemoveFriend(remainder);
|
||||
else if (operation.Equals("old", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrWhiteSpace(remainder))
|
||||
_bindings.RequestLegacyFriends();
|
||||
else
|
||||
_bindings.ShowSystemMessage("Invalid friends command specified.");
|
||||
}
|
||||
|
||||
private void AddFriend(string arguments)
|
||||
{
|
||||
string name = arguments.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage(
|
||||
"You must specify the name of the friend you wish to add.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_bindings.Friends.Snapshot().Count >= 50)
|
||||
{
|
||||
_bindings.ShowWeenieError(0x0561u);
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.AddFriend(name);
|
||||
}
|
||||
|
||||
private void RemoveFriend(string arguments)
|
||||
{
|
||||
string name = arguments.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage(
|
||||
"You must specify the name of the friend you wish to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.ClearFriends();
|
||||
_bindings.Friends.Clear();
|
||||
_bindings.ShowSystemMessage("Your friends list has been cleared.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
FriendEntry? friend = _bindings.Friends.Snapshot().FirstOrDefault(
|
||||
entry => entry.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (friend is null)
|
||||
_bindings.ShowWeenieError(0x0563u);
|
||||
else
|
||||
_bindings.RemoveFriend(friend.Id);
|
||||
}
|
||||
|
||||
private void DisplayFriends(bool onlineOnly)
|
||||
{
|
||||
IReadOnlyList<FriendEntry> entries = _bindings.Friends.Snapshot();
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage("Your friends list is empty!\n");
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = entries
|
||||
.Where(entry => !onlineOnly || entry.Online)
|
||||
.Select(entry => $" {entry.Name}{(entry.Online ? " (Online)" : string.Empty)}")
|
||||
.ToArray();
|
||||
_bindings.ShowSystemMessage(lines.Length == 0
|
||||
? "Your friends:\n You have no friends that are online.\n"
|
||||
: "Your friends:\n" + string.Join("\n", lines) + "\n");
|
||||
}
|
||||
|
||||
private void ExecuteSquelch(string arguments, bool add)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
DisplayCharacterSquelches();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryParseSquelch(arguments, out SquelchArguments parsed, out string error))
|
||||
{
|
||||
_bindings.ShowSystemMessage(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.AccountWide)
|
||||
_bindings.ModifyAccountSquelch(add, parsed.Name);
|
||||
else
|
||||
_bindings.ModifyCharacterSquelch(add, 0u, parsed.Name, parsed.MessageType);
|
||||
}
|
||||
|
||||
private void ExecuteGlobalFilter(string arguments, bool add)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
DisplayGlobalFilters();
|
||||
return;
|
||||
}
|
||||
|
||||
string[] parts = SplitArguments(arguments);
|
||||
if (parts.Length != 1 || !parts[0].StartsWith('-'))
|
||||
{
|
||||
_bindings.ShowSystemMessage("Incorrect usage, use @help for proper usage.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetMessageType(parts[0][1..], out uint type) || type == 1u)
|
||||
{
|
||||
_bindings.ShowSystemMessage("You must specify a valid message type.");
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.ModifyGlobalSquelch(add, type);
|
||||
}
|
||||
|
||||
private bool TryParseSquelch(
|
||||
string arguments,
|
||||
out SquelchArguments parsed,
|
||||
out string error)
|
||||
{
|
||||
parsed = default;
|
||||
error = string.Empty;
|
||||
string[] parts = SplitArguments(arguments);
|
||||
bool account = false;
|
||||
uint messageType = 1u;
|
||||
string? replyName = null;
|
||||
int index = 0;
|
||||
for (; index < parts.Length && parts[index].StartsWith('-'); index++)
|
||||
{
|
||||
string option = parts[index][1..];
|
||||
if (option.Equals("account", StringComparison.OrdinalIgnoreCase))
|
||||
account = true;
|
||||
else if (option.Equals("reply", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
replyName = _bindings.LastTeller();
|
||||
if (string.IsNullOrWhiteSpace(replyName))
|
||||
{
|
||||
error = "A player must @tell you before you can use the -reply option.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!TryGetMessageType(option, out messageType))
|
||||
{
|
||||
error = $"\"{option}\" is not a valid squelch category.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
string name = replyName ?? string.Join(' ', parts.Skip(index));
|
||||
if (name.Length == 0)
|
||||
{
|
||||
error = "You have not specified a squelch target.";
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed = new SquelchArguments(account, messageType, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DisplayCharacterSquelches()
|
||||
{
|
||||
SquelchDatabase database = _bindings.Squelch.Snapshot();
|
||||
var lines = database.Characters.Values
|
||||
.Where(info => info.MessageTypes.Count > 0)
|
||||
.Select(FormatSquelchInfo)
|
||||
.ToArray();
|
||||
_bindings.ShowSystemMessage(
|
||||
"(account) denotes a character whose account has also been squelched.\n"
|
||||
+ "Format: Name : List of squelched message types.\n--------\n"
|
||||
+ (lines.Length == 0 ? "none\n" : string.Join("\n", lines) + "\n"));
|
||||
}
|
||||
|
||||
private void DisplayGlobalFilters()
|
||||
{
|
||||
SquelchInfo global = _bindings.Squelch.Snapshot().Global;
|
||||
string list = global.MessageTypes.Count == 0
|
||||
? "none"
|
||||
: FormatMessageTypes(global.MessageTypes);
|
||||
_bindings.ShowSystemMessage(
|
||||
"The following types of messages are currently being filtered globally:\n"
|
||||
+ list + "\n(For a list of filter options, type @help filter)\n");
|
||||
}
|
||||
|
||||
private static string FormatSquelchInfo(SquelchInfo info) =>
|
||||
$"Name: {info.Name}{(info.AccountWide ? " (account) " : " ")}"
|
||||
+ FormatMessageTypes(info.MessageTypes);
|
||||
|
||||
private static string FormatMessageTypes(IReadOnlySet<uint> types)
|
||||
{
|
||||
if (types.Contains(1u)) return "All message types";
|
||||
return string.Join(", ", MessageTypes
|
||||
.Where(pair => pair.Key != 1u && types.Contains(pair.Key))
|
||||
.Select(pair => pair.Value));
|
||||
}
|
||||
|
||||
private void ExecuteFillComponents(string arguments)
|
||||
{
|
||||
string[] parts = SplitArguments(arguments);
|
||||
if (parts.Length > 2)
|
||||
{
|
||||
_bindings.ShowSystemMessage("Please use @help fillcomps for proper usage.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.Length > 0 && parts[0].Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.ClearDesiredComponents();
|
||||
_bindings.ShowSystemMessage("Component list cleared.");
|
||||
return;
|
||||
}
|
||||
|
||||
uint? category = null;
|
||||
uint maximumPrice = 0u;
|
||||
foreach (string part in parts)
|
||||
{
|
||||
if (uint.TryParse(part, out uint price))
|
||||
{
|
||||
if (price == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage("Please specify a value greater than zero.");
|
||||
return;
|
||||
}
|
||||
maximumPrice = price;
|
||||
}
|
||||
else if (TryGetComponentCategory(part, out uint parsedCategory))
|
||||
{
|
||||
category = parsedCategory;
|
||||
}
|
||||
else
|
||||
{
|
||||
_bindings.ShowSystemMessage("Invalid component type specified.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_bindings.HasOpenVendor())
|
||||
{
|
||||
_bindings.ShowSystemMessage("You need an open vendor.");
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.FillComponentBuyList(category, maximumPrice);
|
||||
}
|
||||
|
||||
private static bool TryGetComponentCategory(string value, out uint category)
|
||||
{
|
||||
category = value.ToLowerInvariant() switch
|
||||
{
|
||||
"scarab" or "scarabs" => 0u,
|
||||
"herb" or "herbs" => 1u,
|
||||
"powderedgem" or "powderedgems" or "powder" or "powders" => 2u,
|
||||
"alchemicalsubstance" or "alchemicalsubstances" or "potion" or "potions" => 3u,
|
||||
"talisman" or "talismans" => 4u,
|
||||
"taper" or "tapers" => 5u,
|
||||
"pea" or "peas" => 6u,
|
||||
_ => uint.MaxValue,
|
||||
};
|
||||
return category != uint.MaxValue;
|
||||
}
|
||||
|
||||
private static bool TryGetMessageType(string value, out uint type)
|
||||
{
|
||||
foreach ((uint key, string name) in MessageTypes)
|
||||
{
|
||||
if (name.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
type = key;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
type = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FirstArgument(string arguments)
|
||||
{
|
||||
string trimmed = arguments.Trim();
|
||||
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
|
||||
return separator < 0 ? trimmed : trimmed[..separator];
|
||||
}
|
||||
|
||||
private static string RemainderAfterFirstArgument(string arguments)
|
||||
{
|
||||
string trimmed = arguments.Trim();
|
||||
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
|
||||
return separator < 0 ? string.Empty : trimmed[(separator + 1)..].TrimStart();
|
||||
}
|
||||
|
||||
private static string[] SplitArguments(string arguments) =>
|
||||
arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
private readonly record struct SquelchArguments(
|
||||
bool AccountWide, uint MessageType, string Name);
|
||||
|
||||
private static readonly IReadOnlyDictionary<uint, string> MessageTypes =
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[1] = "All",
|
||||
[2] = "Speech",
|
||||
[3] = "Tell",
|
||||
[6] = "Combat",
|
||||
[7] = "Magic",
|
||||
[12] = "Emote",
|
||||
[16] = "Appraisal",
|
||||
[17] = "Spellcasting",
|
||||
[18] = "Allegiance",
|
||||
[19] = "Fellowship",
|
||||
[21] = "Combat_Enemy",
|
||||
[22] = "Combat_Self",
|
||||
[23] = "Recall",
|
||||
[24] = "Craft",
|
||||
[25] = "Salvaging",
|
||||
};
|
||||
|
||||
private const string StandardEmotes =
|
||||
"Standard Emotes:\n"
|
||||
+ "Note: These commands should be bound on either side by asterisks. (Example: *wave*)\n"
|
||||
+ "ShakeFist; Beckon; BeSeeingYou; BlowKiss; BowDeep; ClapHands; Cry; Laugh; Nod; Point; Shrug; Wave; Akimbo; HeartyLaugh; Salute; TapFoot; WaveHigh; WaveLow; Yawn; Stretch; Cringe; Kneel; Plead; Shiver; Shoo; Slouch; Spit; Surrender; Woah; Winded; YMCA; Eat; Drink; Teapot; Pray; Mock; Cheer; Helper; Warm Hands; Scratch Head; Shake Head\n\n";
|
||||
|
||||
private const string AwayHelp =
|
||||
"@afk - Turns on AFK (away-from-keyboard) mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
|
||||
+ "@afk on - Turns on AFK mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
|
||||
+ "@afk off - Turn off AFK mode.\n"
|
||||
+ "@afk msg <message> - Set the message that will be sent to players that send you directed chat while you are in AFK mode. Issuing \"@afk msg\" with no message will set your AFK message back to the default. Your custom AFK message is limited to 192 characters.\n";
|
||||
|
||||
/// <summary>
|
||||
/// Null means the local PublicWeenieDesc has not arrived yet. Retail only
|
||||
/// rejects when it has a player object and the required bit is absent, so
|
||||
/// unknown follows the same send-and-let-the-server-decide path.
|
||||
/// </summary>
|
||||
private bool? HasPlayerFlag(EntityCollisionFlags flag)
|
||||
{
|
||||
uint? bitfield = _bindings.PlayerPublicWeenieBitfield();
|
||||
return bitfield is null
|
||||
? null
|
||||
: (EntityCollisionFlagsExt.FromPwdBitfield(bitfield.Value) & flag) != 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ public static class DatWidgetFactory
|
|||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||
0x10000035u => BuildCheckbox(info, resolve, elementFont, stringResolve), // UIOption_Checkbox
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
|
|
|
|||
|
|
@ -221,6 +221,24 @@ public static class LayoutImporter
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElementManager::CreateRootElementByDataID</c> counterpart: resolve one
|
||||
/// authored root from a catalog-style LayoutDesc instead of instantiating every
|
||||
/// top-level template. DialogFactory uses this path for the shared dialog catalog.
|
||||
/// </summary>
|
||||
public static ElementInfo? ImportInfos(
|
||||
DatCollection dats,
|
||||
uint layoutId,
|
||||
uint rootElementId)
|
||||
{
|
||||
var ld = dats.Get<LayoutDesc>(layoutId);
|
||||
if (ld is null) return null;
|
||||
ElementDesc? root = FindDesc(ld, rootElementId);
|
||||
return root is null
|
||||
? null
|
||||
: Resolve(dats, root, new HashSet<(uint, uint)>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
|
||||
/// element, and build the widget tree. Returns null if the layout is absent
|
||||
|
|
@ -260,6 +278,21 @@ public static class LayoutImporter
|
|||
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
|
||||
}
|
||||
|
||||
/// <summary>Import one selected root from a catalog-style LayoutDesc.</summary>
|
||||
public static ImportedLayout? Import(
|
||||
DatCollection dats,
|
||||
uint layoutId,
|
||||
uint rootElementId,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
{
|
||||
var rootInfo = ImportInfos(dats, layoutId, rootElementId);
|
||||
if (rootInfo is null) return null;
|
||||
var strings = new DatStringResolver(dats);
|
||||
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
|
||||
}
|
||||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
|
||||
|
|
|
|||
122
src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs
Normal file
122
src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained implementation of retail <c>DialogFactory</c>'s confirmation queue.
|
||||
/// The visual tree is element <c>0x15</c> from the shared dialog catalog resolved by
|
||||
/// <c>GetDIDByEnum(2, 5)</c>; callbacks mirror property <c>0x92</c> from
|
||||
/// <c>ConfirmationDialog::ListenToElementMessage @ 0x00476670</c>.
|
||||
/// </summary>
|
||||
public sealed class RetailConfirmationDialogService
|
||||
{
|
||||
public const uint RootElementId = 0x15u;
|
||||
public const uint PopupElementId = 0x3Du;
|
||||
public const uint MessageElementId = 0x3Eu;
|
||||
public const uint AcceptButtonId = 0x17u;
|
||||
public const uint RejectButtonId = 0x19u;
|
||||
|
||||
private sealed record Request(string Message, Action<bool> Completed);
|
||||
|
||||
private readonly UiRoot _host;
|
||||
private readonly UiDialogRoot _dialog;
|
||||
private readonly UiElement _popup;
|
||||
private readonly UiText _message;
|
||||
private readonly UiButton _accept;
|
||||
private readonly UiButton _reject;
|
||||
private readonly Queue<Request> _queue = new();
|
||||
private readonly float _basePopupHeight;
|
||||
private readonly float _baseMessageHeight;
|
||||
private Request? _current;
|
||||
|
||||
public RetailConfirmationDialogService(UiRoot host, ImportedLayout layout)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
_dialog = layout.Root as UiDialogRoot
|
||||
?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout));
|
||||
_popup = layout.FindElement(PopupElementId)
|
||||
?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout));
|
||||
_message = layout.FindElement(MessageElementId) as UiText
|
||||
?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout));
|
||||
_accept = layout.FindElement(AcceptButtonId) as UiButton
|
||||
?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout));
|
||||
_reject = layout.FindElement(RejectButtonId) as UiButton
|
||||
?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", nameof(layout));
|
||||
|
||||
_basePopupHeight = _popup.Height;
|
||||
_baseMessageHeight = _message.Height;
|
||||
_popup.LayoutPolicy = null;
|
||||
_popup.Anchors = AnchorEdges.None;
|
||||
_message.LayoutPolicy = null;
|
||||
_message.Anchors = AnchorEdges.None;
|
||||
_message.Padding = 0f;
|
||||
_message.Selectable = false;
|
||||
_dialog.Cancel = () => Complete(false);
|
||||
_accept.OnClick = () => Complete(true);
|
||||
_reject.OnClick = () => Complete(false);
|
||||
}
|
||||
|
||||
public bool IsOpen => _current is not null;
|
||||
public int PendingCount => _queue.Count;
|
||||
|
||||
public void Show(string message, Action<bool> completed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
ArgumentNullException.ThrowIfNull(completed);
|
||||
_queue.Enqueue(new Request(message, completed));
|
||||
if (_current is null)
|
||||
ShowNext();
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_current is null) return;
|
||||
SizeAndCenter();
|
||||
}
|
||||
|
||||
private void ShowNext()
|
||||
{
|
||||
if (_queue.Count == 0) return;
|
||||
_current = _queue.Dequeue();
|
||||
|
||||
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
|
||||
Func<string, float> measure = _message.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static text => text.Length * 8f;
|
||||
IReadOnlyList<string> wrapped = UiText.WrapWords(
|
||||
_current.Message,
|
||||
measure,
|
||||
maximumWidth);
|
||||
var lines = new UiText.Line[wrapped.Count];
|
||||
for (int i = 0; i < wrapped.Count; i++)
|
||||
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
|
||||
_message.LinesProvider = () => lines;
|
||||
|
||||
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
|
||||
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
|
||||
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
|
||||
|
||||
SizeAndCenter();
|
||||
_host.AddChild(_dialog);
|
||||
_host.Modal = _dialog;
|
||||
}
|
||||
|
||||
private void SizeAndCenter()
|
||||
{
|
||||
_dialog.Left = 0f;
|
||||
_dialog.Top = 0f;
|
||||
_dialog.Width = _host.Width;
|
||||
_dialog.Height = _host.Height;
|
||||
_popup.Left = MathF.Round((_dialog.Width - _popup.Width) * 0.5f);
|
||||
_popup.Top = MathF.Round((_dialog.Height - _popup.Height) * 0.5f);
|
||||
}
|
||||
|
||||
private void Complete(bool accepted)
|
||||
{
|
||||
Request? completed = _current;
|
||||
if (completed is null) return;
|
||||
_current = null;
|
||||
_host.RemoveChild(_dialog);
|
||||
completed.Completed(accepted);
|
||||
ShowNext();
|
||||
}
|
||||
}
|
||||
28
src/AcDream.App/UI/RetailDataIdResolver.cs
Normal file
28
src/AcDream.App/UI/RetailDataIdResolver.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map
|
||||
/// selects an enum category, then that category map resolves the client enum value.
|
||||
/// </summary>
|
||||
public static class RetailDataIdResolver
|
||||
{
|
||||
public static uint Resolve(DatCollection dats, uint enumValue, uint enumCategory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|
||||
|| master is null
|
||||
|| !master.ClientEnumToID.TryGetValue(enumCategory, out uint subMapDid)
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(subMapDid, out var subMap)
|
||||
|| subMap is null
|
||||
|| !subMap.ClientEnumToID.TryGetValue(enumValue, out uint did))
|
||||
return 0u;
|
||||
|
||||
return did;
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountToolbar();
|
||||
MountCombat();
|
||||
MountJumpPowerbar();
|
||||
MountConfirmationDialogs();
|
||||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
|
|
@ -182,6 +183,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public SelectedObjectController? SelectedObjectController { get; private set; }
|
||||
public UiViewport? PaperdollViewportWidget { get; private set; }
|
||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
public RetailConfirmationDialogService? ConfirmationDialogs { get; private set; }
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -201,6 +203,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ConfirmationDialogs?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
}
|
||||
|
|
@ -218,6 +221,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
public void RestoreLayout() => _persistence?.RestoreAll();
|
||||
|
||||
public void SaveLayout() => _persistence?.SaveAll();
|
||||
|
||||
public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName);
|
||||
|
||||
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
|
||||
|
||||
public bool ToggleWindow(string name)
|
||||
=> Host.ToggleWindow(name);
|
||||
|
||||
|
|
@ -586,6 +595,36 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
|
||||
}
|
||||
|
||||
private void MountConfirmationDialogs()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
uint layoutId;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 calls
|
||||
// GetDIDByEnum(2, 5), then creates root element 0x15.
|
||||
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
|
||||
layout = layoutId == 0u
|
||||
? null
|
||||
: LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
layoutId,
|
||||
RetailConfirmationDialogService.RootElementId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[UI] confirmation dialog catalog could not be resolved.");
|
||||
return;
|
||||
}
|
||||
|
||||
ConfirmationDialogs = new RetailConfirmationDialogService(Host.Root, layout);
|
||||
Console.WriteLine($"[UI] retail confirmation dialogs from LayoutDesc 0x{layoutId:X8} element 0x15.");
|
||||
}
|
||||
|
||||
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
|
||||
{
|
||||
uint[]? regular = null, ghosted = null, empty = null;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,55 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Force-save every attached window to the current automatic profile.</summary>
|
||||
public void SaveAll()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
string character = _characterKey();
|
||||
if (!CanPersist(character)) return;
|
||||
var screen = ValidScreenSize();
|
||||
string resolution = ResolutionKey(screen);
|
||||
foreach (RetailWindowHandle handle in _attached)
|
||||
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
|
||||
}
|
||||
|
||||
/// <summary>Save every attached window to a portable named retail UI profile.</summary>
|
||||
public void SaveNamed(string profileName)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
foreach (RetailWindowHandle handle in _attached)
|
||||
_store.SaveNamedWindowLayout(profileName, handle.Name, Capture(handle));
|
||||
}
|
||||
|
||||
/// <summary>Restore every window found in a portable named retail UI profile.</summary>
|
||||
public void RestoreNamed(string profileName)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
var screen = ValidScreenSize();
|
||||
|
||||
_restoring = true;
|
||||
try
|
||||
{
|
||||
foreach (RetailWindowHandle handle in _attached)
|
||||
{
|
||||
UiWindowLayout? saved = _store.LoadNamedWindowLayout(
|
||||
profileName, handle.Name, Capture(handle));
|
||||
if (saved is not { } layout) continue;
|
||||
Apply(
|
||||
handle,
|
||||
layout,
|
||||
screen,
|
||||
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Attach(RetailWindowHandle handle)
|
||||
{
|
||||
_attached.Add(handle);
|
||||
|
|
|
|||
32
src/AcDream.App/UI/UiDialogRoot.cs
Normal file
32
src/AcDream.App/UI/UiDialogRoot.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Full-device modal root for retail dialog element classes. The authored child with id
|
||||
/// <c>0x3D</c> supplies the visible popup art; this root only owns modality and input capture.
|
||||
/// </summary>
|
||||
public sealed class UiDialogRoot : UiPanel
|
||||
{
|
||||
public Action? Cancel { get; set; }
|
||||
|
||||
public UiDialogRoot()
|
||||
{
|
||||
BackgroundColor = Vector4.Zero;
|
||||
BorderColor = Vector4.Zero;
|
||||
ClickThrough = false;
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.KeyDown
|
||||
&& e.Data0 == (int)Silk.NET.Input.Key.Escape)
|
||||
{
|
||||
Cancel?.Invoke();
|
||||
}
|
||||
|
||||
// A dialog root is full-screen and modal. Unhandled clicks/keys must not
|
||||
// fall through into movement, combat, or the default chat input.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -311,6 +311,7 @@ public sealed class UiText : UiElement
|
|||
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
||||
|
||||
string text = lines[i].Text;
|
||||
float lineX = HorizontalOffset(text, datFont, bitmapFont);
|
||||
|
||||
// Selection highlight behind this line's selected character span.
|
||||
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
|
||||
|
|
@ -324,12 +325,12 @@ public sealed class UiText : UiElement
|
|||
float hx, hw;
|
||||
if (datFont is not null)
|
||||
{
|
||||
hx = Padding + datFont.MeasureWidth(text.Substring(0, c0));
|
||||
hx = lineX + datFont.MeasureWidth(text.Substring(0, c0));
|
||||
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
else
|
||||
{
|
||||
hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0));
|
||||
hx = lineX + bitmapFont!.MeasureWidth(text.Substring(0, c0));
|
||||
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
// Highlight sits BEHIND the line's text → sprite bucket, submitted
|
||||
|
|
@ -339,12 +340,24 @@ public sealed class UiText : UiElement
|
|||
}
|
||||
|
||||
if (datFont is not null)
|
||||
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
|
||||
ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color);
|
||||
else
|
||||
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
|
||||
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont);
|
||||
}
|
||||
}
|
||||
|
||||
private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
|
||||
{
|
||||
float width = datFont is not null
|
||||
? datFont.MeasureWidth(text)
|
||||
: bitmapFont?.MeasureWidth(text) ?? 0f;
|
||||
if (Centered)
|
||||
return Math.Max(Padding, (Width - width) * 0.5f);
|
||||
if (RightAligned)
|
||||
return Math.Max(Padding, Width - Padding - width);
|
||||
return Padding;
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
|
|
@ -545,16 +558,80 @@ public sealed class UiText : UiElement
|
|||
line = Math.Clamp(line, 0, lines.Count - 1);
|
||||
|
||||
string text = lines[line].Text;
|
||||
float lineX = HorizontalOffset(text, _lastDatFont, _lastFont);
|
||||
int col = _lastDatFont is { } df
|
||||
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
|
||||
localX - _lastPadding)
|
||||
localX - lineX)
|
||||
: (_lastFont is { } bf
|
||||
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
|
||||
localX - _lastPadding)
|
||||
localX - lineX)
|
||||
: 0);
|
||||
return new Pos(line, col);
|
||||
}
|
||||
|
||||
/// <summary>Word-wrap text to a measured pixel width, preserving explicit newlines.</summary>
|
||||
public static IReadOnlyList<string> WrapWords(
|
||||
string text,
|
||||
Func<string, float> measureWidth,
|
||||
float maximumWidth)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
ArgumentNullException.ThrowIfNull(measureWidth);
|
||||
if (maximumWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(maximumWidth));
|
||||
|
||||
var result = new List<string>();
|
||||
string[] paragraphs = text.Replace("\r", string.Empty).Split('\n');
|
||||
foreach (string paragraph in paragraphs)
|
||||
{
|
||||
if (paragraph.Length == 0)
|
||||
{
|
||||
result.Add(string.Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var line = new StringBuilder();
|
||||
foreach (string word in words)
|
||||
{
|
||||
string candidate = line.Length == 0 ? word : $"{line} {word}";
|
||||
if (measureWidth(candidate) <= maximumWidth)
|
||||
{
|
||||
if (line.Length != 0) line.Append(' ');
|
||||
line.Append(word);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Length != 0 && measureWidth(word) <= maximumWidth)
|
||||
{
|
||||
result.Add(line.ToString());
|
||||
line.Clear();
|
||||
line.Append(word);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Retail GlyphList wrapping can split an over-width glyph run.
|
||||
// Pack as much of the long token as possible onto the current
|
||||
// line, then continue at character boundaries without hyphens.
|
||||
for (int i = 0; i < word.Length; i++)
|
||||
{
|
||||
string prefix = i == 0 && line.Length != 0 ? " " : string.Empty;
|
||||
if (line.Length != 0
|
||||
&& measureWidth(line + prefix + word[i]) > maximumWidth)
|
||||
{
|
||||
result.Add(line.ToString());
|
||||
line.Clear();
|
||||
prefix = string.Empty;
|
||||
}
|
||||
line.Append(prefix).Append(word[i]);
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(line.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caret column for a horizontal position <paramref name="x"/> (already
|
||||
/// adjusted for the left padding, so x=0 is the start of the text). Walks the
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Social;
|
||||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
|
|
@ -70,7 +71,11 @@ public static class GameEventWiring
|
|||
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint /*weenieError*/>? onUseDone = null,
|
||||
ItemManaState? itemMana = null)
|
||||
ItemManaState? itemMana = null,
|
||||
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
|
||||
FriendsState? friends = null,
|
||||
SquelchState? squelch = null,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -99,6 +104,42 @@ public static class GameEventWiring
|
|||
var s = GameEvents.ParsePopupString(e.Payload.Span);
|
||||
if (s is not null) chat.OnPopup(s);
|
||||
});
|
||||
dispatcher.Register(GameEventType.QueryAgeResponse, e =>
|
||||
{
|
||||
var p = GameEvents.ParseQueryAgeResponse(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
string text = string.IsNullOrEmpty(p.Value.Name)
|
||||
? $"You have played for {p.Value.Age}."
|
||||
: $"{p.Value.Name} has played for {p.Value.Age}.";
|
||||
chat.OnSystemMessage(text, chatType: 0u);
|
||||
});
|
||||
if (onConfirmationRequest is not null)
|
||||
{
|
||||
dispatcher.Register(GameEventType.CharacterConfirmationRequest, e =>
|
||||
{
|
||||
var request = GameEvents.ParseCharacterConfirmationRequest(e.Payload.Span);
|
||||
if (request is not null)
|
||||
onConfirmationRequest(request.Value);
|
||||
});
|
||||
}
|
||||
|
||||
if (friends is not null)
|
||||
{
|
||||
dispatcher.Register(GameEventType.FriendsListUpdate, e =>
|
||||
{
|
||||
FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate(e.Payload.Span);
|
||||
if (update is not null) friends.Apply(update);
|
||||
});
|
||||
}
|
||||
|
||||
if (squelch is not null)
|
||||
{
|
||||
dispatcher.Register(GameEventType.SetSquelchDB, e =>
|
||||
{
|
||||
SquelchDatabase? database = SocialStateMessages.ParseSquelchDatabase(e.Payload.Span);
|
||||
if (database is not null) squelch.Replace(database);
|
||||
});
|
||||
}
|
||||
|
||||
// ── TurbineChat channel list (0x0295 SetTurbineChatChannels) ─────
|
||||
// Phase I.6: arrives once at login (and after chat-server reconnect)
|
||||
|
|
@ -251,6 +292,7 @@ public static class GameEventWiring
|
|||
{
|
||||
var p = GameEvents.ParseWieldObject(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
|
||||
uint wielderGuid = p.Value.WielderGuid != 0u
|
||||
? p.Value.WielderGuid
|
||||
: (playerGuid?.Invoke() ?? 0u);
|
||||
|
|
@ -264,6 +306,7 @@ public static class GameEventWiring
|
|||
{
|
||||
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
|
||||
items.MoveItem(
|
||||
p.Value.ItemGuid,
|
||||
p.Value.ContainerGuid,
|
||||
|
|
@ -385,6 +428,8 @@ public static class GameEventWiring
|
|||
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
|
||||
if (p is null) return;
|
||||
|
||||
onDesiredComponents?.Invoke(p.Value.DesiredComps);
|
||||
|
||||
// B-Wire: deliver the player's OWN properties to the player ClientObject.
|
||||
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
|
||||
// CreateObject; the player's own stats legitimately come from PD.) Upsert
|
||||
|
|
@ -412,6 +457,15 @@ public static class GameEventWiring
|
|||
if (localPlayer is not null)
|
||||
{
|
||||
localPlayer.OnProperties(p.Value.Properties);
|
||||
localPlayer.OnPositions(p.Value.Positions.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => new AcDream.Core.Physics.Position(
|
||||
pair.Value.LandblockId,
|
||||
new System.Numerics.Vector3(
|
||||
pair.Value.X, pair.Value.Y, pair.Value.Z),
|
||||
new System.Numerics.Quaternion(
|
||||
pair.Value.Qx, pair.Value.Qy,
|
||||
pair.Value.Qz, pair.Value.Qw))));
|
||||
|
||||
foreach (var attr in p.Value.Attributes)
|
||||
{
|
||||
|
|
|
|||
235
src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
Normal file
235
src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Wire builders for game actions initiated by retail client-owned chat
|
||||
/// commands. Keeping this family separate from generic object interaction
|
||||
/// makes the command boundary explicit while reusing the normal
|
||||
/// <c>0xF7B1</c> game-action envelope.
|
||||
/// </summary>
|
||||
public static class ClientCommandRequests
|
||||
{
|
||||
public const uint MarketplaceOpcode = 0x028Du;
|
||||
public const uint PkArenaOpcode = 0x0027u;
|
||||
public const uint PkLiteArenaOpcode = 0x0026u;
|
||||
public const uint HouseRecallOpcode = 0x0262u;
|
||||
public const uint MansionRecallOpcode = 0x0278u;
|
||||
public const uint QueryAgeOpcode = 0x01C2u;
|
||||
public const uint QueryBirthOpcode = 0x01C4u;
|
||||
public const uint ConfirmationResponseOpcode = 0x0275u;
|
||||
public const uint SuicideOpcode = 0x0279u;
|
||||
public const uint SetAfkModeOpcode = 0x000Fu;
|
||||
public const uint SetAfkMessageOpcode = 0x0010u;
|
||||
public const uint EmoteOpcode = 0x01DFu;
|
||||
public const uint AddFriendOpcode = 0x0018u;
|
||||
public const uint RemoveFriendOpcode = 0x0017u;
|
||||
public const uint ClearFriendsOpcode = 0x0025u;
|
||||
public const uint ModifyCharacterSquelchOpcode = 0x0058u;
|
||||
public const uint ModifyAccountSquelchOpcode = 0x0059u;
|
||||
public const uint ModifyGlobalSquelchOpcode = 0x005Bu;
|
||||
public const uint ClearConsentOpcode = 0x0216u;
|
||||
public const uint DisplayConsentOpcode = 0x0217u;
|
||||
public const uint RemoveConsentOpcode = 0x0218u;
|
||||
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
|
||||
public const uint LegacyFriendsOpcode = 0xF7CDu;
|
||||
|
||||
// Named-retail anchors:
|
||||
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
|
||||
// CM_Character::Event_TeleToPKArena @ 0x006A1CB0
|
||||
// CM_Character::Event_TeleToPKLArena @ 0x006A1D40
|
||||
// CM_House::Event_TeleToHouse_Event @ 0x006AAFA0
|
||||
// CM_House::Event_TeleToMansion_Event @ 0x006AB030
|
||||
public static byte[] BuildMarketplace(uint sequence) =>
|
||||
BuildParameterless(sequence, MarketplaceOpcode);
|
||||
|
||||
public static byte[] BuildPkArena(uint sequence) =>
|
||||
BuildParameterless(sequence, PkArenaOpcode);
|
||||
|
||||
public static byte[] BuildPkLiteArena(uint sequence) =>
|
||||
BuildParameterless(sequence, PkLiteArenaOpcode);
|
||||
|
||||
public static byte[] BuildHouseRecall(uint sequence) =>
|
||||
BuildParameterless(sequence, HouseRecallOpcode);
|
||||
|
||||
public static byte[] BuildMansionRecall(uint sequence) =>
|
||||
BuildParameterless(sequence, MansionRecallOpcode);
|
||||
|
||||
// Named-retail anchors:
|
||||
// CM_Character::Event_QueryAge @ 0x006A1620
|
||||
// CM_Character::Event_QueryBirth @ 0x006A16F0
|
||||
public static byte[] BuildQueryAge(uint sequence, uint objectId = 0u) =>
|
||||
BuildObjectQuery(sequence, QueryAgeOpcode, objectId);
|
||||
|
||||
public static byte[] BuildQueryBirth(uint sequence, uint objectId = 0u) =>
|
||||
BuildObjectQuery(sequence, QueryBirthOpcode, objectId);
|
||||
|
||||
// CM_Character::Event_ConfirmationResponse @ 0x006A1210.
|
||||
public static byte[] BuildConfirmationResponse(
|
||||
uint sequence,
|
||||
uint confirmationType,
|
||||
uint contextId,
|
||||
bool accepted)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), ConfirmationResponseOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), confirmationType);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), contextId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), accepted ? 1u : 0u);
|
||||
return body;
|
||||
}
|
||||
|
||||
// CM_Character::Event_Suicide @ 0x006A1B00.
|
||||
public static byte[] BuildSuicide(uint sequence) =>
|
||||
BuildParameterless(sequence, SuicideOpcode);
|
||||
|
||||
// CM_Communication::Event_SetAFKMode @ 0x006A3EE0.
|
||||
public static byte[] BuildSetAfkMode(uint sequence, bool away) =>
|
||||
BuildUInt32(sequence, SetAfkModeOpcode, away ? 1u : 0u);
|
||||
|
||||
// CM_Communication::Event_SetAFKMessage @ 0x006A4440.
|
||||
public static byte[] BuildSetAfkMessage(uint sequence, string message) =>
|
||||
BuildString(sequence, SetAfkMessageOpcode, message);
|
||||
|
||||
// CM_Communication::Event_Emote @ 0x006A4120.
|
||||
public static byte[] BuildEmote(uint sequence, string message) =>
|
||||
BuildString(sequence, EmoteOpcode, message);
|
||||
|
||||
// CM_Social::Event_AddFriend/RemoveFriend/ClearFriends
|
||||
// @ 0x006A5C10 / 0x006A5650 / 0x006A55C0.
|
||||
public static byte[] BuildAddFriend(uint sequence, string name) =>
|
||||
BuildString(sequence, AddFriendOpcode, name);
|
||||
|
||||
public static byte[] BuildRemoveFriend(uint sequence, uint friendId) =>
|
||||
BuildUInt32(sequence, RemoveFriendOpcode, friendId);
|
||||
|
||||
public static byte[] BuildClearFriends(uint sequence) =>
|
||||
BuildParameterless(sequence, ClearFriendsOpcode);
|
||||
|
||||
// Proto_UI::SendFriendsCommand @ 0x00546C50 is a control message,
|
||||
// not a 0xF7B1 GameAction. Command zero requests the legacy listing.
|
||||
public static byte[] BuildLegacyFriendsCommand(uint command, string name)
|
||||
{
|
||||
byte[] packedName = PackString16L(name);
|
||||
byte[] body = new byte[8 + packedName.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, LegacyFriendsOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), command);
|
||||
packedName.CopyTo(body, 8);
|
||||
return body;
|
||||
}
|
||||
|
||||
// CM_Communication::Event_ModifyCharacterSquelch @ 0x006A42D0.
|
||||
public static byte[] BuildModifyCharacterSquelch(
|
||||
uint sequence,
|
||||
bool add,
|
||||
uint characterId,
|
||||
string name,
|
||||
uint messageType)
|
||||
{
|
||||
byte[] packedName = PackString16L(name);
|
||||
byte[] body = CreateBody(sequence, ModifyCharacterSquelchOpcode, 12 + packedName.Length);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), characterId);
|
||||
packedName.CopyTo(body, 20);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20 + packedName.Length), messageType);
|
||||
return body;
|
||||
}
|
||||
|
||||
// CM_Communication::Event_ModifyAccountSquelch @ 0x006A41E0.
|
||||
public static byte[] BuildModifyAccountSquelch(uint sequence, bool add, string name)
|
||||
{
|
||||
byte[] packedName = PackString16L(name);
|
||||
byte[] body = CreateBody(sequence, ModifyAccountSquelchOpcode, 4 + packedName.Length);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
|
||||
packedName.CopyTo(body, 16);
|
||||
return body;
|
||||
}
|
||||
|
||||
// CM_Communication::Event_ModifyGlobalSquelch @ 0x006A3D00.
|
||||
public static byte[] BuildModifyGlobalSquelch(uint sequence, bool add, uint messageType)
|
||||
{
|
||||
byte[] body = CreateBody(sequence, ModifyGlobalSquelchOpcode, 8);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), messageType);
|
||||
return body;
|
||||
}
|
||||
|
||||
// CM_Character consent events @ 0x006A1180 / 0x006A1360 / 0x006A2C10.
|
||||
public static byte[] BuildClearConsent(uint sequence) =>
|
||||
BuildParameterless(sequence, ClearConsentOpcode);
|
||||
|
||||
public static byte[] BuildDisplayConsent(uint sequence) =>
|
||||
BuildParameterless(sequence, DisplayConsentOpcode);
|
||||
|
||||
public static byte[] BuildRemoveConsent(uint sequence, string name) =>
|
||||
BuildString(sequence, RemoveConsentOpcode, name);
|
||||
|
||||
// CM_Character::Event_SetDesiredComponentLevel @ 0x006A2350.
|
||||
public static byte[] BuildSetDesiredComponentLevel(
|
||||
uint sequence, uint componentId, uint amount)
|
||||
{
|
||||
byte[] body = CreateBody(sequence, SetDesiredComponentLevelOpcode, 8);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), componentId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), amount);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] BuildParameterless(uint sequence, uint opcode)
|
||||
{
|
||||
byte[] body = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] BuildUInt32(uint sequence, uint opcode, uint value)
|
||||
{
|
||||
byte[] body = CreateBody(sequence, opcode, 4);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), value);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] BuildString(uint sequence, uint opcode, string value)
|
||||
{
|
||||
byte[] packed = PackString16L(value);
|
||||
byte[] body = CreateBody(sequence, opcode, packed.Length);
|
||||
packed.CopyTo(body, 12);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] CreateBody(uint sequence, uint opcode, int payloadLength)
|
||||
{
|
||||
byte[] body = new byte[12 + payloadLength];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] PackString16L(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
byte[] data = Encoding.GetEncoding(1252).GetBytes(value);
|
||||
if (data.Length > ushort.MaxValue)
|
||||
throw new ArgumentException("String too long for String16L.", nameof(value));
|
||||
int unpadded = 2 + data.Length;
|
||||
byte[] packed = new byte[(unpadded + 3) & ~3];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(packed, (ushort)data.Length);
|
||||
data.CopyTo(packed, 2);
|
||||
return packed;
|
||||
}
|
||||
|
||||
private static byte[] BuildObjectQuery(uint sequence, uint opcode, uint objectId)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), objectId);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,25 @@ public static class GameEvents
|
|||
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 0x01C3 QueryAgeResponse: target name (empty for self), then the
|
||||
/// server-formatted played duration. Retail source:
|
||||
/// <c>CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40</c>.
|
||||
/// </summary>
|
||||
public readonly record struct QueryAgeResponse(string Name, string Age);
|
||||
|
||||
public static QueryAgeResponse? ParseQueryAgeResponse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try
|
||||
{
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
string age = ReadString16L(payload, ref pos);
|
||||
return new QueryAgeResponse(name, age);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ── Errors ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>0x028A WeenieError: generic game-logic failure code.</summary>
|
||||
|
|
@ -470,20 +489,18 @@ public static class GameEvents
|
|||
public readonly record struct CharacterConfirmationRequest(
|
||||
uint Type,
|
||||
uint ContextId,
|
||||
uint OtherGuid,
|
||||
string Message);
|
||||
|
||||
public static CharacterConfirmationRequest? ParseCharacterConfirmationRequest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 12) return null;
|
||||
if (payload.Length < 8) return null;
|
||||
int pos = 0;
|
||||
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload); pos += 4;
|
||||
uint contextId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint otherGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
try
|
||||
{
|
||||
string msg = ReadString16L(payload, ref pos);
|
||||
return new CharacterConfirmationRequest(type, contextId, otherGuid, msg);
|
||||
return new CharacterConfirmationRequest(type, contextId, msg);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
|
|
|||
138
src/AcDream.Core.Net/Messages/SocialStateMessages.cs
Normal file
138
src/AcDream.Core.Net/Messages/SocialStateMessages.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Social;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail social-state parsers. Sources: <c>CM_Social::DispatchUI_FriendsUpdate
|
||||
/// @ 0x006A5DD0</c>, <c>FriendData::UnPack @ 0x005B9D20</c>, and
|
||||
/// <c>SquelchDB::UnPack @ 0x006B1900</c>.
|
||||
/// </summary>
|
||||
public static class SocialStateMessages
|
||||
{
|
||||
public static FriendsUpdate? ParseFriendsUpdate(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try
|
||||
{
|
||||
uint count = ReadU32(payload, ref pos);
|
||||
// PList<FriendData>::UnPack @ 0x0048CD30 stores a 32-bit count
|
||||
// and bounds it only by the remaining packet. Keep a generous
|
||||
// allocation guard without inventing the UI's 50-friend policy
|
||||
// at the wire layer.
|
||||
if (count > 65_536) return null;
|
||||
var entries = new List<FriendEntry>((int)count);
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
uint id = ReadU32(payload, ref pos);
|
||||
bool online = ReadU32(payload, ref pos) != 0;
|
||||
bool appearOffline = ReadU32(payload, ref pos) != 0;
|
||||
string name = StringReader.ReadString16L(payload, ref pos);
|
||||
IReadOnlyList<uint> friends = ReadUInt32List(payload, ref pos);
|
||||
IReadOnlyList<uint> friendOf = ReadUInt32List(payload, ref pos);
|
||||
entries.Add(new FriendEntry(id, name, online, appearOffline, friends, friendOf));
|
||||
}
|
||||
|
||||
FriendsUpdateType type = (FriendsUpdateType)ReadU32(payload, ref pos);
|
||||
return Enum.IsDefined(type) ? new FriendsUpdate(type, entries) : null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static SquelchDatabase? ParseSquelchDatabase(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try
|
||||
{
|
||||
IReadOnlyDictionary<string, uint> accounts = ReadAccountTable(payload, ref pos);
|
||||
IReadOnlyDictionary<uint, SquelchInfo> characters = ReadCharacterTable(payload, ref pos);
|
||||
SquelchInfo global = ReadSquelchInfo(payload, ref pos);
|
||||
return pos <= payload.Length
|
||||
? new SquelchDatabase(accounts, characters, global)
|
||||
: null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, uint> ReadAccountTable(
|
||||
ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
|
||||
// A zero-sized retail table is valid only when it is empty (see the
|
||||
// early return in PackableHashTable::UnPack @ 0x006B1A86).
|
||||
if (buckets == 0 && count != 0)
|
||||
throw new FormatException("invalid account squelch table");
|
||||
var result = new Dictionary<string, uint>(count, StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
string name = StringReader.ReadString16L(source, ref pos);
|
||||
result[name] = ReadU32(source, ref pos);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<uint, SquelchInfo> ReadCharacterTable(
|
||||
ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
|
||||
if (buckets == 0 && count != 0)
|
||||
throw new FormatException("invalid character squelch table");
|
||||
var result = new Dictionary<uint, SquelchInfo>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
uint id = ReadU32(source, ref pos);
|
||||
result[id] = ReadSquelchInfo(source, ref pos);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static SquelchInfo ReadSquelchInfo(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
uint wordCount = ReadU32(source, ref pos);
|
||||
if (wordCount > 4) throw new FormatException("invalid vlong word count");
|
||||
var messageTypes = new HashSet<uint>();
|
||||
for (uint word = 0; word < wordCount; word++)
|
||||
{
|
||||
uint bits = ReadU32(source, ref pos);
|
||||
for (uint bit = 0; bit < 32; bit++)
|
||||
{
|
||||
if ((bits & (1u << (int)bit)) != 0)
|
||||
messageTypes.Add(word * 32 + bit);
|
||||
}
|
||||
}
|
||||
|
||||
string name = StringReader.ReadString16L(source, ref pos);
|
||||
bool accountWide = ReadU32(source, ref pos) != 0;
|
||||
return new SquelchInfo(name, accountWide, messageTypes);
|
||||
}
|
||||
|
||||
private static (ushort Count, ushort Buckets) ReadHashHeader(
|
||||
ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
uint header = ReadU32(source, ref pos);
|
||||
return ((ushort)(header & 0xFFFFu), (ushort)(header >> 16));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> ReadUInt32List(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
uint count = ReadU32(source, ref pos);
|
||||
if (count > 10_000) throw new FormatException("invalid packed list count");
|
||||
var result = new uint[count];
|
||||
for (int i = 0; i < result.Length; i++) result[i] = ReadU32(source, ref pos);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos, 4));
|
||||
pos += 4;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1272,6 +1272,153 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(InteractRequests.BuildTeleToLifestone(seq));
|
||||
}
|
||||
|
||||
/// <summary>Send retail marketplace recall (0x028D).</summary>
|
||||
public void SendTeleportToMarketplace()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildMarketplace(seq));
|
||||
}
|
||||
|
||||
/// <summary>Send retail full-PK arena recall (0x0027).</summary>
|
||||
public void SendTeleportToPkArena()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildPkArena(seq));
|
||||
}
|
||||
|
||||
/// <summary>Send retail PKLite arena recall (0x0026).</summary>
|
||||
public void SendTeleportToPkLiteArena()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildPkLiteArena(seq));
|
||||
}
|
||||
|
||||
/// <summary>Send retail personal-house recall (0x0262).</summary>
|
||||
public void SendTeleportToHouse()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildHouseRecall(seq));
|
||||
}
|
||||
|
||||
/// <summary>Send retail allegiance-mansion recall (0x0278).</summary>
|
||||
public void SendTeleportToMansion()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
|
||||
}
|
||||
|
||||
/// <summary>Query the local character's played time (0x01C2).</summary>
|
||||
public void SendQueryAge()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildQueryAge(seq));
|
||||
}
|
||||
|
||||
/// <summary>Query the local character's creation date (0x01C4).</summary>
|
||||
public void SendQueryBirth()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildQueryBirth(seq));
|
||||
}
|
||||
|
||||
/// <summary>Reply to a server confirmation request (0x0275).</summary>
|
||||
public void SendConfirmationResponse(uint confirmationType, uint contextId, bool accepted)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildConfirmationResponse(
|
||||
seq, confirmationType, contextId, accepted));
|
||||
}
|
||||
|
||||
/// <summary>Send the confirmed retail suicide action (0x0279).</summary>
|
||||
public void SendSuicide()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSuicide(seq));
|
||||
}
|
||||
|
||||
public void SendSetAfkMode(bool away)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetAfkMode(seq, away));
|
||||
}
|
||||
|
||||
public void SendSetAfkMessage(string message)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetAfkMessage(seq, message));
|
||||
}
|
||||
|
||||
public void SendEmote(string message)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildEmote(seq, message));
|
||||
}
|
||||
|
||||
public void SendAddFriend(string name)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAddFriend(seq, name));
|
||||
}
|
||||
|
||||
public void SendRemoveFriend(uint friendId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveFriend(seq, friendId));
|
||||
}
|
||||
|
||||
public void SendClearFriends()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildClearFriends(seq));
|
||||
}
|
||||
|
||||
public void SendLegacyFriendsListRequest() =>
|
||||
SendControlMessage(ClientCommandRequests.BuildLegacyFriendsCommand(0u, string.Empty));
|
||||
|
||||
public void SendModifyCharacterSquelch(bool add, uint characterId, string name, uint messageType)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildModifyCharacterSquelch(
|
||||
seq, add, characterId, name, messageType));
|
||||
}
|
||||
|
||||
public void SendModifyAccountSquelch(bool add, string name)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildModifyAccountSquelch(seq, add, name));
|
||||
}
|
||||
|
||||
public void SendModifyGlobalSquelch(bool add, uint messageType)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType));
|
||||
}
|
||||
|
||||
public void SendClearConsent()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildClearConsent(seq));
|
||||
}
|
||||
|
||||
public void SendDisplayConsent()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildDisplayConsent(seq));
|
||||
}
|
||||
|
||||
public void SendRemoveConsent(string name)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveConsent(seq, name));
|
||||
}
|
||||
|
||||
public void SendClearDesiredComponents()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
|
||||
seq, componentId: 0u, amount: uint.MaxValue));
|
||||
}
|
||||
|
||||
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
|
||||
public void SendChangeCombatMode(CombatMode mode)
|
||||
{
|
||||
|
|
@ -1517,10 +1664,20 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(body);
|
||||
}
|
||||
|
||||
private void SendGameMessage(byte[] gameMessageBody)
|
||||
private void SendGameMessage(byte[] gameMessageBody) =>
|
||||
SendGameMessage(gameMessageBody, GameMessageGroup.UIQueue);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Proto_UI::SendToControl</c> path used by standalone control
|
||||
/// messages such as the legacy <c>0xF7CD</c> friends request.
|
||||
/// </summary>
|
||||
private void SendControlMessage(byte[] gameMessageBody) =>
|
||||
SendGameMessage(gameMessageBody, GameMessageGroup.ControlQueue);
|
||||
|
||||
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
|
||||
{
|
||||
var fragment = GameMessageFragment.BuildSingleFragment(
|
||||
_fragmentSequence++, GameMessageGroup.UIQueue, gameMessageBody);
|
||||
_fragmentSequence++, queue, gameMessageBody);
|
||||
byte[] packetBody = GameMessageFragment.Serialize(fragment);
|
||||
var header = new PacketHeader
|
||||
{
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ public static class WeenieErrorMessages
|
|||
{
|
||||
// Command parser
|
||||
[0x0026] = "That is not a valid command.", // ThatIsNotAValidCommand
|
||||
[0x055F] = "Only Player Killer characters may use this command!",
|
||||
[0x0560] = "Only Player Killer Lite characters may use this command!",
|
||||
|
||||
// Tell-related
|
||||
[0x052B] = "That person is not available now.", // CharacterNotAvailable
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Player;
|
||||
|
|
@ -108,6 +109,7 @@ public sealed class LocalPlayerState
|
|||
private VitalSnapshot? _mana;
|
||||
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
|
||||
private readonly Dictionary<uint, SkillSnapshot> _skills = new();
|
||||
private readonly Dictionary<uint, Position> _positions = new();
|
||||
private PropertyBundle _properties = new();
|
||||
private readonly Spellbook? _spellbook;
|
||||
|
||||
|
|
@ -185,6 +187,20 @@ public sealed class LocalPlayerState
|
|||
/// <summary>All known skill snapshots, keyed by SkillId.</summary>
|
||||
public IReadOnlyDictionary<uint, SkillSnapshot> Skills => _skills;
|
||||
|
||||
/// <summary>Player Position qualities keyed by retail PositionType.</summary>
|
||||
public IReadOnlyDictionary<uint, Position> Positions => _positions;
|
||||
|
||||
public Position? GetPosition(uint positionType) =>
|
||||
_positions.TryGetValue(positionType, out var value) ? value : null;
|
||||
|
||||
public void OnPositions(IReadOnlyDictionary<uint, Position> positions)
|
||||
{
|
||||
_positions.Clear();
|
||||
foreach (var pair in positions)
|
||||
_positions[pair.Key] = pair.Value;
|
||||
CharacterChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Snapshot for one skill, or <c>null</c> if it has not arrived yet.</summary>
|
||||
public SkillSnapshot? GetSkill(uint skillId) =>
|
||||
_skills.TryGetValue(skillId, out var s) ? s : null;
|
||||
|
|
|
|||
74
src/AcDream.Core/Social/FriendsState.cs
Normal file
74
src/AcDream.Core/Social/FriendsState.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
namespace AcDream.Core.Social;
|
||||
|
||||
/// <summary>Authoritative client-side copy of retail's <c>FriendDataList</c>.</summary>
|
||||
public sealed class FriendsState
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<FriendEntry> _entries = new();
|
||||
|
||||
public IReadOnlyList<FriendEntry> Snapshot()
|
||||
{
|
||||
lock (_gate) return _entries.ToArray();
|
||||
}
|
||||
|
||||
public void Apply(FriendsUpdate update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
lock (_gate)
|
||||
{
|
||||
switch (update.Type)
|
||||
{
|
||||
case FriendsUpdateType.Full:
|
||||
_entries.Clear();
|
||||
_entries.AddRange(update.Entries);
|
||||
break;
|
||||
case FriendsUpdateType.Add:
|
||||
foreach (FriendEntry entry in update.Entries)
|
||||
{
|
||||
int index = _entries.FindIndex(candidate => candidate.Id == entry.Id);
|
||||
if (index >= 0) _entries[index] = entry;
|
||||
else _entries.Add(entry);
|
||||
}
|
||||
break;
|
||||
case FriendsUpdateType.Remove:
|
||||
case FriendsUpdateType.RemoveSilent:
|
||||
foreach (FriendEntry entry in update.Entries)
|
||||
_entries.RemoveAll(candidate => candidate.Id == entry.Id);
|
||||
break;
|
||||
case FriendsUpdateType.OnlineStatus:
|
||||
foreach (FriendEntry entry in update.Entries)
|
||||
{
|
||||
int index = _entries.FindIndex(candidate => candidate.Id == entry.Id);
|
||||
if (index >= 0) _entries[index] = entry;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate) _entries.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record FriendEntry(
|
||||
uint Id,
|
||||
string Name,
|
||||
bool Online,
|
||||
bool AppearOffline,
|
||||
IReadOnlyList<uint> Friends,
|
||||
IReadOnlyList<uint> FriendOf);
|
||||
|
||||
public enum FriendsUpdateType : uint
|
||||
{
|
||||
Full = 0,
|
||||
Add = 1,
|
||||
Remove = 2,
|
||||
RemoveSilent = 3,
|
||||
OnlineStatus = 4,
|
||||
}
|
||||
|
||||
public sealed record FriendsUpdate(
|
||||
FriendsUpdateType Type,
|
||||
IReadOnlyList<FriendEntry> Entries);
|
||||
35
src/AcDream.Core/Social/SquelchState.cs
Normal file
35
src/AcDream.Core/Social/SquelchState.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
namespace AcDream.Core.Social;
|
||||
|
||||
/// <summary>Authoritative copy of retail's server-supplied <c>SquelchDB</c>.</summary>
|
||||
public sealed class SquelchState
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private SquelchDatabase _database = SquelchDatabase.Empty;
|
||||
|
||||
public SquelchDatabase Snapshot()
|
||||
{
|
||||
lock (_gate) return _database;
|
||||
}
|
||||
|
||||
public void Replace(SquelchDatabase database)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
lock (_gate) _database = database;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SquelchInfo(
|
||||
string Name,
|
||||
bool AccountWide,
|
||||
IReadOnlySet<uint> MessageTypes);
|
||||
|
||||
public sealed record SquelchDatabase(
|
||||
IReadOnlyDictionary<string, uint> Accounts,
|
||||
IReadOnlyDictionary<uint, SquelchInfo> Characters,
|
||||
SquelchInfo Global)
|
||||
{
|
||||
public static SquelchDatabase Empty { get; } = new(
|
||||
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<uint, SquelchInfo>(),
|
||||
new SquelchInfo(string.Empty, false, new HashSet<uint>()));
|
||||
}
|
||||
31
src/AcDream.Core/Ui/RetailPositionFormatter.cs
Normal file
31
src/AcDream.Core/Ui/RetailPositionFormatter.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Ui;
|
||||
|
||||
/// <summary>Retail text formatting for world positions and outdoor cells.</summary>
|
||||
public static class RetailPositionFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Verbatim field order and precision from
|
||||
/// <c>Position::ToString @ 0x005A9330</c>.
|
||||
/// </summary>
|
||||
public static string Format(Position position)
|
||||
{
|
||||
var p = position.Frame.Origin;
|
||||
var q = position.Frame.Orientation;
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"0x{position.ObjCellId:X8} [{p.X:F6} {p.Y:F6} {p.Z:F6}] " +
|
||||
$"{q.W:F6} {q.X:F6} {q.Y:F6} {q.Z:F6}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>LandDefs::CellidToCoordinateString @ 0x005A9CB0</c>.
|
||||
/// Returns null for indoor/invalid cells, matching the underlying
|
||||
/// <c>gid_to_lcoord</c> failure.
|
||||
/// </summary>
|
||||
public static string? FormatOutdoorCell(uint cellId) =>
|
||||
RadarCoordinates.TryFromCell(cellId, out var coordinates)
|
||||
? coordinates.CombinedText
|
||||
: null;
|
||||
}
|
||||
|
|
@ -8,4 +8,36 @@ public enum ClientCommandId
|
|||
{
|
||||
/// <summary>Recall to the character's bound lifestone.</summary>
|
||||
LifestoneRecall,
|
||||
|
||||
MarketplaceRecall,
|
||||
PkArenaRecall,
|
||||
PkLiteArenaRecall,
|
||||
HouseRecall,
|
||||
MansionRecall,
|
||||
QueryAge,
|
||||
QueryBirth,
|
||||
ToggleFrameRate,
|
||||
ToggleUiLock,
|
||||
ShowVersion,
|
||||
ShowLocation,
|
||||
ShowLastCorpseLocation,
|
||||
Die,
|
||||
ClearChat,
|
||||
SaveUi,
|
||||
LoadUi,
|
||||
SaveAutoUi,
|
||||
LoadAutoUi,
|
||||
Away,
|
||||
Consent,
|
||||
Emote,
|
||||
ListEmotes,
|
||||
Friends,
|
||||
FriendsAdd,
|
||||
FriendsRemove,
|
||||
Squelch,
|
||||
Unsquelch,
|
||||
Filter,
|
||||
Unfilter,
|
||||
ListMessageTypes,
|
||||
FillComponents,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ public static class ChatCommandRouter
|
|||
{
|
||||
if (!clientCommand.HasValidArguments)
|
||||
{
|
||||
vm.ShowSystemMessage($"Usage: {clientCommand.Usage}");
|
||||
vm.ShowSystemMessage(clientCommand.InvalidArgumentsText
|
||||
?? $"Usage: {clientCommand.Usage}");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
|
|
@ -98,24 +99,6 @@ public static class ChatCommandRouter
|
|||
return true;
|
||||
}
|
||||
|
||||
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
|
||||
{
|
||||
vm.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (EqAny(trimmed, "/framerate", "@framerate"))
|
||||
{
|
||||
vm.ShowFps();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (EqAny(trimmed, "/loc", "@loc"))
|
||||
{
|
||||
vm.ShowLocation();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
|||
/// This is intentionally separate from chat aliases and ACE server commands.
|
||||
///
|
||||
/// <para>
|
||||
/// First vertical slice: retail registers <c>lifestone</c>, <c>lif</c>, and
|
||||
/// <c>ls</c> against <c>ClientCommunicationSystem::DoLifestone @ 0x0056FC70</c>.
|
||||
/// See <c>docs/research/2026-07-13-retail-client-command-routing-pseudocode.md</c>.
|
||||
/// Aliases and ownership come from the named-retail command table. Packet
|
||||
/// and local-state behavior is recorded in the command-family pseudocode
|
||||
/// notes under <c>docs/research/</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailClientCommandCatalog
|
||||
|
|
@ -18,14 +18,16 @@ public static class RetailClientCommandCatalog
|
|||
ClientCommandId Command,
|
||||
string Usage,
|
||||
string HelpText,
|
||||
Func<string, bool> ValidateArguments);
|
||||
Func<string, bool> ValidateArguments,
|
||||
string? InvalidArgumentsText = null);
|
||||
|
||||
/// <summary>A resolved retail command plus its raw, trimmed argument text.</summary>
|
||||
public readonly record struct Match(
|
||||
ClientCommandId Command,
|
||||
string Arguments,
|
||||
string Usage,
|
||||
bool HasValidArguments);
|
||||
bool HasValidArguments,
|
||||
string? InvalidArgumentsText);
|
||||
|
||||
private static readonly Definition Lifestone = new(
|
||||
ClientCommandId.LifestoneRecall,
|
||||
|
|
@ -33,12 +35,212 @@ public static class RetailClientCommandCatalog
|
|||
HelpText: "/lifestone (/lif, /ls) - Returns you to the last lifestone you used without killing you.",
|
||||
ValidateArguments: static arguments => arguments.Length == 0);
|
||||
|
||||
private static readonly Definition Marketplace = NoArguments(
|
||||
ClientCommandId.MarketplaceRecall,
|
||||
"/marketplace",
|
||||
"/marketplace (/mar, /mp) - Teleports you to the Marketplace of Dereth.");
|
||||
|
||||
private static readonly Definition PkArena = NoArguments(
|
||||
ClientCommandId.PkArenaRecall,
|
||||
"/pkarena",
|
||||
"/pkarena (/pka) - Teleports a Player Killer to the PK Arena.");
|
||||
|
||||
private static readonly Definition PkLiteArena = NoArguments(
|
||||
ClientCommandId.PkLiteArenaRecall,
|
||||
"/pklarena",
|
||||
"/pklarena (/pla) - Teleports a PKLite player to the PKLite Arena.");
|
||||
|
||||
private static readonly Definition HouseRecall = NoArguments(
|
||||
ClientCommandId.HouseRecall,
|
||||
"/house recall",
|
||||
"/house recall (/hor, /hr) - Teleports you to your house.");
|
||||
|
||||
private static readonly Definition MansionRecall = NoArguments(
|
||||
ClientCommandId.MansionRecall,
|
||||
"/house mansion_recall",
|
||||
"/house mansion_recall (/hom, /hoa) - Teleports you to your allegiance mansion.");
|
||||
|
||||
private static readonly Definition QueryAge = NoArguments(
|
||||
ClientCommandId.QueryAge,
|
||||
"/age",
|
||||
"/age - Displays how long your character has been played.");
|
||||
|
||||
private static readonly Definition QueryBirth = NoArguments(
|
||||
ClientCommandId.QueryBirth,
|
||||
"/birth",
|
||||
"/birth - Displays when your character was created.");
|
||||
|
||||
private static readonly Definition FrameRate = NoArguments(
|
||||
ClientCommandId.ToggleFrameRate,
|
||||
"/framerate",
|
||||
"/framerate - Toggles the framerate display.");
|
||||
|
||||
private static readonly Definition LockUi = NoArguments(
|
||||
ClientCommandId.ToggleUiLock,
|
||||
"/lockui",
|
||||
"/lockui - Toggles whether the interface can be moved or resized.");
|
||||
|
||||
private static readonly Definition Version = NoArguments(
|
||||
ClientCommandId.ShowVersion,
|
||||
"/version",
|
||||
"/version - Displays the client version.");
|
||||
|
||||
private static readonly Definition Location = NoArguments(
|
||||
ClientCommandId.ShowLocation,
|
||||
"/loc",
|
||||
"/loc - Displays your current position.");
|
||||
|
||||
private static readonly Definition Corpse = new(
|
||||
ClientCommandId.ShowLastCorpseLocation,
|
||||
Usage: "/corpse",
|
||||
HelpText: "/corpse (/cor) - Displays the location of your last outdoor death.",
|
||||
// DoCorpse @ 0x00578220 ignores its argument count.
|
||||
ValidateArguments: static _ => true);
|
||||
|
||||
private static readonly Definition Die = new(
|
||||
ClientCommandId.Die,
|
||||
Usage: "/die",
|
||||
HelpText: "/die - Kills your character after confirmation.",
|
||||
ValidateArguments: static arguments => arguments.Length == 0,
|
||||
InvalidArgumentsText: "Please see @help die for more information on how to use this command.");
|
||||
|
||||
private static readonly Definition Clear = AnyArguments(
|
||||
ClientCommandId.ClearChat,
|
||||
"/clear [all]",
|
||||
"/clear [all] - Clears the current chat window, or every chat window.");
|
||||
|
||||
private static readonly Definition SaveUi = AnyArguments(
|
||||
ClientCommandId.SaveUi,
|
||||
"/saveui [filename]",
|
||||
"/saveui [filename] - Saves the current interface layout.");
|
||||
|
||||
private static readonly Definition LoadUi = AnyArguments(
|
||||
ClientCommandId.LoadUi,
|
||||
"/loadui [filename]",
|
||||
"/loadui [filename] - Loads a saved interface layout.");
|
||||
|
||||
private static readonly Definition SaveAutoUi = AnyArguments(
|
||||
ClientCommandId.SaveAutoUi,
|
||||
"/saveautoui",
|
||||
"/saveautoui - Saves the automatic character-and-resolution interface layout.");
|
||||
|
||||
private static readonly Definition LoadAutoUi = AnyArguments(
|
||||
ClientCommandId.LoadAutoUi,
|
||||
"/loadautoui",
|
||||
"/loadautoui - Loads the automatic character-and-resolution interface layout.");
|
||||
|
||||
private static readonly Definition Away = AnyArguments(
|
||||
ClientCommandId.Away,
|
||||
"/afk [on|off|msg <message>]",
|
||||
"/afk [on|off|msg <message>] - Sets your away-from-keyboard status.");
|
||||
|
||||
private static readonly Definition Consent = AnyArguments(
|
||||
ClientCommandId.Consent,
|
||||
"/consent <on|off|who|clear|remove <name>>",
|
||||
"/consent - Manages corpse-looting consent.");
|
||||
|
||||
private static readonly Definition Emote = AnyArguments(
|
||||
ClientCommandId.Emote,
|
||||
"/emote <text>",
|
||||
"/emote (/e, /em, /me) - Performs a text emote.");
|
||||
|
||||
private static readonly Definition Emotes = NoArguments(
|
||||
ClientCommandId.ListEmotes,
|
||||
"/emotes",
|
||||
"/emotes - Lists all standard emotes.");
|
||||
|
||||
private static readonly Definition Friends = AnyArguments(
|
||||
ClientCommandId.Friends,
|
||||
"/friends [add|remove|online|old]",
|
||||
"/friends - Helps you manage your friends list.");
|
||||
|
||||
private static readonly Definition FriendsAdd = AnyArguments(
|
||||
ClientCommandId.FriendsAdd,
|
||||
"/friends_add <name>",
|
||||
"/friends_add <name> - Adds a character to your friends list.");
|
||||
|
||||
private static readonly Definition FriendsRemove = AnyArguments(
|
||||
ClientCommandId.FriendsRemove,
|
||||
"/friends_remove <name|-all>",
|
||||
"/friends_remove <name|-all> - Removes friends from your list.");
|
||||
|
||||
private static readonly Definition Squelch = AnyArguments(
|
||||
ClientCommandId.Squelch,
|
||||
"/squelch [options] <name>",
|
||||
"/squelch - Ignores messages from a player or account.");
|
||||
|
||||
private static readonly Definition Unsquelch = AnyArguments(
|
||||
ClientCommandId.Unsquelch,
|
||||
"/unsquelch [options] <name>",
|
||||
"/unsquelch - Stops ignoring messages from a player or account.");
|
||||
|
||||
private static readonly Definition Filter = AnyArguments(
|
||||
ClientCommandId.Filter,
|
||||
"/filter -<message type>",
|
||||
"/filter -<message type> - Globally hides a message category.");
|
||||
|
||||
private static readonly Definition Unfilter = AnyArguments(
|
||||
ClientCommandId.Unfilter,
|
||||
"/unfilter -<message type>",
|
||||
"/unfilter -<message type> - Shows a globally hidden message category.");
|
||||
|
||||
private static readonly Definition MessageTypes = NoArguments(
|
||||
ClientCommandId.ListMessageTypes,
|
||||
"/messagetypes",
|
||||
"/messagetypes - Lists valid filter and squelch message types.");
|
||||
|
||||
private static readonly Definition FillComponents = AnyArguments(
|
||||
ClientCommandId.FillComponents,
|
||||
"/fillcomps [component type] [pyreal value]",
|
||||
"/fillcomps - Helps you buy components in bulk.");
|
||||
|
||||
private static readonly FrozenDictionary<string, Definition> ByVerb =
|
||||
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["lifestone"] = Lifestone,
|
||||
["lif"] = Lifestone,
|
||||
["ls"] = Lifestone,
|
||||
["marketplace"] = Marketplace,
|
||||
["mar"] = Marketplace,
|
||||
["mp"] = Marketplace,
|
||||
["pkarena"] = PkArena,
|
||||
["pka"] = PkArena,
|
||||
["pklarena"] = PkLiteArena,
|
||||
["pla"] = PkLiteArena,
|
||||
["hor"] = HouseRecall,
|
||||
["hr"] = HouseRecall,
|
||||
["hom"] = MansionRecall,
|
||||
["hoa"] = MansionRecall,
|
||||
["age"] = QueryAge,
|
||||
["birth"] = QueryBirth,
|
||||
["framerate"] = FrameRate,
|
||||
["lockui"] = LockUi,
|
||||
["version"] = Version,
|
||||
["loc"] = Location,
|
||||
["corpse"] = Corpse,
|
||||
["cor"] = Corpse,
|
||||
["die"] = Die,
|
||||
["clear"] = Clear,
|
||||
["saveui"] = SaveUi,
|
||||
["loadui"] = LoadUi,
|
||||
["saveautoui"] = SaveAutoUi,
|
||||
["loadautoui"] = LoadAutoUi,
|
||||
["afk"] = Away,
|
||||
["consent"] = Consent,
|
||||
["e"] = Emote,
|
||||
["em"] = Emote,
|
||||
["emote"] = Emote,
|
||||
["me"] = Emote,
|
||||
["emotes"] = Emotes,
|
||||
["friends"] = Friends,
|
||||
["friends_add"] = FriendsAdd,
|
||||
["friends_remove"] = FriendsRemove,
|
||||
["squelch"] = Squelch,
|
||||
["unsquelch"] = Unsquelch,
|
||||
["filter"] = Filter,
|
||||
["unfilter"] = Unfilter,
|
||||
["messagetypes"] = MessageTypes,
|
||||
["fillcomps"] = FillComponents,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -60,22 +262,88 @@ public static class RetailClientCommandCatalog
|
|||
string verb = separator < 0
|
||||
? trimmed[1..]
|
||||
: trimmed.Substring(1, separator - 1);
|
||||
if (!ByVerb.TryGetValue(verb, out Definition? definition))
|
||||
return false;
|
||||
|
||||
string arguments = separator < 0
|
||||
? string.Empty
|
||||
: trimmed[(separator + 1)..].Trim();
|
||||
|
||||
Definition? definition;
|
||||
if (verb.Equals("house", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
definition = arguments.ToLowerInvariant() switch
|
||||
{
|
||||
"recall" => HouseRecall,
|
||||
"mansion_recall" or "alleg_recall" => MansionRecall,
|
||||
_ => null,
|
||||
};
|
||||
if (definition is null)
|
||||
{
|
||||
match = new Match(
|
||||
ClientCommandId.HouseRecall,
|
||||
arguments,
|
||||
"/house recall | /house mansion_recall",
|
||||
HasValidArguments: false,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The subcommand selected the operation; its own handler has no
|
||||
// additional arguments.
|
||||
arguments = string.Empty;
|
||||
}
|
||||
else if (!ByVerb.TryGetValue(verb, out definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
match = new Match(
|
||||
definition.Command,
|
||||
arguments,
|
||||
definition.Usage,
|
||||
definition.ValidateArguments(arguments));
|
||||
definition.ValidateArguments(arguments),
|
||||
definition.InvalidArgumentsText);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Help line generated from the same definition routing uses.</summary>
|
||||
public static string BuildHelpText() => Lifestone.HelpText;
|
||||
public static string BuildHelpText() => string.Join("\n ",
|
||||
Lifestone.HelpText,
|
||||
Marketplace.HelpText,
|
||||
PkArena.HelpText,
|
||||
PkLiteArena.HelpText,
|
||||
HouseRecall.HelpText,
|
||||
MansionRecall.HelpText,
|
||||
QueryAge.HelpText,
|
||||
QueryBirth.HelpText,
|
||||
FrameRate.HelpText,
|
||||
LockUi.HelpText,
|
||||
Version.HelpText,
|
||||
Location.HelpText,
|
||||
Corpse.HelpText,
|
||||
Die.HelpText,
|
||||
Clear.HelpText,
|
||||
SaveUi.HelpText,
|
||||
LoadUi.HelpText,
|
||||
SaveAutoUi.HelpText,
|
||||
LoadAutoUi.HelpText,
|
||||
Away.HelpText,
|
||||
Consent.HelpText,
|
||||
Emote.HelpText,
|
||||
Emotes.HelpText,
|
||||
Friends.HelpText,
|
||||
Squelch.HelpText,
|
||||
Unsquelch.HelpText,
|
||||
Filter.HelpText,
|
||||
Unfilter.HelpText,
|
||||
MessageTypes.HelpText,
|
||||
FillComponents.HelpText);
|
||||
|
||||
private static Definition NoArguments(
|
||||
ClientCommandId command, string usage, string helpText) =>
|
||||
new(command, usage, helpText, static arguments => arguments.Length == 0);
|
||||
|
||||
private static Definition AnyArguments(
|
||||
ClientCommandId command, string usage, string helpText) =>
|
||||
new(command, usage, helpText, static _ => true);
|
||||
|
||||
private static int IndexOfWhitespace(string value)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ public sealed record GameplaySettings(
|
|||
bool ShowHelm, // 0x100000 — render helm overlay on character
|
||||
bool ShowCloak, // 0x800000 — render cloak on character
|
||||
bool LockUI, // 0x1000000 — disable panel drag/resize
|
||||
bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags
|
||||
bool UseMouseTurning, // 0x400000 — turn character when right-mouse drags
|
||||
bool AcceptLootPermits = false) // 0x80000 — accept corpse-looting permissions
|
||||
{
|
||||
/// <summary>Sensible starting values for first launch. NOT bit-exact
|
||||
/// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> +
|
||||
|
|
@ -61,5 +62,7 @@ public sealed record GameplaySettings(
|
|||
ShowHelm: true,
|
||||
ShowCloak: true,
|
||||
LockUI: false,
|
||||
UseMouseTurning: false);
|
||||
UseMouseTurning: false,
|
||||
// Default_CharacterOption 0x50C4A54A leaves bit 0x80000 clear.
|
||||
AcceptLootPermits: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,8 @@ public sealed class SettingsStore
|
|||
ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm),
|
||||
ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak),
|
||||
LockUI: ReadBool(gp, "lockUI", d.LockUI),
|
||||
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning));
|
||||
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning),
|
||||
AcceptLootPermits: ReadBool(gp, "acceptLootPermits", d.AcceptLootPermits));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -419,6 +420,70 @@ public sealed class SettingsStore
|
|||
WriteMutableRoot(root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load one window from a user-named retail <c>@saveui</c> profile.
|
||||
/// Named profiles are intentionally independent of character and screen
|
||||
/// resolution, matching retail's portable UI files.
|
||||
/// </summary>
|
||||
public UiWindowLayout? LoadNamedWindowLayout(
|
||||
string profileName,
|
||||
string windowName,
|
||||
UiWindowLayout fallback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
if (!File.Exists(_path)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
|
||||
JsonObject? node = root?["namedWindowLayouts"]?[profileName]?[windowName] as JsonObject;
|
||||
return node is null
|
||||
? null
|
||||
: new UiWindowLayout(
|
||||
ReadNodeFloat(node, "x", fallback.X),
|
||||
ReadNodeFloat(node, "y", fallback.Y),
|
||||
ReadNodeFloat(node, "width", fallback.Width),
|
||||
ReadNodeFloat(node, "height", fallback.Height),
|
||||
ReadNodeBool(node, "visible", fallback.Visible),
|
||||
ReadNodeBool(node, "collapsed", fallback.Collapsed),
|
||||
ReadNodeBool(node, "maximized", fallback.Maximized));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load named window layout from {_path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Save one window to a user-named retail <c>@saveui</c> profile.</summary>
|
||||
public void SaveNamedWindowLayout(
|
||||
string profileName,
|
||||
string windowName,
|
||||
UiWindowLayout layout)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
|
||||
JsonObject root = LoadMutableRoot();
|
||||
JsonObject profiles = GetOrCreateObject(root, "namedWindowLayouts");
|
||||
JsonObject profile = GetOrCreateObject(profiles, profileName);
|
||||
profile[windowName] = SerializeWindowLayout(layout);
|
||||
root["version"] = CurrentSchemaVersion;
|
||||
WriteMutableRoot(root);
|
||||
}
|
||||
|
||||
private static JsonObject SerializeWindowLayout(UiWindowLayout layout) => new()
|
||||
{
|
||||
["x"] = layout.X,
|
||||
["y"] = layout.Y,
|
||||
["width"] = layout.Width,
|
||||
["height"] = layout.Height,
|
||||
["visible"] = layout.Visible,
|
||||
["collapsed"] = layout.Collapsed,
|
||||
["maximized"] = layout.Maximized,
|
||||
};
|
||||
|
||||
private JsonObject LoadMutableRoot()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
|
|
@ -481,6 +546,7 @@ public sealed class SettingsStore
|
|||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["advancedCombatUI"] = g.AdvancedCombatUI,
|
||||
["acceptLootPermits"] = g.AcceptLootPermits,
|
||||
["allowGive"] = g.AllowGive,
|
||||
["autoRepeatAttack"] = g.AutoRepeatAttack,
|
||||
["autoTarget"] = g.AutoTarget,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
|
@ -6,23 +8,312 @@ namespace AcDream.App.Tests.UI;
|
|||
public sealed class ClientCommandControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void LifestoneRecall_ExecutesSuppliedSessionActionOnce()
|
||||
public void RecallAndQueryCommands_ExecuteTheirExactBindings()
|
||||
{
|
||||
int recalls = 0;
|
||||
var controller = new ClientCommandController(() => recalls++);
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls: calls);
|
||||
|
||||
Execute(ClientCommandId.LifestoneRecall);
|
||||
Execute(ClientCommandId.MarketplaceRecall);
|
||||
Execute(ClientCommandId.PkArenaRecall);
|
||||
Execute(ClientCommandId.PkLiteArenaRecall);
|
||||
Execute(ClientCommandId.HouseRecall);
|
||||
Execute(ClientCommandId.MansionRecall);
|
||||
Execute(ClientCommandId.QueryAge);
|
||||
Execute(ClientCommandId.QueryBirth);
|
||||
|
||||
Assert.Equal(
|
||||
["ls", "mp", "pka", "pla", "house", "mansion", "age", "birth"],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id) => controller.Execute(
|
||||
new ExecuteClientCommandCmd(id, Arguments: string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PkArena_NonPk_ShowsRetailFailureWithoutSending()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var errors = new List<uint>();
|
||||
var controller = NewController(calls, errors, playerBitfield: 0x8u);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.LifestoneRecall, Arguments: string.Empty));
|
||||
ClientCommandId.PkArenaRecall, string.Empty));
|
||||
|
||||
Assert.Equal(1, recalls);
|
||||
Assert.Empty(calls);
|
||||
Assert.Equal([0x055Fu], errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PkLiteArena_NonPkLite_ShowsRetailFailureWithoutSending()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var errors = new List<uint>();
|
||||
var controller = NewController(calls, errors, playerBitfield: 0x8u);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.PkLiteArenaRecall, string.Empty));
|
||||
|
||||
Assert.Empty(calls);
|
||||
Assert.Equal([0x0560u], errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingPlayerDescription_DoesNotInventAClientRejection()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, playerBitfield: null);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.PkArenaRecall, string.Empty));
|
||||
|
||||
Assert.Equal(["pka"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalPresentationCommands_UseApplicationServices()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleFrameRate, ""));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleUiLock, ""));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ShowVersion, ""));
|
||||
|
||||
Assert.Equal(["fps", "lock"], calls);
|
||||
Assert.Equal(["Client version 1.2.3"], messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Die_RequiresRetailConfirmationBeforeSuicide()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Die, ""));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"confirm:Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
|
||||
"suicide",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatAndLayoutCommands_UseNamedAndAutomaticPersistenceBindings()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages);
|
||||
|
||||
Execute(ClientCommandId.ClearChat, "all");
|
||||
Execute(ClientCommandId.SaveUi, "hunt");
|
||||
Execute(ClientCommandId.LoadUi, "hunt");
|
||||
Execute(ClientCommandId.SaveAutoUi, string.Empty);
|
||||
Execute(ClientCommandId.LoadAutoUi, string.Empty);
|
||||
Execute(ClientCommandId.SaveUi, "this-name-is-over-16-characters");
|
||||
|
||||
Assert.Equal(
|
||||
["clear:True", "saveui:hunt", "loadui:hunt", "saveautoui", "loadautoui"],
|
||||
calls);
|
||||
Assert.Equal(["The file name must be 16 characters or less."], messages);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AwayCommands_RespectCurrentModeAndPackRetailMessageForm()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages, isAway: false);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "on"));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "msg Stepped away"));
|
||||
|
||||
Assert.Equal(["afk:True", "afkmsg:Stepped away\n"], calls);
|
||||
Assert.Equal(["New AFK message set: Stepped away\n"], messages);
|
||||
|
||||
calls.Clear();
|
||||
controller = NewController(calls, isAway: true);
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "off"));
|
||||
Assert.Equal(["afk:False"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsentAndEmoteCommands_RouteTheirExactActions()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, acceptsLootPermits: false);
|
||||
|
||||
Execute(ClientCommandId.Consent, "on");
|
||||
Execute(ClientCommandId.Consent, "who");
|
||||
Execute(ClientCommandId.Consent, "clear");
|
||||
Execute(ClientCommandId.Consent, "remove Alice Example");
|
||||
Execute(ClientCommandId.Emote, "waves happily");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"consentmode:True",
|
||||
"consentwho",
|
||||
"consentclear",
|
||||
"consentremove:Alice Example",
|
||||
"emote:waves happily",
|
||||
],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendsCommands_ReadAuthoritativeStateAndSendObjectId()
|
||||
{
|
||||
var friends = new FriendsState();
|
||||
friends.Apply(new FriendsUpdate(
|
||||
FriendsUpdateType.Full,
|
||||
[
|
||||
new FriendEntry(0x50000001u, "Alice", true, false, [], []),
|
||||
new FriendEntry(0x50000002u, "Bjørn", false, false, [], []),
|
||||
]));
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages, friends: friends);
|
||||
|
||||
Execute(ClientCommandId.Friends, "online");
|
||||
Execute(ClientCommandId.FriendsRemove, "alice");
|
||||
Execute(ClientCommandId.FriendsAdd, "Cara");
|
||||
Execute(ClientCommandId.Friends, "old");
|
||||
|
||||
Assert.Contains("Alice (Online)", messages.Single());
|
||||
Assert.DoesNotContain("Bjørn", messages.Single());
|
||||
Assert.Equal(
|
||||
["friendremove:1342177281", "friendadd:Cara", "friendsold"],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquelchAndFilterCommands_ParseRetailOptions()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, lastTeller: "Recent Teller");
|
||||
|
||||
Execute(ClientCommandId.Squelch, "-account Alice");
|
||||
Execute(ClientCommandId.Unsquelch, "-reply");
|
||||
Execute(ClientCommandId.Squelch, "-Magic Caster Name");
|
||||
Execute(ClientCommandId.Filter, "-Combat_Self");
|
||||
Execute(ClientCommandId.Unfilter, "-Tell");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"accountsquelch:True:Alice",
|
||||
"charsquelch:False:0:Recent Teller:1",
|
||||
"charsquelch:True:0:Caster Name:7",
|
||||
"globalsquelch:True:22",
|
||||
"globalsquelch:False:3",
|
||||
],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillComponents_ClearWorksWithoutVendorAndBuyingRequiresOne()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages, vendorOpen: false);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "clear"));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "scarabs 500"));
|
||||
|
||||
Assert.Equal(["clearcomps"], calls);
|
||||
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCommandId_FailsAtApplicationBoundary()
|
||||
{
|
||||
var controller = new ClientCommandController(() => { });
|
||||
var controller = NewController();
|
||||
var command = new ExecuteClientCommandCmd((ClientCommandId)999, string.Empty);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => controller.Execute(command));
|
||||
}
|
||||
|
||||
private static ClientCommandController NewController(
|
||||
List<string>? calls = null,
|
||||
List<uint>? errors = null,
|
||||
uint? playerBitfield = 0x02000028u,
|
||||
List<string>? messages = null,
|
||||
bool isAway = false,
|
||||
bool acceptsLootPermits = true,
|
||||
FriendsState? friends = null,
|
||||
SquelchState? squelch = null,
|
||||
string? lastTeller = null,
|
||||
bool vendorOpen = false)
|
||||
{
|
||||
calls ??= [];
|
||||
errors ??= [];
|
||||
messages ??= [];
|
||||
return new ClientCommandController(new ClientCommandController.Bindings(
|
||||
() => calls.Add("ls"),
|
||||
() => calls.Add("mp"),
|
||||
() => calls.Add("pka"),
|
||||
() => calls.Add("pla"),
|
||||
() => calls.Add("house"),
|
||||
() => calls.Add("mansion"),
|
||||
() => calls.Add("age"),
|
||||
() => calls.Add("birth"),
|
||||
() => calls.Add("fps"),
|
||||
() => calls.Add("lock"),
|
||||
messages.Add,
|
||||
errors.Add,
|
||||
() => playerBitfield,
|
||||
() => "1.2.3",
|
||||
() => new Position(
|
||||
0xA9B40001u,
|
||||
new CellFrame(new System.Numerics.Vector3(1f, 2f, 3f),
|
||||
System.Numerics.Quaternion.Identity)),
|
||||
() => null,
|
||||
(message, completed) =>
|
||||
{
|
||||
calls.Add($"confirm:{message}");
|
||||
completed(true);
|
||||
},
|
||||
() => calls.Add("suicide"),
|
||||
all => calls.Add("clear:" + all),
|
||||
name => calls.Add("saveui:" + name),
|
||||
name => calls.Add("loadui:" + name),
|
||||
() => calls.Add("saveautoui"),
|
||||
() => calls.Add("loadautoui"),
|
||||
() => isAway,
|
||||
away => calls.Add("afk:" + away),
|
||||
message => calls.Add("afkmsg:" + message),
|
||||
() => acceptsLootPermits,
|
||||
enabled => calls.Add("consentmode:" + enabled),
|
||||
() => calls.Add("consentwho"),
|
||||
() => calls.Add("consentclear"),
|
||||
name => calls.Add("consentremove:" + name),
|
||||
message => calls.Add("emote:" + message),
|
||||
friends ?? new FriendsState(),
|
||||
name => calls.Add("friendadd:" + name),
|
||||
id => calls.Add("friendremove:" + id),
|
||||
() => calls.Add("friendsclear"),
|
||||
() => calls.Add("friendsold"),
|
||||
squelch ?? new SquelchState(),
|
||||
(add, id, name, type) => calls.Add($"charsquelch:{add}:{id}:{name}:{type}"),
|
||||
(add, name) => calls.Add($"accountsquelch:{add}:{name}"),
|
||||
(add, type) => calls.Add($"globalsquelch:{add}:{type}"),
|
||||
() => lastTeller,
|
||||
() => calls.Add("clearcomps"),
|
||||
() => vendorOpen,
|
||||
(category, price) => calls.Add($"fillcomps:{category}:{price}")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,12 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadPowerbarInfos()
|
||||
=> LoadInfos("powerbar_21000072.json");
|
||||
|
||||
public static ImportedLayout LoadConfirmationDialog()
|
||||
=> LayoutImporter.Build(LoadConfirmationDialogInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadConfirmationDialogInfos()
|
||||
=> LoadInfos("dialogs_2100003C.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RetailConfirmationDialogServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixtureBuildsProductionConfirmationWidgets()
|
||||
{
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
|
||||
Assert.IsType<UiDialogRoot>(layout.Root);
|
||||
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogService.MessageElementId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.RejectButtonId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowCentersModalAndAcceptCompletesIt()
|
||||
{
|
||||
var root = new UiRoot { Width = 1024f, Height = 768f };
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
var service = new RetailConfirmationDialogService(root, layout);
|
||||
bool? result = null;
|
||||
|
||||
service.Show("Do you really want to kill your character?", accepted => result = accepted);
|
||||
|
||||
Assert.Same(layout.Root, root.Modal);
|
||||
var popup = Assert.IsAssignableFrom<UiElement>(
|
||||
layout.FindElement(RetailConfirmationDialogService.PopupElementId));
|
||||
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
|
||||
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
|
||||
|
||||
var accept = Assert.IsType<UiButton>(
|
||||
layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
|
||||
accept.OnClick!();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.False(service.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestsArePresentedInFifoOrder()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
var service = new RetailConfirmationDialogService(root, layout);
|
||||
var results = new List<string>();
|
||||
|
||||
service.Show("first", accepted => results.Add($"first:{accepted}"));
|
||||
service.Show("second", accepted => results.Add($"second:{accepted}"));
|
||||
|
||||
Assert.Equal(1, service.PendingCount);
|
||||
Assert.IsType<UiButton>(layout.FindElement(
|
||||
RetailConfirmationDialogService.RejectButtonId)).OnClick!();
|
||||
Assert.Equal(["first:False"], results);
|
||||
Assert.True(service.IsOpen);
|
||||
Assert.Equal(0, service.PendingCount);
|
||||
|
||||
Assert.IsType<UiButton>(layout.FindElement(
|
||||
RetailConfirmationDialogService.AcceptButtonId)).OnClick!();
|
||||
Assert.Equal(["first:False", "second:True"], results);
|
||||
Assert.False(service.IsOpen);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Text.Json;
|
|||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using EnumIDMap = DatReaderWriter.DBObjs.EnumIDMap;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -44,6 +45,25 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
Assert.True(dats.Portal.TryGet<EnumIDMap>(masterDid, out var master));
|
||||
Assert.True(master!.ClientEnumToID.TryGetValue(5u, out uint uiMapDid));
|
||||
Assert.True(dats.Portal.TryGet<EnumIDMap>(uiMapDid, out var uiMap));
|
||||
Assert.True(uiMap!.ClientEnumToID.TryGetValue(2u, out uint dialogsLayoutDid));
|
||||
var dialogs = LayoutImporter.ImportInfos(
|
||||
dats,
|
||||
dialogsLayoutDid,
|
||||
RetailConfirmationDialogService.RootElementId);
|
||||
Assert.NotNull(dialogs);
|
||||
var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(
|
||||
Path.Combine(FixtureDirectory(), $"dialogs_{dialogsLayoutDid:X8}.json"),
|
||||
dialogsJson);
|
||||
|
||||
foreach (var (layoutId, fileName) in Layouts)
|
||||
{
|
||||
var info = LayoutImporter.ImportInfos(dats, layoutId);
|
||||
|
|
@ -67,6 +87,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
});
|
||||
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), json);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
|
||||
|
|
|
|||
2942
tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json
Normal file
2942
tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -99,6 +99,31 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
Assert.False(saved.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedProfile_SaveAndRestore_AppliesEveryMountedWindow()
|
||||
{
|
||||
var store = new SettingsStore(PathName);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
RetailWindowHandle chat = Mount(root, "chat");
|
||||
RetailWindowHandle radar = Mount(root, "radar");
|
||||
using var persistence = new RetailWindowLayoutPersistence(
|
||||
root.WindowManager, store, () => "Alice", () => (800, 600));
|
||||
|
||||
chat.MoveTo(44f, 55f);
|
||||
radar.MoveTo(300f, 120f);
|
||||
radar.Hide();
|
||||
persistence.SaveNamed("hunting");
|
||||
|
||||
chat.MoveTo(1f, 2f);
|
||||
radar.MoveTo(3f, 4f);
|
||||
radar.Show();
|
||||
persistence.RestoreNamed("hunting");
|
||||
|
||||
Assert.Equal((44f, 55f), (chat.Left, chat.Top));
|
||||
Assert.Equal((300f, 120f), (radar.Left, radar.Top));
|
||||
Assert.False(radar.IsVisible);
|
||||
}
|
||||
|
||||
private static RetailWindowHandle Mount(
|
||||
UiRoot root,
|
||||
string name,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ namespace AcDream.App.Tests.UI;
|
|||
|
||||
public class UiTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs()
|
||||
{
|
||||
IReadOnlyList<string> lines = UiText.WrapWords(
|
||||
"one two three\nfour",
|
||||
text => text.Length * 10f,
|
||||
maximumWidth: 75f);
|
||||
|
||||
Assert.Equal(["one two", "three", "four"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampScroll_PinsToZero_WhenContentFitsView()
|
||||
{
|
||||
|
|
@ -142,6 +153,28 @@ public class UiTextTests
|
|||
Assert.Equal(new UiText.Pos(1, 8), e2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapWords_PreservesNewlinesAndWrapsAtSpaces()
|
||||
{
|
||||
IReadOnlyList<string> lines = UiText.WrapWords(
|
||||
"one two three\nfour",
|
||||
static text => text.Length,
|
||||
maximumWidth: 7f);
|
||||
|
||||
Assert.Equal(["one two", "three", "four"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapWords_SplitsAnOverWidthWordAtCharacterBoundaries()
|
||||
{
|
||||
IReadOnlyList<string> lines = UiText.WrapWords(
|
||||
"go abcdef",
|
||||
static text => text.Length,
|
||||
maximumWidth: 5f);
|
||||
|
||||
Assert.Equal(["go ab", "cdef"], lines);
|
||||
}
|
||||
|
||||
// ── VOffset: vertical positioning for single-line mode ───────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using AcDream.Core.Net;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Social;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
|
@ -142,6 +143,72 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var items = new ClientObjectTable();
|
||||
var combat = new CombatState();
|
||||
var spellbook = new Spellbook();
|
||||
var chat = new ChatLog();
|
||||
GameEvents.CharacterConfirmationRequest? received = null;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
items,
|
||||
combat,
|
||||
spellbook,
|
||||
chat,
|
||||
onConfirmationRequest: request => received = request);
|
||||
|
||||
byte[] text = MakeString16L("Proceed?");
|
||||
byte[] payload = new byte[8 + text.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u);
|
||||
text.CopyTo(payload.AsSpan(8));
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
||||
GameEventType.CharacterConfirmationRequest,
|
||||
payload));
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var friends = new FriendsState();
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
friends: friends);
|
||||
|
||||
byte[] name = MakeString16L("Alice");
|
||||
byte[] payload = new byte[4 + 12 + name.Length + 4 + 4 + 4];
|
||||
int p = 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0x50000001u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
||||
name.CopyTo(payload.AsSpan(p)); p += name.Length;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
payload.AsSpan(p), (uint)FriendsUpdateType.Full);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
||||
GameEventType.FriendsListUpdate, payload));
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
FriendEntry friend = Assert.Single(friends.Snapshot());
|
||||
Assert.Equal("Alice", friend.Name);
|
||||
Assert.True(friend.Online);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_PopupString_RoutesToChatLog()
|
||||
{
|
||||
|
|
@ -155,6 +222,24 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "2d 4h", "You have played for 2d 4h.")]
|
||||
[InlineData("Alice", "1y 2mo", "Alice has played for 1y 2mo.")]
|
||||
public void WireAll_QueryAgeResponse_UsesRetailWording(
|
||||
string name, string age, string expected)
|
||||
{
|
||||
var (d, _, _, _, chat) = MakeAll();
|
||||
byte[] nameBytes = MakeString16L(name);
|
||||
byte[] ageBytes = MakeString16L(age);
|
||||
byte[] payload = [.. nameBytes, .. ageBytes];
|
||||
|
||||
var env = GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.QueryAgeResponse, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(expected, chat.Snapshot().Single().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateVitals()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class ClientCommandRequestsTests
|
||||
{
|
||||
public static TheoryData<Func<uint, byte[]>, uint> ParameterlessActions => new()
|
||||
{
|
||||
{ ClientCommandRequests.BuildMarketplace, ClientCommandRequests.MarketplaceOpcode },
|
||||
{ ClientCommandRequests.BuildPkArena, ClientCommandRequests.PkArenaOpcode },
|
||||
{ ClientCommandRequests.BuildPkLiteArena, ClientCommandRequests.PkLiteArenaOpcode },
|
||||
{ ClientCommandRequests.BuildHouseRecall, ClientCommandRequests.HouseRecallOpcode },
|
||||
{ ClientCommandRequests.BuildMansionRecall, ClientCommandRequests.MansionRecallOpcode },
|
||||
{ ClientCommandRequests.BuildSuicide, ClientCommandRequests.SuicideOpcode },
|
||||
{ ClientCommandRequests.BuildClearFriends, ClientCommandRequests.ClearFriendsOpcode },
|
||||
{ ClientCommandRequests.BuildClearConsent, ClientCommandRequests.ClearConsentOpcode },
|
||||
{ ClientCommandRequests.BuildDisplayConsent, ClientCommandRequests.DisplayConsentOpcode },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParameterlessActions))]
|
||||
public void ParameterlessAction_MatchesRetailEnvelope(
|
||||
Func<uint, byte[]> build, uint opcode)
|
||||
{
|
||||
byte[] body = build(0x1234u);
|
||||
|
||||
Assert.Equal(12, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(0x1234u, Read(body, 4));
|
||||
Assert.Equal(opcode, Read(body, 8));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, ClientCommandRequests.QueryAgeOpcode)]
|
||||
[InlineData(true, ClientCommandRequests.QueryBirthOpcode)]
|
||||
public void CharacterQuery_MatchesRetailPayload(bool birth, uint opcode)
|
||||
{
|
||||
byte[] body = birth
|
||||
? ClientCommandRequests.BuildQueryBirth(7u, 0x50000001u)
|
||||
: ClientCommandRequests.BuildQueryAge(7u, 0x50000001u);
|
||||
|
||||
Assert.Equal(16, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(7u, Read(body, 4));
|
||||
Assert.Equal(opcode, Read(body, 8));
|
||||
Assert.Equal(0x50000001u, Read(body, 12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmationResponse_MatchesRetailPayload()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildConfirmationResponse(
|
||||
9u, confirmationType: 7u, contextId: 42u, accepted: true);
|
||||
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(9u, Read(body, 4));
|
||||
Assert.Equal(ClientCommandRequests.ConfirmationResponseOpcode, Read(body, 8));
|
||||
Assert.Equal(7u, Read(body, 12));
|
||||
Assert.Equal(42u, Read(body, 16));
|
||||
Assert.Equal(1u, Read(body, 20));
|
||||
}
|
||||
|
||||
public static TheoryData<Func<uint, string, byte[]>, uint> StringActions => new()
|
||||
{
|
||||
{ ClientCommandRequests.BuildSetAfkMessage, ClientCommandRequests.SetAfkMessageOpcode },
|
||||
{ ClientCommandRequests.BuildEmote, ClientCommandRequests.EmoteOpcode },
|
||||
{ ClientCommandRequests.BuildAddFriend, ClientCommandRequests.AddFriendOpcode },
|
||||
{ ClientCommandRequests.BuildRemoveConsent, ClientCommandRequests.RemoveConsentOpcode },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(StringActions))]
|
||||
public void StringAction_PacksAlignedCp1252String16L(
|
||||
Func<uint, string, byte[]> build, uint opcode)
|
||||
{
|
||||
byte[] body = build(11u, "Bjørn");
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(11u, Read(body, 4));
|
||||
Assert.Equal(opcode, Read(body, 8));
|
||||
Assert.Equal(5, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal([0x42, 0x6A, 0xF8, 0x72, 0x6E], body[14..19]);
|
||||
Assert.Equal(0, body[19]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetAfkMode_MatchesRetailBooleanPayload()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildSetAfkMode(12u, away: true);
|
||||
|
||||
Assert.Equal(16, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.SetAfkModeOpcode, Read(body, 8));
|
||||
Assert.Equal(1u, Read(body, 12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendAndGlobalSquelchIntegerActions_MatchRetailPayloads()
|
||||
{
|
||||
byte[] remove = ClientCommandRequests.BuildRemoveFriend(2u, 0x50000001u);
|
||||
byte[] filter = ClientCommandRequests.BuildModifyGlobalSquelch(3u, add: false, 17u);
|
||||
|
||||
Assert.Equal(ClientCommandRequests.RemoveFriendOpcode, Read(remove, 8));
|
||||
Assert.Equal(0x50000001u, Read(remove, 12));
|
||||
Assert.Equal(ClientCommandRequests.ModifyGlobalSquelchOpcode, Read(filter, 8));
|
||||
Assert.Equal(0u, Read(filter, 12));
|
||||
Assert.Equal(17u, Read(filter, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterSquelch_PreservesRetailFieldOrder()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildModifyCharacterSquelch(
|
||||
4u, add: true, 0x50000001u, "Alice", 12u);
|
||||
|
||||
Assert.Equal(32, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.ModifyCharacterSquelchOpcode, Read(body, 8));
|
||||
Assert.Equal(1u, Read(body, 12));
|
||||
Assert.Equal(0x50000001u, Read(body, 16));
|
||||
Assert.Equal(5, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
|
||||
Assert.Equal(12u, Read(body, 28));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountSquelch_PreservesRetailFieldOrder()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildModifyAccountSquelch(
|
||||
5u, add: false, "Alice");
|
||||
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.ModifyAccountSquelchOpcode, Read(body, 8));
|
||||
Assert.Equal(0u, Read(body, 12));
|
||||
Assert.Equal(5, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesiredComponentLevel_MatchesRetailTwoIntegerPayload()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildSetDesiredComponentLevel(
|
||||
6u, 0x12000042u, 25u);
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.SetDesiredComponentLevelOpcode, Read(body, 8));
|
||||
Assert.Equal(0x12000042u, Read(body, 12));
|
||||
Assert.Equal(25u, Read(body, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFriendsCommand_IsAControlMessageWithoutGameActionEnvelope()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildLegacyFriendsCommand(0u, string.Empty);
|
||||
|
||||
Assert.Equal(12, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.LegacyFriendsOpcode, Read(body, 0));
|
||||
Assert.Equal(0u, Read(body, 4));
|
||||
Assert.Equal(0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(8)));
|
||||
}
|
||||
|
||||
private static uint Read(byte[] body, int offset) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(offset));
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Social;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class SocialStateMessagesTests
|
||||
{
|
||||
[Fact]
|
||||
public void FriendsUpdate_UnpacksRetailPListAndUpdateType()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
WriteU32(payload, 2u);
|
||||
WriteFriend(payload, 0x50000001u, online: true, appearOffline: false,
|
||||
"Alice", [0x50000002u], [0x50000003u, 0x50000004u]);
|
||||
WriteFriend(payload, 0x50000002u, online: false, appearOffline: true,
|
||||
"Bjørn", [], []);
|
||||
WriteU32(payload, (uint)FriendsUpdateType.Full);
|
||||
|
||||
FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate([.. payload]);
|
||||
|
||||
Assert.NotNull(update);
|
||||
Assert.Equal(FriendsUpdateType.Full, update!.Type);
|
||||
Assert.Collection(
|
||||
update.Entries,
|
||||
alice =>
|
||||
{
|
||||
Assert.Equal(0x50000001u, alice.Id);
|
||||
Assert.Equal("Alice", alice.Name);
|
||||
Assert.True(alice.Online);
|
||||
Assert.False(alice.AppearOffline);
|
||||
Assert.Equal([0x50000002u], alice.Friends);
|
||||
Assert.Equal([0x50000003u, 0x50000004u], alice.FriendOf);
|
||||
},
|
||||
bjorn =>
|
||||
{
|
||||
Assert.Equal("Bjørn", bjorn.Name);
|
||||
Assert.False(bjorn.Online);
|
||||
Assert.True(bjorn.AppearOffline);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendsUpdate_RejectsTruncatedFriendData()
|
||||
{
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 1u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x50000001u);
|
||||
|
||||
Assert.Null(SocialStateMessages.ParseFriendsUpdate(payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquelchDatabase_UnpacksRetailHashTablesAndVlongs()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
|
||||
WriteHashHeader(payload, count: 1, buckets: 128);
|
||||
WriteString16L(payload, "MutedAccount");
|
||||
WriteU32(payload, 0x11223344u);
|
||||
|
||||
WriteHashHeader(payload, count: 1, buckets: 128);
|
||||
WriteU32(payload, 0x50000001u);
|
||||
WriteSquelchInfo(payload, "Muted Character", accountWide: true, 2u, 17u);
|
||||
|
||||
WriteSquelchInfo(payload, string.Empty, accountWide: false, 7u, 19u);
|
||||
|
||||
SquelchDatabase? database =
|
||||
SocialStateMessages.ParseSquelchDatabase([.. payload]);
|
||||
|
||||
Assert.NotNull(database);
|
||||
Assert.Equal(0x11223344u, database!.Accounts["mutedaccount"]);
|
||||
SquelchInfo character = database.Characters[0x50000001u];
|
||||
Assert.Equal("Muted Character", character.Name);
|
||||
Assert.True(character.AccountWide);
|
||||
Assert.Equal([2u, 17u], character.MessageTypes.Order());
|
||||
Assert.False(database.Global.AccountWide);
|
||||
Assert.Equal([7u, 19u], database.Global.MessageTypes.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquelchDatabase_RejectsNonEmptyZeroBucketTable()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
WriteHashHeader(payload, count: 1, buckets: 0);
|
||||
|
||||
Assert.Null(SocialStateMessages.ParseSquelchDatabase([.. payload]));
|
||||
}
|
||||
|
||||
private static void WriteFriend(
|
||||
List<byte> destination,
|
||||
uint id,
|
||||
bool online,
|
||||
bool appearOffline,
|
||||
string name,
|
||||
IReadOnlyList<uint> friends,
|
||||
IReadOnlyList<uint> friendOf)
|
||||
{
|
||||
WriteU32(destination, id);
|
||||
WriteU32(destination, online ? 1u : 0u);
|
||||
WriteU32(destination, appearOffline ? 1u : 0u);
|
||||
WriteString16L(destination, name);
|
||||
WriteU32List(destination, friends);
|
||||
WriteU32List(destination, friendOf);
|
||||
}
|
||||
|
||||
private static void WriteSquelchInfo(
|
||||
List<byte> destination,
|
||||
string name,
|
||||
bool accountWide,
|
||||
params uint[] messageTypes)
|
||||
{
|
||||
uint highest = messageTypes.Length == 0 ? 0u : messageTypes.Max();
|
||||
int wordCount = messageTypes.Length == 0 ? 0 : checked((int)(highest / 32u + 1u));
|
||||
var words = new uint[wordCount];
|
||||
foreach (uint type in messageTypes)
|
||||
words[type / 32u] |= 1u << checked((int)(type % 32u));
|
||||
|
||||
WriteU32(destination, (uint)words.Length);
|
||||
foreach (uint word in words) WriteU32(destination, word);
|
||||
WriteString16L(destination, name);
|
||||
WriteU32(destination, accountWide ? 1u : 0u);
|
||||
}
|
||||
|
||||
private static void WriteU32List(List<byte> destination, IReadOnlyList<uint> values)
|
||||
{
|
||||
WriteU32(destination, (uint)values.Count);
|
||||
foreach (uint value in values) WriteU32(destination, value);
|
||||
}
|
||||
|
||||
private static void WriteHashHeader(List<byte> destination, ushort count, ushort buckets) =>
|
||||
WriteU32(destination, ((uint)buckets << 16) | count);
|
||||
|
||||
private static void WriteString16L(List<byte> destination, string value)
|
||||
{
|
||||
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(value);
|
||||
int start = destination.Count;
|
||||
destination.Add((byte)bytes.Length);
|
||||
destination.Add((byte)(bytes.Length >> 8));
|
||||
destination.AddRange(bytes);
|
||||
while ((destination.Count - start & 3) != 0) destination.Add(0);
|
||||
}
|
||||
|
||||
private static void WriteU32(List<byte> destination, uint value)
|
||||
{
|
||||
destination.Add((byte)value);
|
||||
destination.Add((byte)(value >> 8));
|
||||
destination.Add((byte)(value >> 16));
|
||||
destination.Add((byte)(value >> 24));
|
||||
}
|
||||
}
|
||||
57
tests/AcDream.Core.Tests/Social/SocialStateTests.cs
Normal file
57
tests/AcDream.Core.Tests/Social/SocialStateTests.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using AcDream.Core.Social;
|
||||
|
||||
namespace AcDream.Core.Tests.Social;
|
||||
|
||||
public sealed class SocialStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void FriendsState_AppliesRetailIncrementalUpdateKindsByObjectId()
|
||||
{
|
||||
var state = new FriendsState();
|
||||
state.Apply(Update(FriendsUpdateType.Full,
|
||||
Friend(1u, "Alice", online: false),
|
||||
Friend(2u, "Bjørn", online: true)));
|
||||
state.Apply(Update(FriendsUpdateType.OnlineStatus,
|
||||
Friend(1u, "Alice", online: true)));
|
||||
state.Apply(Update(FriendsUpdateType.Add,
|
||||
Friend(3u, "Cara", online: true)));
|
||||
state.Apply(Update(FriendsUpdateType.RemoveSilent,
|
||||
Friend(2u, "Bjørn", online: false)));
|
||||
|
||||
Assert.Collection(
|
||||
state.Snapshot(),
|
||||
alice =>
|
||||
{
|
||||
Assert.Equal(1u, alice.Id);
|
||||
Assert.True(alice.Online);
|
||||
},
|
||||
cara => Assert.Equal(3u, cara.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquelchState_ReplacesDatabaseAtomically()
|
||||
{
|
||||
var state = new SquelchState();
|
||||
var expected = new SquelchDatabase(
|
||||
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Account"] = 7u,
|
||||
},
|
||||
new Dictionary<uint, SquelchInfo>
|
||||
{
|
||||
[42u] = new("Character", false, new HashSet<uint> { 2u }),
|
||||
},
|
||||
new SquelchInfo(string.Empty, false, new HashSet<uint> { 7u }));
|
||||
|
||||
state.Replace(expected);
|
||||
|
||||
Assert.Same(expected, state.Snapshot());
|
||||
}
|
||||
|
||||
private static FriendEntry Friend(uint id, string name, bool online) =>
|
||||
new(id, name, online, AppearOffline: false, [], []);
|
||||
|
||||
private static FriendsUpdate Update(
|
||||
FriendsUpdateType type, params FriendEntry[] entries) =>
|
||||
new(type, entries);
|
||||
}
|
||||
31
tests/AcDream.Core.Tests/Ui/RetailPositionFormatterTests.cs
Normal file
31
tests/AcDream.Core.Tests/Ui/RetailPositionFormatterTests.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.Core.Tests.Ui;
|
||||
|
||||
public sealed class RetailPositionFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Format_MatchesPositionToStringFieldOrderAndPrecision()
|
||||
{
|
||||
var position = new Position(
|
||||
0xA9B40001u,
|
||||
new CellFrame(
|
||||
new Vector3(1.25f, -2.5f, 3f),
|
||||
new Quaternion(0.1f, 0.2f, 0.3f, 0.9f)));
|
||||
|
||||
Assert.Equal(
|
||||
"0xA9B40001 [1.250000 -2.500000 3.000000] 0.900000 0.100000 0.200000 0.300000",
|
||||
RetailPositionFormatter.Format(position));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatOutdoorCell_UsesRetailRadarCoordinateOrder()
|
||||
{
|
||||
Assert.True(RadarCoordinates.TryFromCell(0xA9B40001u, out var expected));
|
||||
Assert.Equal(expected.CombinedText,
|
||||
RetailPositionFormatter.FormatOutdoorCell(0xA9B40001u));
|
||||
Assert.Null(RetailPositionFormatter.FormatOutdoorCell(0xA9B40164u));
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ public class ChatCommandRouterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearCommand_DrainsLog_DoesNotPublish()
|
||||
public void ClearCommand_PublishesTypedRetailCommand()
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
log.OnSystemMessage("x", chatType: 0);
|
||||
|
|
@ -53,8 +53,10 @@ public class ChatCommandRouterTests
|
|||
var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Empty(log.Snapshot());
|
||||
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ClientCommandId.ClearChat, command.Command);
|
||||
Assert.Equal("", command.Arguments);
|
||||
Assert.Single(log.Snapshot());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ public sealed class ChatPanelInputTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_FramerateCommand_PrintsFpsAndDoesNotPublish()
|
||||
public void Submit_FramerateCommand_PublishesTypedClientCommand()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log) { FpsProvider = () => 60f };
|
||||
|
|
@ -84,13 +84,13 @@ public sealed class ChatPanelInputTests
|
|||
|
||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||
|
||||
Assert.Empty(bus.Published);
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Contains("60.0 FPS", entry.Text);
|
||||
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ClientCommandId.ToggleFrameRate, command.Command);
|
||||
Assert.Empty(log.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_LocCommand_PrintsPositionAndDoesNotPublish()
|
||||
public void Submit_LocCommand_PublishesTypedClientCommand()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log)
|
||||
|
|
@ -107,14 +107,13 @@ public sealed class ChatPanelInputTests
|
|||
|
||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||
|
||||
Assert.Empty(bus.Published);
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Contains("(10.0, 20.0, 30.0)", entry.Text);
|
||||
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ClientCommandId.ShowLocation, command.Command);
|
||||
Assert.Empty(log.Snapshot());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/foo", "@foo")]
|
||||
[InlineData("/mp /tools/script.py", "@mp /tools/script.py")]
|
||||
[InlineData("/genio public", "@genio public")]
|
||||
public void Submit_UnknownSlashCommand_RoutesToExplicitServerCommand(string raw, string expectedText)
|
||||
{
|
||||
|
|
@ -213,7 +212,7 @@ public sealed class ChatPanelInputTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_ClearCommand_DrainsLog_AndDoesNotPublish()
|
||||
public void Submit_ClearCommand_PublishesTypedClientCommand()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
log.OnSystemMessage("seed line", chatType: 0);
|
||||
|
|
@ -228,8 +227,9 @@ public sealed class ChatPanelInputTests
|
|||
|
||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Empty(log.Snapshot());
|
||||
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ClientCommandId.ClearChat, command.Command);
|
||||
Assert.Single(log.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,81 @@ public sealed class RetailClientCommandCatalogTests
|
|||
Assert.Empty(match.Arguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/marketplace", ClientCommandId.MarketplaceRecall)]
|
||||
[InlineData("@MAR", ClientCommandId.MarketplaceRecall)]
|
||||
[InlineData("/mp", ClientCommandId.MarketplaceRecall)]
|
||||
[InlineData("/pkarena", ClientCommandId.PkArenaRecall)]
|
||||
[InlineData("/pka", ClientCommandId.PkArenaRecall)]
|
||||
[InlineData("/pklarena", ClientCommandId.PkLiteArenaRecall)]
|
||||
[InlineData("/pla", ClientCommandId.PkLiteArenaRecall)]
|
||||
[InlineData("/hor", ClientCommandId.HouseRecall)]
|
||||
[InlineData("/hr", ClientCommandId.HouseRecall)]
|
||||
[InlineData("/hom", ClientCommandId.MansionRecall)]
|
||||
[InlineData("/hoa", ClientCommandId.MansionRecall)]
|
||||
[InlineData("/age", ClientCommandId.QueryAge)]
|
||||
[InlineData("/birth", ClientCommandId.QueryBirth)]
|
||||
[InlineData("/framerate", ClientCommandId.ToggleFrameRate)]
|
||||
[InlineData("/lockui", ClientCommandId.ToggleUiLock)]
|
||||
[InlineData("/version", ClientCommandId.ShowVersion)]
|
||||
[InlineData("/loc", ClientCommandId.ShowLocation)]
|
||||
[InlineData("/corpse", ClientCommandId.ShowLastCorpseLocation)]
|
||||
[InlineData("/cor", ClientCommandId.ShowLastCorpseLocation)]
|
||||
public void AdditionalRetailAliases_Resolve(string input, ClientCommandId expected)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/clear all", ClientCommandId.ClearChat)]
|
||||
[InlineData("/saveui hunt", ClientCommandId.SaveUi)]
|
||||
[InlineData("/loadui hunt", ClientCommandId.LoadUi)]
|
||||
[InlineData("/saveautoui", ClientCommandId.SaveAutoUi)]
|
||||
[InlineData("/loadautoui", ClientCommandId.LoadAutoUi)]
|
||||
[InlineData("/afk msg lunch", ClientCommandId.Away)]
|
||||
[InlineData("/consent who", ClientCommandId.Consent)]
|
||||
[InlineData("/e waves", ClientCommandId.Emote)]
|
||||
[InlineData("/em waves", ClientCommandId.Emote)]
|
||||
[InlineData("/emote waves", ClientCommandId.Emote)]
|
||||
[InlineData("/me waves", ClientCommandId.Emote)]
|
||||
[InlineData("/emotes", ClientCommandId.ListEmotes)]
|
||||
[InlineData("/friends online", ClientCommandId.Friends)]
|
||||
[InlineData("/friends_add Alice", ClientCommandId.FriendsAdd)]
|
||||
[InlineData("/friends_remove Alice", ClientCommandId.FriendsRemove)]
|
||||
[InlineData("/squelch -tell Alice", ClientCommandId.Squelch)]
|
||||
[InlineData("/unsquelch Alice", ClientCommandId.Unsquelch)]
|
||||
[InlineData("/filter -combat", ClientCommandId.Filter)]
|
||||
[InlineData("/unfilter -combat", ClientCommandId.Unfilter)]
|
||||
[InlineData("/messagetypes", ClientCommandId.ListMessageTypes)]
|
||||
[InlineData("/fillcomps scarabs 500", ClientCommandId.FillComponents)]
|
||||
public void CommandFamilies_ResolveToTypedClientCommands(
|
||||
string input, ClientCommandId expected)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/house recall", ClientCommandId.HouseRecall)]
|
||||
[InlineData("@house mansion_recall", ClientCommandId.MansionRecall)]
|
||||
[InlineData("/house alleg_recall", ClientCommandId.MansionRecall)]
|
||||
public void HouseRecallSubcommands_Resolve(string input, ClientCommandId expected)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedHouseSubcommand_RemainsClientOwnedAndShowsUsage()
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch("/house nope", out var match));
|
||||
Assert.False(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LifestoneArgument_IsRecognizedButInvalid()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
ShowHelm = false,
|
||||
LockUI = true,
|
||||
UseMouseTurning = true,
|
||||
AcceptLootPermits = false,
|
||||
};
|
||||
|
||||
store.SaveGameplay(original);
|
||||
|
|
@ -434,4 +435,19 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.Equal(hd, store.LoadWindowLayout("Alice", "1600x900", "chat", default));
|
||||
Assert.Equal(uhd, store.LoadWindowLayout("Alice", "3200x1800", "chat", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedWindowLayouts_RoundTripIndependentlyOfCharacterAndResolution()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
var hunting = new UiWindowLayout(10f, 20f, 500f, 250f, true, false, true);
|
||||
var crafting = new UiWindowLayout(30f, 40f, 300f, 400f, false, true, false);
|
||||
|
||||
store.SaveNamedWindowLayout("hunting", "chat", hunting);
|
||||
store.SaveNamedWindowLayout("crafting", "chat", crafting);
|
||||
|
||||
Assert.Equal(hunting, store.LoadNamedWindowLayout("hunting", "chat", default));
|
||||
Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default));
|
||||
Assert.Null(store.LoadNamedWindowLayout("missing", "chat", default));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue