diff --git a/docs/ISSUES.md b/docs/ISSUES.md index eb03dfcd..e03ea0ad 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 — full named-retail command-family port complete 2026-07-13, pending live gate +**Status:** IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate **Severity:** MEDIUM **Component:** retained UI / chat commands / net @@ -74,6 +74,15 @@ dialog, and unknown `/` commands still publish `SendServerCommandCmd` in canonical `@` form so ACE administrator commands continue to work. Both chat backends use the same router. +**Corrective pass (2026-07-13):** The first connected gate passed all other +representative commands and exposed two presentation gaps. Suicide success +arrives as WeenieError `0x004A`; it now resolves to retail's informational +"Ack! You killed yourself!" instead of the raw numeric fallback. `/framerate` +now mounts and toggles SmartBox LayoutDesc `0x2100000F` element `0x10000047`, +showing the live `FPS` and `DEG` values with the authored font and two-decimal +format. `/framrate` remains unknown because retail registers no misspelled +alias. + **Research:** `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`; @@ -85,7 +94,9 @@ backends use the same router. 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. +command such as `/ci 629 1` still works. `/die` ends with the retail success +text rather than a raw error code, and exact `/framerate` toggles the two-line +in-world SmartBox readout. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a2239185..7f272db1 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -501,7 +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`. +- **Retail client command families implemented 2026-07-13; corrective 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`. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #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 30b90f55..afa1e888 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -484,7 +484,7 @@ 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)** — +- **Retail client command families (implemented 2026-07-13; corrective 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 @@ -492,6 +492,9 @@ include dungeons. 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`. + The first connected gate passed the family except for raw suicide success + code `0x004A` and the incomplete title-bar-only FPS toggle; the corrective + pass maps retail's success text and mounts the authored SmartBox FPS element. - **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 index 03d713dc..691e3315 100644 --- a/docs/research/2026-07-13-retail-client-command-families-pseudocode.md +++ b/docs/research/2026-07-13-retail-client-command-families-pseudocode.md @@ -14,6 +14,12 @@ cross-check. - `ClientCommunicationSystem::DoMarketplace @ 0x0056FCE0` - `ClientCommunicationSystem::DoLockUI @ 0x005703B0` - `ClientCommunicationSystem::DoFrameRate @ 0x005707D0` +- `gmSmartBoxUI::UpdateFPSMeter @ 0x004D63A0` +- `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` +- `gmSmartBoxUI::UseTime @ 0x004D6E30` +- `ClientCommunicationSystem::DoDie @ 0x00580050` +- `ClientCommunicationSystem::DieDialogCallback @ 0x0057BA70` +- `CM_Character::Event_Suicide @ 0x006A1B00` - `ClientCommunicationSystem::DoPKArena @ 0x005788D0` - `ClientCommunicationSystem::DoPKLArena @ 0x005789D0` - `ClientCommunicationSystem::DoAge @ 0x0057C5A0` @@ -104,7 +110,18 @@ framerate(arguments): if arguments: display local usage else: show_framerate = !show_framerate - notify UI so the display changes immediately + notify gmSmartBoxUI + if enabled: + resolve UIElement_Text 0x10000047 from LayoutDesc 0x2100000F + show it and update it immediately + else: + hide it and clear the active FPS-display pointer + +smartbox_use_time(): + if the FPS-display pointer is active: + update the display with two-precision values: + "FPS: {Render.GetFramerate():F2}" + "DEG: {active_degrade_multiplier:F2}" lockui(arguments): if arguments: display local usage @@ -113,6 +130,29 @@ lockui(arguments): broadcast the UI-lock state change ``` +The two-line template is portal string-table entry `0x0DCFFF73` in table +`0x23000001`; its authored text fragments are `FPS: ` and `\nDEG: `. The +display is the exact 128×36 SmartBox text element at `(1,1)`, using font +`0x4000001A`. Retail registers only the exact command name `/framerate`; +`/framrate` is not an alias. + +```text +die(arguments): + if arguments: display local usage + else: + show the authored local confirmation dialog + if accepted: send parameterless game action 0x0279 + +suicide_response(weenie_error): + if code == 0x004A: + display "Ack! You killed yourself!" +``` + +`0x004A` is an informational success response, despite arriving through the +WeenieError message family. Retail's own error-string table contains that exact +text; exposing the numeric fallback makes a successful `/die` look like a +failure. + ```text query_age_response(name, duration): if name is empty: display "You have played for {duration}." diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c3f6e576..0f16d7bc 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2150,6 +2150,15 @@ public sealed class GameWindow : IDisposable SetRetailCombatGameplay), JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings( () => _playerController?.JumpCharge ?? default), + Fps: new AcDream.App.UI.FpsRuntimeBindings( + () => _lastFps, + // Retail displays either the user degrade bias or the live + // distance-driven multiplier. acdream does not yet implement + // adaptive distance degradation (tracked by TS-15), so its + // effective multiplier is exactly 1.0. + () => 1.0, + () => _settingsVm?.DisplayDraft.ShowFps + ?? _persistedDisplay.ShowFps), Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Objects, () => Shortcuts, @@ -9988,32 +9997,23 @@ public sealed class GameWindow : IDisposable int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; - // L.0 Display tab: ShowFps gates the perf string in the - // title bar. Default is true (matches pre-L.0 behaviour); - // unchecking the toggle in Display tab collapses the title - // to just "acdream" for a cleaner alt-tab experience. + // Keep the developer diagnostic title independent from retail's + // /framerate switch. That command owns the in-world SmartBox readout; + // conflating it with window chrome was the original incomplete port. // - // When perf is shown, also include the in-game calendar/time — + // Also include the in-game calendar/time — // matches retail's @timestamp output ("Date: , // PY Time: "). Uses NowTicks (server-synced // + wall-clock interpolation) so the user can read the same // fields off both acdream and retail and confirm clock parity // directly. Drift > 1 hour = real bug. - bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true; - if (showFps) - { - double tNow = WorldTime.NowTicks; - var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow); - double df = WorldTime.DayFraction; - _window!.Title = - $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " - + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " - + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; - } - else - { - _window!.Title = "acdream"; - } + double tNow = WorldTime.NowTicks; + var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow); + double df = WorldTime.DayFraction; + _window!.Title = + $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " + + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; @@ -11482,7 +11482,7 @@ public sealed class GameWindow : IDisposable ShowFps = !_persistedDisplay.ShowFps, }; if (_settingsVm is not null) - _settingsVm.SetDisplay(_settingsVm.DisplayDraft with + _settingsVm.ApplyExternalDisplayChange(display => display with { ShowFps = _persistedDisplay.ShowFps, }); diff --git a/src/AcDream.App/UI/Layout/RetailFpsController.cs b/src/AcDream.App/UI/Layout/RetailFpsController.cs new file mode 100644 index 00000000..b3a299b2 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailFpsController.cs @@ -0,0 +1,79 @@ +using System.Globalization; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds retail's SmartBox frame-rate display to the authored text element from +/// LayoutDesc 0x2100000F. +/// +/// +/// Faithful port of gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0, +/// gmSmartBoxUI::UpdateFPSMeter @ 0x004D63A0, and +/// gmSmartBoxUI::UseTime @ 0x004D6E30. Portal string table 0x23000001, +/// string 0x0DCFFF73 supplies the two labels FPS: and DEG:; +/// retail inserts both floating-point values with two decimal places. +/// +public sealed class RetailFpsController +{ + public const uint LayoutId = 0x2100000Fu; + public const uint DisplayElementId = 0x10000047u; + + private readonly UiText _display; + private readonly Func _framesPerSecond; + private readonly Func _degradeMultiplier; + private readonly Func _isVisible; + + private RetailFpsController( + UiText display, + Func framesPerSecond, + Func degradeMultiplier, + Func isVisible) + { + _display = display; + _framesPerSecond = framesPerSecond; + _degradeMultiplier = degradeMultiplier; + _isVisible = isVisible; + + // The selected catalog element is authored at (1,1) inside gmSmartBoxUI. + // Selective LayoutDesc import makes it a root, so restore that parent-relative + // placement explicitly instead of letting it drift to (0,0). + _display.Left = 1f; + _display.Top = 1f; + _display.Padding = 1f; + _display.Selectable = false; + _display.LinesProvider = BuildLines; + Tick(); + } + + public UiText Display => _display; + + public static RetailFpsController? Bind( + ImportedLayout layout, + Func framesPerSecond, + Func degradeMultiplier, + Func isVisible) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(framesPerSecond); + ArgumentNullException.ThrowIfNull(degradeMultiplier); + ArgumentNullException.ThrowIfNull(isVisible); + + return layout.FindElement(DisplayElementId) is UiText display + ? new RetailFpsController(display, framesPerSecond, degradeMultiplier, isVisible) + : null; + } + + /// Mirrors the retail notice-driven show/hide state each UI tick. + public void Tick() => _display.Visible = _isVisible(); + + private IReadOnlyList BuildLines() + { + string fps = _framesPerSecond().ToString("F2", CultureInfo.InvariantCulture); + string degrade = _degradeMultiplier().ToString("F2", CultureInfo.InvariantCulture); + return + [ + new UiText.Line($"FPS: {fps}", _display.DefaultColor), + new UiText.Line($"DEG: {degrade}", _display.DefaultColor), + ]; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 38a1e663..10068258 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -45,6 +45,11 @@ public sealed record CombatRuntimeBindings( public sealed record JumpPowerbarRuntimeBindings(Func Snapshot); +public sealed record FpsRuntimeBindings( + Func FramesPerSecond, + Func DegradeMultiplier, + Func IsVisible); + public sealed record ToolbarRuntimeBindings( ClientObjectTable Objects, Func> Shortcuts, @@ -110,6 +115,7 @@ public sealed record RetailUiRuntimeBindings( RadarRuntimeBindings Radar, CombatRuntimeBindings Combat, JumpPowerbarRuntimeBindings JumpPowerbar, + FpsRuntimeBindings Fps, ToolbarRuntimeBindings Toolbar, CharacterRuntimeBindings Character, InventoryRuntimeBindings Inventory, @@ -135,6 +141,7 @@ public sealed class RetailUiRuntime : IDisposable private RetailUiRuntime(RetailUiRuntimeBindings bindings) { _bindings = bindings; + MountFpsDisplay(); MountVitals(); MountRadar(); MountChat(); @@ -180,6 +187,7 @@ public sealed class RetailUiRuntime : IDisposable public ToolbarInputController? ToolbarInputController { get; private set; } public CombatUiController? CombatUiController { get; private set; } public JumpPowerbarController? JumpPowerbarController { get; private set; } + public RetailFpsController? FpsController { get; private set; } public SelectedObjectController? SelectedObjectController { get; private set; } public UiViewport? PaperdollViewportWidget { get; private set; } public UiNineSlicePanel? InventoryFrame { get; private set; } @@ -201,6 +209,7 @@ public sealed class RetailUiRuntime : IDisposable public void Tick(double deltaSeconds) { + FpsController?.Tick(); JumpPowerbarController?.Tick(); SelectedObjectController?.Tick(deltaSeconds); ConfirmationDialogs?.Tick(); @@ -269,6 +278,47 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveFont); } + private ImportedLayout? Import(uint layoutId, uint rootElementId) + { + lock (_bindings.Assets.DatLock) + return LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + rootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + + private void MountFpsDisplay() + { + ImportedLayout? layout = Import( + RetailFpsController.LayoutId, + RetailFpsController.DisplayElementId); + if (layout is null) + { + Console.WriteLine("[D.2b] FPS display: SmartBox element 0x10000047 not found."); + return; + } + + FpsRuntimeBindings b = _bindings.Fps; + FpsController = RetailFpsController.Bind( + layout, + b.FramesPerSecond, + b.DegradeMultiplier, + b.IsVisible); + if (FpsController is null) + { + Console.WriteLine("[D.2b] FPS display: SmartBox element is not UIElement_Text."); + return; + } + + // SmartBox HUD content is mounted before movable windows, matching retail's + // screen-layer ordering and ensuring ordinary panels can cover the readout. + Host.Root.AddChild(layout.Root); + Console.WriteLine("[D.2b] retail FPS display from SmartBox LayoutDesc 0x2100000F."); + } + private void MountVitals() { ImportedLayout? layout = Import(0x2100006Cu); diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index 2f1c4b6b..5178a091 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -96,6 +96,7 @@ public static class WeenieErrorMessages // Movement / teleport / housing [0x0036] = "Action cancelled!", // ActionCancelled [0x003D] = "You charged too far!", // YouChargedTooFar + [0x004A] = "Ack! You killed yourself!", // YouKilledYourself [0x0498] = "You have moved too far!", // YouHaveMovedTooFar [0x0499] = "That is not a valid destination!", // TeleToInvalidPosition [0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.", diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index 3b5a2b66..e0dfb2a0 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -25,19 +25,17 @@ public sealed record DisplaySettings( QualityPreset Quality) { /// Values used on first launch / when settings.json is absent. - /// All defaults pinned to the pre-L.0 runtime state — Resolution + /// Geometry/render defaults preserve the pre-L.0 runtime state — Resolution /// matches the WindowOptions startup size (1280×720), FieldOfView /// matches camera FovY (60°), VSync matches WindowOptions (false), - /// ShowFps preserves the perf string in the title bar. Net effect: - /// opening Display + Save without touching anything is a complete - /// visual no-op. + /// ShowFps matches retail's initially-hidden SmartBox FPS readout. public static DisplaySettings Default { get; } = new( Resolution: "1280x720", Fullscreen: false, VSync: false, FieldOfView: 60f, Gamma: 1.0f, - ShowFps: true, + ShowFps: false, Quality: QualityPreset.High); /// 16:9 resolution presets offered in the dropdown. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index f4bcc36d..7988cf92 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -151,6 +151,20 @@ public sealed class SettingsVM _displayDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Apply one externally persisted display-option change to both snapshots. + /// Existing unsaved edits to other fields remain drafts rather than being + /// accidentally promoted to persisted state. + /// + public void ApplyExternalDisplayChange(Func update) + { + ArgumentNullException.ThrowIfNull(update); + _displayPersisted = update(_displayPersisted) + ?? throw new InvalidOperationException("Display update returned null."); + _displayDraft = update(_displayDraft) + ?? throw new InvalidOperationException("Display update returned null."); + } + /// /// Replace the entire Audio draft with . /// Live audio preview is achieved at the host layer by pushing diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index a1d1e05e..11f6d633 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -107,6 +107,12 @@ public static class FixtureLoader public static ElementInfo LoadConfirmationDialogInfos() => LoadInfos("dialogs_2100003C.json"); + public static ImportedLayout LoadFpsDisplay() + => LayoutImporter.Build(LoadFpsDisplayInfos(), _ => (0u, 0, 0), null); + + public static ElementInfo LoadFpsDisplayInfos() + => LoadInfos("smartbox_fps_2100000F.json"); + // ── Shared loader ──────────────────────────────────────────────────────── private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailFpsControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailFpsControllerTests.cs new file mode 100644 index 00000000..715f3ea5 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailFpsControllerTests.cs @@ -0,0 +1,57 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class RetailFpsControllerTests +{ + [Fact] + public void AuthoredSmartBoxDisplay_FormatsUpdatesAndTogglesLikeRetail() + { + double fps = 59.994; + double degrade = 0.875; + bool visible = false; + ImportedLayout layout = FixtureLoader.LoadFpsDisplay(); + ElementInfo info = FixtureLoader.LoadFpsDisplayInfos(); + + Assert.Equal(0x4000001Au, info.FontDid); + Assert.Equal(128, info.Width); + Assert.Equal(36, info.Height); + Assert.Equal(1u, info.Left); + Assert.Equal(1u, info.Top); + + var controller = RetailFpsController.Bind( + layout, () => fps, () => degrade, () => visible)!; + + UiText display = controller.Display; + Assert.Equal(RetailFpsController.DisplayElementId, display.ElementId); + Assert.Equal(128f, display.Width); + Assert.Equal(36f, display.Height); + Assert.Equal(1f, display.Left); + Assert.Equal(1f, display.Top); + Assert.False(display.Visible); + + Assert.Equal( + ["FPS: 59.99", "DEG: 0.88"], + display.LinesProvider().Select(line => line.Text)); + + fps = 144.0; + degrade = 1.0; + visible = true; + controller.Tick(); + + Assert.True(display.Visible); + Assert.Equal( + ["FPS: 144.00", "DEG: 1.00"], + display.LinesProvider().Select(line => line.Text)); + } + + [Fact] + public void Bind_RejectsLayoutWithoutAuthoredTextElement() + { + var root = new UiPanel(); + var layout = new ImportedLayout(root, new Dictionary()); + + Assert.Null(RetailFpsController.Bind(layout, () => 60, () => 1, () => true)); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs index 84dfac17..4316864b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs @@ -64,6 +64,25 @@ public sealed class RetailLayoutFixtureGenerator Path.Combine(FixtureDirectory(), $"dialogs_{dialogsLayoutDid:X8}.json"), dialogsJson); + const uint smartboxLayoutDid = 0x2100000Fu; + var fps = LayoutImporter.ImportInfos( + dats, + smartboxLayoutDid, + 0x10000047u); + Assert.NotNull(fps); + Assert.Equal(0x4000001Au, fps!.FontDid); + var fpsStrings = new DatStringResolver(dats); + Assert.Equal("FPS: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 0)); + Assert.Equal("\nDEG: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 1)); + var fpsJson = JsonSerializer.Serialize(fps, new JsonSerializerOptions + { + IncludeFields = true, + WriteIndented = true, + }); + File.WriteAllText( + Path.Combine(FixtureDirectory(), $"smartbox_fps_{smartboxLayoutDid:X8}.json"), + fpsJson); + foreach (var (layoutId, fileName) in Layouts) { var info = LayoutImporter.ImportInfos(dats, layoutId); diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/smartbox_fps_2100000F.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/smartbox_fps_2100000F.json new file mode 100644 index 00000000..f9e0fca1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/smartbox_fps_2100000F.json @@ -0,0 +1,395 @@ +{ + "Id": 268435527, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 128, + "Height": 36, + "OriginalParentWidth": 0, + "OriginalParentHeight": 0, + "HasOriginalParentSize": false, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 15, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "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": {} + }, + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 2, + "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": {} + }, + "81": { + "Kind": 1, + "MasterPropertyId": 81, + "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": {} + }, + "59": { + "Kind": 1, + "MasterPropertyId": 59, + "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": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 2, + "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": 1073741850, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 2, + "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": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 1073741850, + "HJustify": 0, + "VJustify": 2, + "FontColor": { + "X": 1, + "Y": 1, + "Z": 1, + "W": 1 + }, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs index bb446db2..6124eb4a 100644 --- a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs +++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs @@ -99,6 +99,7 @@ public sealed class WeenieErrorMessagesTests [Theory] [InlineData(0x0036u, "Action cancelled!")] [InlineData(0x003Du, "You charged too far!")] + [InlineData(0x004Au, "Ack! You killed yourself!")] public void Format_CombatMovementErrors(uint code, string expected) => Assert.Equal(expected, WeenieErrorMessages.Format(code, null)); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs index 90b1bb25..d8fe9bca 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -17,17 +17,14 @@ public sealed class DisplaySettingsTests // · Resolution matches WindowOptions (1280×720 in GameWindow.Run) // · FieldOfView matches camera FovY (60° = π/3) // · VSync matches WindowOptions (false during dev) - // · ShowFps true preserves the perf string in the title bar - // Net effect: opening Display + Save with no edits is a visual - // no-op (no window resize, no camera FovY change, no title - // bar change). + // · ShowFps false matches retail's initially-hidden SmartBox meter var d = DisplaySettings.Default; Assert.Equal("1280x720", d.Resolution); Assert.False(d.Fullscreen); Assert.False(d.VSync); Assert.Equal(60f, d.FieldOfView); Assert.Equal(1.0f, d.Gamma); - Assert.True(d.ShowFps); + Assert.False(d.ShowFps); } [Fact] @@ -65,7 +62,7 @@ public sealed class DisplaySettingsTests // Other fields untouched. Assert.Equal("1280x720", d.Resolution); Assert.False(d.VSync); - Assert.True(d.ShowFps); + Assert.False(d.ShowFps); } private static int ParseWidth(string res) diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index b892cf1c..c3e5b234 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -271,12 +271,31 @@ public sealed class SettingsVMTests public void SetDisplay_marks_unsaved_changes() { var (vm, _, _, _, _, _, _, _, _, _) = Build(); - // Default ShowFps is true → flip to false to ensure the with- + // Default ShowFps is false → flip to true to ensure the with- // expression actually mutates a field. - vm.SetDisplay(vm.DisplayDraft with { ShowFps = false }); + vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); Assert.True(vm.HasUnsavedChanges); } + [Fact] + public void ApplyExternalDisplayChange_updates_both_snapshots_and_preserves_other_drafts() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + + vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 90f }); + Assert.True(vm.HasUnsavedChanges); + + vm.ApplyExternalDisplayChange(display => display with { ShowFps = true }); + + Assert.True(vm.DisplayDraft.ShowFps); + Assert.Equal(90f, vm.DisplayDraft.FieldOfView); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + Assert.True(vm.DisplayDraft.ShowFps); + Assert.Equal(DisplaySettings.Default.FieldOfView, vm.DisplayDraft.FieldOfView); + } + [Fact] public void Save_invokes_display_callback_with_draft() {