fix #213: complete suicide and framerate presentation
Translate suicide response 0x004A as retail's successful self-kill notice. Port /framerate onto the authored SmartBox FPS element with live two-decimal FPS and DEG values, keep diagnostic window chrome independent, and synchronize command-driven settings without discarding panel drafts. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
9ea579bdd0
commit
43f7c7807c
17 changed files with 729 additions and 39 deletions
|
|
@ -48,7 +48,7 @@ Copy this block when adding a new issue:
|
|||
|
||||
## #213 — Retail client commands were sent to ACE as chat text
|
||||
|
||||
**Status:** IN-PROGRESS — 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
|
|
|
|||
|
|
@ -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,20 +9997,16 @@ 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: <Month> <Day>,
|
||||
// PY <Year> Time: <HourName>"). 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;
|
||||
|
|
@ -10009,11 +10014,6 @@ public sealed class GameWindow : IDisposable
|
|||
$"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";
|
||||
}
|
||||
_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,
|
||||
});
|
||||
|
|
|
|||
79
src/AcDream.App/UI/Layout/RetailFpsController.cs
Normal file
79
src/AcDream.App/UI/Layout/RetailFpsController.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds retail's SmartBox frame-rate display to the authored text element from
|
||||
/// <c>LayoutDesc 0x2100000F</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Faithful port of <c>gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0</c>,
|
||||
/// <c>gmSmartBoxUI::UpdateFPSMeter @ 0x004D63A0</c>, and
|
||||
/// <c>gmSmartBoxUI::UseTime @ 0x004D6E30</c>. Portal string table 0x23000001,
|
||||
/// string 0x0DCFFF73 supplies the two labels <c>FPS:</c> and <c>DEG:</c>;
|
||||
/// retail inserts both floating-point values with two decimal places.
|
||||
/// </remarks>
|
||||
public sealed class RetailFpsController
|
||||
{
|
||||
public const uint LayoutId = 0x2100000Fu;
|
||||
public const uint DisplayElementId = 0x10000047u;
|
||||
|
||||
private readonly UiText _display;
|
||||
private readonly Func<double> _framesPerSecond;
|
||||
private readonly Func<double> _degradeMultiplier;
|
||||
private readonly Func<bool> _isVisible;
|
||||
|
||||
private RetailFpsController(
|
||||
UiText display,
|
||||
Func<double> framesPerSecond,
|
||||
Func<double> degradeMultiplier,
|
||||
Func<bool> 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<double> framesPerSecond,
|
||||
Func<double> degradeMultiplier,
|
||||
Func<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the retail notice-driven show/hide state each UI tick.</summary>
|
||||
public void Tick() => _display.Visible = _isVisible();
|
||||
|
||||
private IReadOnlyList<UiText.Line> 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,11 @@ public sealed record CombatRuntimeBindings(
|
|||
|
||||
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
|
||||
|
||||
public sealed record FpsRuntimeBindings(
|
||||
Func<double> FramesPerSecond,
|
||||
Func<double> DegradeMultiplier,
|
||||
Func<bool> IsVisible);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<IReadOnlyList<ShortcutEntry>> 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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -25,19 +25,17 @@ public sealed record DisplaySettings(
|
|||
QualityPreset Quality)
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
/// ShowFps matches retail's initially-hidden SmartBox FPS readout.</summary>
|
||||
public static DisplaySettings Default { get; } = new(
|
||||
Resolution: "1280x720",
|
||||
Fullscreen: false,
|
||||
VSync: false,
|
||||
FieldOfView: 60f,
|
||||
Gamma: 1.0f,
|
||||
ShowFps: true,
|
||||
ShowFps: false,
|
||||
Quality: QualityPreset.High);
|
||||
|
||||
/// <summary>16:9 resolution presets offered in the dropdown.</summary>
|
||||
|
|
|
|||
|
|
@ -151,6 +151,20 @@ public sealed class SettingsVM
|
|||
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void ApplyExternalDisplayChange(Func<DisplaySettings, DisplaySettings> update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
_displayPersisted = update(_displayPersisted)
|
||||
?? throw new InvalidOperationException("Display update returned null.");
|
||||
_displayDraft = update(_displayDraft)
|
||||
?? throw new InvalidOperationException("Display update returned null.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Audio draft with <paramref name="value"/>.
|
||||
/// Live audio preview is achieved at the host layer by pushing
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<uint, UiElement>());
|
||||
|
||||
Assert.Null(RetailFpsController.Bind(layout, () => 60, () => 1, () => true));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue