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:
Erik 2026-07-13 14:50:15 +02:00
parent 5a45a7ac7f
commit 9ea579bdd0
47 changed files with 6659 additions and 99 deletions

View file

@ -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.
---

View file

@ -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` |
---

View file

@ -501,6 +501,7 @@ behavior. Estimated 1726 days focused work, 35 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.

View file

@ -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:**

View file

@ -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.

View file

@ -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

View file

@ -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)
{

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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

View 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();
}
}

View 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;
}
}

View file

@ -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;

View file

@ -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);

View 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;
}
}

View file

@ -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

View file

@ -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)
{

View 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;
}
}

View file

@ -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; }
}

View 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;
}
}

View file

@ -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
{

View file

@ -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

View file

@ -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;

View 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);

View 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>()));
}

View 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;
}

View file

@ -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,
}

View file

@ -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;
}

View file

@ -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)
{

View file

@ -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);
}

View file

@ -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,

View file

@ -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}")));
}
}

View file

@ -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)

View file

@ -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);
}
}

View file

@ -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 = "")

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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>

View file

@ -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()
{

View file

@ -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));
}

View file

@ -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));
}
}

View 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);
}

View 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));
}
}

View file

@ -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]

View file

@ -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]

View file

@ -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()
{

View file

@ -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));
}
}