From 9ea579bdd0f66499b9feaa3029744bbd83de11b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Jul 2026 14:50:15 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 44 +- .../retail-divergence-register.md | 8 +- docs/plans/2026-04-11-roadmap.md | 1 + docs/plans/2026-05-12-milestones.md | 8 + ...tail-client-command-families-pseudocode.md | 343 ++ .../Input/PlayerMovementController.cs | 1 + src/AcDream.App/Rendering/GameWindow.cs | 115 +- src/AcDream.App/UI/ClientCommandController.cs | 648 +++- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 1 + src/AcDream.App/UI/Layout/LayoutImporter.cs | 33 + .../Layout/RetailConfirmationDialogService.cs | 122 + src/AcDream.App/UI/RetailDataIdResolver.cs | 28 + src/AcDream.App/UI/RetailUiRuntime.cs | 39 + .../UI/RetailWindowLayoutPersistence.cs | 49 + src/AcDream.App/UI/UiDialogRoot.cs | 32 + src/AcDream.App/UI/UiText.cs | 89 +- src/AcDream.Core.Net/GameEventWiring.cs | 56 +- .../Messages/ClientCommandRequests.cs | 235 ++ src/AcDream.Core.Net/Messages/GameEvents.cs | 25 +- .../Messages/SocialStateMessages.cs | 138 + src/AcDream.Core.Net/WorldSession.cs | 161 +- src/AcDream.Core/Chat/WeenieErrorMessages.cs | 2 + src/AcDream.Core/Player/LocalPlayerState.cs | 16 + src/AcDream.Core/Social/FriendsState.cs | 74 + src/AcDream.Core/Social/SquelchState.cs | 35 + .../Ui/RetailPositionFormatter.cs | 31 + .../ClientCommandId.cs | 32 + .../Panels/Chat/ChatCommandRouter.cs | 21 +- .../Panels/Chat/RetailClientCommandCatalog.cs | 288 +- .../Panels/Settings/GameplaySettings.cs | 7 +- .../Panels/Settings/SettingsStore.cs | 68 +- .../UI/ClientCommandControllerTests.cs | 303 +- .../UI/Layout/FixtureLoader.cs | 6 + .../RetailConfirmationDialogServiceTests.cs | 67 + .../UI/Layout/RetailLayoutFixtureGenerator.cs | 21 + .../UI/Layout/fixtures/dialogs_2100003C.json | 2942 +++++++++++++++++ .../UI/RetailWindowLayoutPersistenceTests.cs | 25 + tests/AcDream.App.Tests/UI/UiTextTests.cs | 33 + .../GameEventWiringTests.cs | 85 + .../Messages/ClientCommandRequestsTests.cs | 163 + .../Messages/SocialStateMessagesTests.cs | 152 + .../Social/SocialStateTests.cs | 57 + .../Ui/RetailPositionFormatterTests.cs | 31 + .../Panels/Chat/ChatCommandRouterTests.cs | 8 +- .../Panels/Chat/ChatPanelInputTests.cs | 24 +- .../Chat/RetailClientCommandCatalogTests.cs | 75 + .../Panels/Settings/SettingsStoreTests.cs | 16 + 47 files changed, 6659 insertions(+), 99 deletions(-) create mode 100644 docs/research/2026-07-13-retail-client-command-families-pseudocode.md create mode 100644 src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs create mode 100644 src/AcDream.App/UI/RetailDataIdResolver.cs create mode 100644 src/AcDream.App/UI/UiDialogRoot.cs create mode 100644 src/AcDream.Core.Net/Messages/ClientCommandRequests.cs create mode 100644 src/AcDream.Core.Net/Messages/SocialStateMessages.cs create mode 100644 src/AcDream.Core/Social/FriendsState.cs create mode 100644 src/AcDream.Core/Social/SquelchState.cs create mode 100644 src/AcDream.Core/Ui/RetailPositionFormatter.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json create mode 100644 tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/SocialStateMessagesTests.cs create mode 100644 tests/AcDream.Core.Tests/Social/SocialStateTests.cs create mode 100644 tests/AcDream.Core.Tests/Ui/RetailPositionFormatterTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 3d9eb6cb..eb03dfcd 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 `, `@loadui `, -`@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. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 905ef832..3b01a7ff 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 5a75542c..a2239185 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -501,6 +501,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. - **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. +- **Retail client command families implemented 2026-07-13; live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets, and confirmation requests mount imported LayoutDesc `0x2100003C`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index a125e463..30b90f55 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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:** diff --git a/docs/research/2026-07-13-retail-client-command-families-pseudocode.md b/docs/research/2026-07-13-retail-client-command-families-pseudocode.md new file mode 100644 index 00000000..03d713dc --- /dev/null +++ b/docs/research/2026-07-13-retail-client-command-families-pseudocode.md @@ -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 ": add_friend(name) + "remove ": 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 ": 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 ": 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 + - 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 - 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. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 314e493f..4334690c 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -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; /// /// Local-player entity id used to skip self-collision in the diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 91d06d8c..c3f6e576 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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//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( clientCommandController.Execute); _commandBus.Register(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); + } + + /// + /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0: + /// flip the live display flag and persist it through the same settings + /// path used by the Display panel. + /// + 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) { diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs index b4c64a23..08eef64d 100644 --- a/src/AcDream.App/UI/ClientCommandController.cs +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -1,20 +1,69 @@ +using AcDream.Core.Physics; +using AcDream.Core.Ui; +using AcDream.Core.Social; using AcDream.UI.Abstractions; namespace AcDream.App.UI; /// /// 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. /// 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 ShowSystemMessage, + Action ShowWeenieError, + Func PlayerPublicWeenieBitfield, + Func ClientVersion, + Func CurrentPosition, + Func LastOutsideCorpsePosition, + Action> ShowConfirmation, + Action Suicide, + Action ClearChat, + Action SaveUi, + Action LoadUi, + Action SaveAutoUi, + Action LoadAutoUi, + Func IsAway, + Action SetAway, + Action SetAwayMessage, + Func AcceptLootPermits, + Action SetAcceptLootPermits, + Action DisplayConsent, + Action ClearConsent, + Action RemoveConsent, + Action SendEmote, + FriendsState Friends, + Action AddFriend, + Action RemoveFriend, + Action ClearFriends, + Action RequestLegacyFriends, + SquelchState Squelch, + Action ModifyCharacterSquelch, + Action ModifyAccountSquelch, + Action ModifyGlobalSquelch, + Func LastTeller, + Action ClearDesiredComponents, + Func HasOpenVendor, + Action 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 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 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 MessageTypes = + new Dictionary + { + [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 - 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"; + + /// + /// 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. + /// + private bool? HasPlayerFlag(EntityCollisionFlags flag) + { + uint? bitfield = _bindings.PlayerPublicWeenieBitfield(); + return bitfield is null + ? null + : (EntityCollisionFlagsExt.FromPwdBitfield(bitfield.Value) & flag) != 0; + } } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index e2f47f62..36bb928b 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 07573e5f..0f6c603e 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -221,6 +221,24 @@ public static class LayoutImporter }; } + /// + /// Retail UIElementManager::CreateRootElementByDataID 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. + /// + public static ElementInfo? ImportInfos( + DatCollection dats, + uint layoutId, + uint rootElementId) + { + var ld = dats.Get(layoutId); + if (ld is null) return null; + ElementDesc? root = FindDesc(ld, rootElementId); + return root is null + ? null + : Resolve(dats, root, new HashSet<(uint, uint)>()); + } + /// /// 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); } + /// Import one selected root from a catalog-style LayoutDesc. + public static ImportedLayout? Import( + DatCollection dats, + uint layoutId, + uint rootElementId, + Func resolve, + UiDatFont? datFont, + Func? 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 ──────────────────────────────────────────────── /// True when a pure-container leaf should inherit its base's subtree (the diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs new file mode 100644 index 00000000..86d42c46 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs @@ -0,0 +1,122 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retained implementation of retail DialogFactory's confirmation queue. +/// The visual tree is element 0x15 from the shared dialog catalog resolved by +/// GetDIDByEnum(2, 5); callbacks mirror property 0x92 from +/// ConfirmationDialog::ListenToElementMessage @ 0x00476670. +/// +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 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 _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 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 measure = _message.DatFont is { } font + ? font.MeasureWidth + : static text => text.Length * 8f; + IReadOnlyList 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(); + } +} diff --git a/src/AcDream.App/UI/RetailDataIdResolver.cs b/src/AcDream.App/UI/RetailDataIdResolver.cs new file mode 100644 index 00000000..5c1be6d7 --- /dev/null +++ b/src/AcDream.App/UI/RetailDataIdResolver.cs @@ -0,0 +1,28 @@ +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.UI; + +/// +/// Ports DBCache::GetDIDFromEnumStatic @ 0x00413940: the portal master map +/// selects an enum category, then that category map resolves the client enum value. +/// +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(masterDid, out var master) + || master is null + || !master.ClientEnumToID.TryGetValue(enumCategory, out uint subMapDid) + || !dats.Portal.TryGet(subMapDid, out var subMap) + || subMap is null + || !subMap.ClientEnumToID.TryGetValue(enumValue, out uint did)) + return 0u; + + return did; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index cf03e1f4..38a1e663 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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; diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs index 3f5ff430..90083c9e 100644 --- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -74,6 +74,55 @@ public sealed class RetailWindowLayoutPersistence : IDisposable } } + /// Force-save every attached window to the current automatic profile. + 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)); + } + + /// Save every attached window to a portable named retail UI profile. + public void SaveNamed(string profileName) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(profileName); + foreach (RetailWindowHandle handle in _attached) + _store.SaveNamedWindowLayout(profileName, handle.Name, Capture(handle)); + } + + /// Restore every window found in a portable named retail UI profile. + 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); diff --git a/src/AcDream.App/UI/UiDialogRoot.cs b/src/AcDream.App/UI/UiDialogRoot.cs new file mode 100644 index 00000000..3915b497 --- /dev/null +++ b/src/AcDream.App/UI/UiDialogRoot.cs @@ -0,0 +1,32 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Full-device modal root for retail dialog element classes. The authored child with id +/// 0x3D supplies the visible popup art; this root only owns modality and input capture. +/// +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; + } +} diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 8885fc22..b030d0da 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -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); } + /// Word-wrap text to a measured pixel width, preserving explicit newlines. + public static IReadOnlyList WrapWords( + string text, + Func measureWidth, + float maximumWidth) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(measureWidth); + if (maximumWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(maximumWidth)); + + var result = new List(); + 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; + } + /// /// The caret column for a horizontal position (already /// adjusted for the left padding, so x=0 is the start of the text). Walks the diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 137f8d1c..b5dc3a70 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -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? playerGuid = null, Action? onUseDone = null, - ItemManaState? itemMana = null) + ItemManaState? itemMana = null, + Action? onConfirmationRequest = null, + FriendsState? friends = null, + SquelchState? squelch = null, + Action>? 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) { diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs new file mode 100644 index 00000000..84ba9aee --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -0,0 +1,235 @@ +using System.Buffers.Binary; +using System.Text; + +namespace AcDream.Core.Net.Messages; + +/// +/// 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 +/// 0xF7B1 game-action envelope. +/// +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; + } +} diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index fa3a73ec..c4c00d86 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -89,6 +89,25 @@ public static class GameEvents try { return ReadString16L(payload, ref pos); } catch { return null; } } + /// + /// 0x01C3 QueryAgeResponse: target name (empty for self), then the + /// server-formatted played duration. Retail source: + /// CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40. + /// + public readonly record struct QueryAgeResponse(string Name, string Age); + + public static QueryAgeResponse? ParseQueryAgeResponse(ReadOnlySpan 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 ────────────────────────────────────────────────────────────── /// 0x028A WeenieError: generic game-logic failure code. @@ -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 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; } } diff --git a/src/AcDream.Core.Net/Messages/SocialStateMessages.cs b/src/AcDream.Core.Net/Messages/SocialStateMessages.cs new file mode 100644 index 00000000..8742d05a --- /dev/null +++ b/src/AcDream.Core.Net/Messages/SocialStateMessages.cs @@ -0,0 +1,138 @@ +using System.Buffers.Binary; +using AcDream.Core.Social; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail social-state parsers. Sources: CM_Social::DispatchUI_FriendsUpdate +/// @ 0x006A5DD0, FriendData::UnPack @ 0x005B9D20, and +/// SquelchDB::UnPack @ 0x006B1900. +/// +public static class SocialStateMessages +{ + public static FriendsUpdate? ParseFriendsUpdate(ReadOnlySpan payload) + { + int pos = 0; + try + { + uint count = ReadU32(payload, ref pos); + // PList::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((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 friends = ReadUInt32List(payload, ref pos); + IReadOnlyList 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 payload) + { + int pos = 0; + try + { + IReadOnlyDictionary accounts = ReadAccountTable(payload, ref pos); + IReadOnlyDictionary 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 ReadAccountTable( + ReadOnlySpan 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(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 ReadCharacterTable( + ReadOnlySpan 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(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 source, ref int pos) + { + uint wordCount = ReadU32(source, ref pos); + if (wordCount > 4) throw new FormatException("invalid vlong word count"); + var messageTypes = new HashSet(); + 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 source, ref int pos) + { + uint header = ReadU32(source, ref pos); + return ((ushort)(header & 0xFFFFu), (ushort)(header >> 16)); + } + + private static IReadOnlyList ReadUInt32List(ReadOnlySpan 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 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; + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index bcd4d17c..471a9f6d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1272,6 +1272,153 @@ public sealed class WorldSession : IDisposable SendGameAction(InteractRequests.BuildTeleToLifestone(seq)); } + /// Send retail marketplace recall (0x028D). + public void SendTeleportToMarketplace() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildMarketplace(seq)); + } + + /// Send retail full-PK arena recall (0x0027). + public void SendTeleportToPkArena() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildPkArena(seq)); + } + + /// Send retail PKLite arena recall (0x0026). + public void SendTeleportToPkLiteArena() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildPkLiteArena(seq)); + } + + /// Send retail personal-house recall (0x0262). + public void SendTeleportToHouse() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildHouseRecall(seq)); + } + + /// Send retail allegiance-mansion recall (0x0278). + public void SendTeleportToMansion() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildMansionRecall(seq)); + } + + /// Query the local character's played time (0x01C2). + public void SendQueryAge() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildQueryAge(seq)); + } + + /// Query the local character's creation date (0x01C4). + public void SendQueryBirth() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildQueryBirth(seq)); + } + + /// Reply to a server confirmation request (0x0275). + public void SendConfirmationResponse(uint confirmationType, uint contextId, bool accepted) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildConfirmationResponse( + seq, confirmationType, contextId, accepted)); + } + + /// Send the confirmed retail suicide action (0x0279). + 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)); + } + /// Send retail ChangeCombatMode (0x0053). 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); + + /// + /// Retail Proto_UI::SendToControl path used by standalone control + /// messages such as the legacy 0xF7CD friends request. + /// + 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 { diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index c1ea85c5..2f1c4b6b 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -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 diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index b5fa35db..413a260b 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -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 _attrs = new(); private readonly Dictionary _skills = new(); + private readonly Dictionary _positions = new(); private PropertyBundle _properties = new(); private readonly Spellbook? _spellbook; @@ -185,6 +187,20 @@ public sealed class LocalPlayerState /// All known skill snapshots, keyed by SkillId. public IReadOnlyDictionary Skills => _skills; + /// Player Position qualities keyed by retail PositionType. + public IReadOnlyDictionary Positions => _positions; + + public Position? GetPosition(uint positionType) => + _positions.TryGetValue(positionType, out var value) ? value : null; + + public void OnPositions(IReadOnlyDictionary positions) + { + _positions.Clear(); + foreach (var pair in positions) + _positions[pair.Key] = pair.Value; + CharacterChanged?.Invoke(); + } + /// Snapshot for one skill, or null if it has not arrived yet. public SkillSnapshot? GetSkill(uint skillId) => _skills.TryGetValue(skillId, out var s) ? s : null; diff --git a/src/AcDream.Core/Social/FriendsState.cs b/src/AcDream.Core/Social/FriendsState.cs new file mode 100644 index 00000000..af079d7d --- /dev/null +++ b/src/AcDream.Core/Social/FriendsState.cs @@ -0,0 +1,74 @@ +namespace AcDream.Core.Social; + +/// Authoritative client-side copy of retail's FriendDataList. +public sealed class FriendsState +{ + private readonly object _gate = new(); + private readonly List _entries = new(); + + public IReadOnlyList 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 Friends, + IReadOnlyList FriendOf); + +public enum FriendsUpdateType : uint +{ + Full = 0, + Add = 1, + Remove = 2, + RemoveSilent = 3, + OnlineStatus = 4, +} + +public sealed record FriendsUpdate( + FriendsUpdateType Type, + IReadOnlyList Entries); diff --git a/src/AcDream.Core/Social/SquelchState.cs b/src/AcDream.Core/Social/SquelchState.cs new file mode 100644 index 00000000..201a9ddd --- /dev/null +++ b/src/AcDream.Core/Social/SquelchState.cs @@ -0,0 +1,35 @@ +namespace AcDream.Core.Social; + +/// Authoritative copy of retail's server-supplied SquelchDB. +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 MessageTypes); + +public sealed record SquelchDatabase( + IReadOnlyDictionary Accounts, + IReadOnlyDictionary Characters, + SquelchInfo Global) +{ + public static SquelchDatabase Empty { get; } = new( + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary(), + new SquelchInfo(string.Empty, false, new HashSet())); +} diff --git a/src/AcDream.Core/Ui/RetailPositionFormatter.cs b/src/AcDream.Core/Ui/RetailPositionFormatter.cs new file mode 100644 index 00000000..8e902f87 --- /dev/null +++ b/src/AcDream.Core/Ui/RetailPositionFormatter.cs @@ -0,0 +1,31 @@ +using System.Globalization; +using AcDream.Core.Physics; + +namespace AcDream.Core.Ui; + +/// Retail text formatting for world positions and outdoor cells. +public static class RetailPositionFormatter +{ + /// + /// Verbatim field order and precision from + /// Position::ToString @ 0x005A9330. + /// + 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}"); + } + + /// + /// Port of LandDefs::CellidToCoordinateString @ 0x005A9CB0. + /// Returns null for indoor/invalid cells, matching the underlying + /// gid_to_lcoord failure. + /// + public static string? FormatOutdoorCell(uint cellId) => + RadarCoordinates.TryFromCell(cellId, out var coordinates) + ? coordinates.CombinedText + : null; +} diff --git a/src/AcDream.UI.Abstractions/ClientCommandId.cs b/src/AcDream.UI.Abstractions/ClientCommandId.cs index 55235acf..654cc7b6 100644 --- a/src/AcDream.UI.Abstractions/ClientCommandId.cs +++ b/src/AcDream.UI.Abstractions/ClientCommandId.cs @@ -8,4 +8,36 @@ public enum ClientCommandId { /// Recall to the character's bound lifestone. 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, } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs index 14ce9c58..7047f100 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -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; } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs index 6763ce8f..4f6610d2 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs @@ -7,9 +7,9 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// This is intentionally separate from chat aliases and ACE server commands. /// /// -/// First vertical slice: retail registers lifestone, lif, and -/// ls against ClientCommunicationSystem::DoLifestone @ 0x0056FC70. -/// See docs/research/2026-07-13-retail-client-command-routing-pseudocode.md. +/// Aliases and ownership come from the named-retail command table. Packet +/// and local-state behavior is recorded in the command-family pseudocode +/// notes under docs/research/. /// /// public static class RetailClientCommandCatalog @@ -18,14 +18,16 @@ public static class RetailClientCommandCatalog ClientCommandId Command, string Usage, string HelpText, - Func ValidateArguments); + Func ValidateArguments, + string? InvalidArgumentsText = null); /// A resolved retail command plus its raw, trimmed argument text. 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 ]", + "/afk [on|off|msg ] - Sets your away-from-keyboard status."); + + private static readonly Definition Consent = AnyArguments( + ClientCommandId.Consent, + "/consent >", + "/consent - Manages corpse-looting consent."); + + private static readonly Definition Emote = AnyArguments( + ClientCommandId.Emote, + "/emote ", + "/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 ", + "/friends_add - Adds a character to your friends list."); + + private static readonly Definition FriendsRemove = AnyArguments( + ClientCommandId.FriendsRemove, + "/friends_remove ", + "/friends_remove - Removes friends from your list."); + + private static readonly Definition Squelch = AnyArguments( + ClientCommandId.Squelch, + "/squelch [options] ", + "/squelch - Ignores messages from a player or account."); + + private static readonly Definition Unsquelch = AnyArguments( + ClientCommandId.Unsquelch, + "/unsquelch [options] ", + "/unsquelch - Stops ignoring messages from a player or account."); + + private static readonly Definition Filter = AnyArguments( + ClientCommandId.Filter, + "/filter -", + "/filter - - Globally hides a message category."); + + private static readonly Definition Unfilter = AnyArguments( + ClientCommandId.Unfilter, + "/unfilter -", + "/unfilter - - 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 ByVerb = new Dictionary(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); /// @@ -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; } /// Help line generated from the same definition routing uses. - 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) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs index 35f95f62..51a1b777 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs @@ -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 { /// Sensible starting values for first launch. NOT bit-exact /// to retail's Default_CharacterOption = 0x50C4A54A + @@ -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); } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index e243dd0a..412f0b4e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -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); } + /// + /// Load one window from a user-named retail @saveui profile. + /// Named profiles are intentionally independent of character and screen + /// resolution, matching retail's portable UI files. + /// + 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; + } + } + + /// Save one window to a user-named retail @saveui profile. + 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, diff --git a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs index 59b83acc..5802ad2b 100644 --- a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs @@ -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(); + 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(); + var errors = new List(); + 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(); + var errors = new List(); + 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(); + 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(); + var messages = new List(); + 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(); + 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(); + var messages = new List(); + 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(); + var messages = new List(); + 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(); + 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(); + var messages = new List(); + 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(); + 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(); + var messages = new List(); + 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(() => controller.Execute(command)); } + + private static ClientCommandController NewController( + List? calls = null, + List? errors = null, + uint? playerBitfield = 0x02000028u, + List? 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}"))); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index 57fac03a..a1d1e05e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -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) diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs new file mode 100644 index 00000000..a46a3bc9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs @@ -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(layout.Root); + Assert.IsType(layout.FindElement(RetailConfirmationDialogService.MessageElementId)); + Assert.IsType(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId)); + Assert.IsType(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( + 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( + 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(); + + service.Show("first", accepted => results.Add($"first:{accepted}")); + service.Show("second", accepted => results.Add($"second:{accepted}")); + + Assert.Equal(1, service.PendingCount); + Assert.IsType(layout.FindElement( + RetailConfirmationDialogService.RejectButtonId)).OnClick!(); + Assert.Equal(["first:False"], results); + Assert.True(service.IsOpen); + Assert.Equal(0, service.PendingCount); + + Assert.IsType(layout.FindElement( + RetailConfirmationDialogService.AcceptButtonId)).OnClick!(); + Assert.Equal(["first:False", "second:True"], results); + Assert.False(service.IsOpen); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs index 1f463317..84dfac17 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs @@ -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(masterDid, out var master)); + Assert.True(master!.ClientEnumToID.TryGetValue(5u, out uint uiMapDid)); + Assert.True(dats.Portal.TryGet(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 = "") diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json new file mode 100644 index 00000000..7df33b7d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json @@ -0,0 +1,2942 @@ +{ + "Id": 21, + "Type": 19, + "X": 0, + "Y": 0, + "Width": 800, + "Height": 600, + "OriginalParentWidth": 0, + "OriginalParentHeight": 0, + "HasOriginalParentSize": false, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "205": { + "Kind": 0, + "MasterPropertyId": 205, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "51": { + "Kind": 1, + "MasterPropertyId": 51, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "52": { + "Kind": 1, + "MasterPropertyId": 52, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "65": { + "Kind": 1, + "MasterPropertyId": 65, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 61, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 400, + "Height": 95, + "OriginalParentWidth": 800, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100687323, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": { + "60": { + "Kind": 4, + "MasterPropertyId": 60, + "UnsignedValue": 0, + "IntegerValue": 400, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "205": { + "Kind": 0, + "MasterPropertyId": 205, + "UnsignedValue": 3, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "64": { + "Kind": 1, + "MasterPropertyId": 64, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "": { + "Item1": 100687323, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268436271, + "Type": 3, + "X": 0, + "Y": 48, + "Width": 400, + "Height": 32, + "OriginalParentWidth": 400, + "OriginalParentHeight": 95, + "HasOriginalParentSize": true, + "Left": 3, + "Top": 2, + "Right": 3, + "Bottom": 1, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 23, + "Type": 1, + "X": 80, + "Y": 0, + "Width": 80, + "Height": 32, + "OriginalParentWidth": 400, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 7, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741825, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "36": { + "Kind": 4, + "MasterPropertyId": 36, + "UnsignedValue": 0, + "IntegerValue": 7, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "83": { + "Kind": 1, + "MasterPropertyId": 83, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "19": { + "Kind": 1, + "MasterPropertyId": 19, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "63": { + "Kind": 4, + "MasterPropertyId": 63, + "UnsignedValue": 0, + "IntegerValue": 65, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "32": { + "Kind": 1, + "MasterPropertyId": 32, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "23": { + "Kind": 5, + "MasterPropertyId": 23, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 252760419, + "TableId": 587202561, + "Override": 0, + "English": 1, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "33": { + "Kind": 1, + "MasterPropertyId": 33, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 1, + "FontDid": 1073741825, + "HJustify": 1, + "VJustify": 1, + "FontColor": { + "X": 1, + "Y": 1, + "Z": 1, + "W": 1 + }, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "Normal", + "Children": [ + { + "Id": 268436174, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 32, + "Height": 32, + "OriginalParentWidth": 79, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682902, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682902, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682901, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682900, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "Normal": { + "Item1": 100682902, + "Item2": 3 + }, + "Normal_rollover": { + "Item1": 100682902, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100682901, + "Item2": 3 + }, + "Ghosted": { + "Item1": 100682900, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268436175, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 15, + "Height": 32, + "OriginalParentWidth": 79, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682905, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682905, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682904, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682903, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "Normal": { + "Item1": 100682905, + "Item2": 3 + }, + "Normal_rollover": { + "Item1": 100682905, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100682904, + "Item2": 3 + }, + "Ghosted": { + "Item1": 100682903, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268436176, + "Type": 3, + "X": 47, + "Y": 0, + "Width": 32, + "Height": 32, + "OriginalParentWidth": 79, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682908, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682908, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682907, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682906, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "Normal": { + "Item1": 100682908, + "Item2": 3 + }, + "Normal_rollover": { + "Item1": 100682908, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100682907, + "Item2": 3 + }, + "Ghosted": { + "Item1": 100682906, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 25, + "Type": 1, + "X": 240, + "Y": 0, + "Width": 80, + "Height": 32, + "OriginalParentWidth": 400, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 2, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 7, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741825, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "36": { + "Kind": 4, + "MasterPropertyId": 36, + "UnsignedValue": 0, + "IntegerValue": 7, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "83": { + "Kind": 1, + "MasterPropertyId": 83, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "19": { + "Kind": 1, + "MasterPropertyId": 19, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "63": { + "Kind": 4, + "MasterPropertyId": 63, + "UnsignedValue": 0, + "IntegerValue": 65, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "32": { + "Kind": 1, + "MasterPropertyId": 32, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "23": { + "Kind": 5, + "MasterPropertyId": 23, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 183569327, + "TableId": 587202561, + "Override": 0, + "English": 1, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "33": { + "Kind": 1, + "MasterPropertyId": 33, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 1, + "FontDid": 1073741825, + "HJustify": 1, + "VJustify": 1, + "FontColor": { + "X": 1, + "Y": 1, + "Z": 1, + "W": 1 + }, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "Normal", + "Children": [ + { + "Id": 268436174, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 32, + "Height": 32, + "OriginalParentWidth": 79, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682902, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682902, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682901, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682900, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "Normal": { + "Item1": 100682902, + "Item2": 3 + }, + "Normal_rollover": { + "Item1": 100682902, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100682901, + "Item2": 3 + }, + "Ghosted": { + "Item1": 100682900, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268436175, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 15, + "Height": 32, + "OriginalParentWidth": 79, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682905, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682905, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682904, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682903, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "Normal": { + "Item1": 100682905, + "Item2": 3 + }, + "Normal_rollover": { + "Item1": 100682905, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100682904, + "Item2": 3 + }, + "Ghosted": { + "Item1": 100682903, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268436176, + "Type": 3, + "X": 47, + "Y": 0, + "Width": 32, + "Height": 32, + "OriginalParentWidth": 79, + "OriginalParentHeight": 32, + "HasOriginalParentSize": true, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682908, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682908, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682907, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100682906, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "Normal": { + "Item1": 100682908, + "Item2": 3 + }, + "Normal_rollover": { + "Item1": 100682908, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100682907, + "Item2": 3 + }, + "Ghosted": { + "Item1": 100682906, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + } + ] + } + ] + }, + { + "Id": 62, + "Type": 12, + "X": 15, + "Y": 15, + "Width": 370, + "Height": 18, + "OriginalParentWidth": 400, + "OriginalParentHeight": 95, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741825, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 204, + "Green": 247, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "28": { + "Kind": 7, + "MasterPropertyId": 28, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741825, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "29": { + "Kind": 7, + "MasterPropertyId": 29, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 178, + "Red": 0, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 4, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "33": { + "Kind": 1, + "MasterPropertyId": 33, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "34": { + "Kind": 6, + "MasterPropertyId": 34, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 7, + "Green": 15, + "Red": 17, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 51, + "Green": 51, + "Red": 51, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + }, + "6": { + "Id": 6, + "Name": "Highlight", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + }, + "7": { + "Id": 7, + "Name": "Highlight_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + }, + "8": { + "Id": 8, + "Name": "Highlight_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 51, + "Green": 51, + "Red": 51, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 51, + "Green": 51, + "Red": 51, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 1073741825, + "HJustify": 1, + "VJustify": 2, + "FontColor": { + "X": 1, + "Y": 0.96862745, + "Z": 0.8, + "W": 1 + }, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index 287e0338..7fd3aa8e 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -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, diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index 946f7fad..391cc824 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -8,6 +8,17 @@ namespace AcDream.App.Tests.UI; public class UiTextTests { + [Fact] + public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs() + { + IReadOnlyList 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 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 lines = UiText.WrapWords( + "go abcdef", + static text => text.Length, + maximumWidth: 5f); + + Assert.Equal(["go ab", "cdef"], lines); + } + // ── VOffset: vertical positioning for single-line mode ─────────────────── /// diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 3f88c459..785ab039 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs new file mode 100644 index 00000000..fdcdbf47 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs @@ -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, 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 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, 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 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)); +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialStateMessagesTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialStateMessagesTests.cs new file mode 100644 index 00000000..d047b6c3 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialStateMessagesTests.cs @@ -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(); + 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(); + + 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(); + WriteHashHeader(payload, count: 1, buckets: 0); + + Assert.Null(SocialStateMessages.ParseSquelchDatabase([.. payload])); + } + + private static void WriteFriend( + List destination, + uint id, + bool online, + bool appearOffline, + string name, + IReadOnlyList friends, + IReadOnlyList 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 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 destination, IReadOnlyList values) + { + WriteU32(destination, (uint)values.Count); + foreach (uint value in values) WriteU32(destination, value); + } + + private static void WriteHashHeader(List destination, ushort count, ushort buckets) => + WriteU32(destination, ((uint)buckets << 16) | count); + + private static void WriteString16L(List 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 destination, uint value) + { + destination.Add((byte)value); + destination.Add((byte)(value >> 8)); + destination.Add((byte)(value >> 16)); + destination.Add((byte)(value >> 24)); + } +} diff --git a/tests/AcDream.Core.Tests/Social/SocialStateTests.cs b/tests/AcDream.Core.Tests/Social/SocialStateTests.cs new file mode 100644 index 00000000..ef4feb11 --- /dev/null +++ b/tests/AcDream.Core.Tests/Social/SocialStateTests.cs @@ -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(StringComparer.OrdinalIgnoreCase) + { + ["Account"] = 7u, + }, + new Dictionary + { + [42u] = new("Character", false, new HashSet { 2u }), + }, + new SquelchInfo(string.Empty, false, new HashSet { 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); +} diff --git a/tests/AcDream.Core.Tests/Ui/RetailPositionFormatterTests.cs b/tests/AcDream.Core.Tests/Ui/RetailPositionFormatterTests.cs new file mode 100644 index 00000000..f6a57086 --- /dev/null +++ b/tests/AcDream.Core.Tests/Ui/RetailPositionFormatterTests.cs @@ -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)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index b80ca626..54093d80 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -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(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.ClearChat, command.Command); + Assert.Equal("", command.Arguments); + Assert.Single(log.Snapshot()); } [Theory] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 35d7b5fb..c26cb055 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -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(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(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(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.ClearChat, command.Command); + Assert.Single(log.Snapshot()); } [Fact] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs index 7fe96e03..b66d82ae 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs @@ -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() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 59e06fa1..1f994d77 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -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)); + } }