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:
Erik 2026-07-13 15:50:47 +02:00
parent 9ea579bdd0
commit 43f7c7807c
17 changed files with 729 additions and 39 deletions

View file

@ -48,7 +48,7 @@ Copy this block when adding a new issue:
## #213 — Retail client commands were sent to ACE as chat text
**Status:** IN-PROGRESS — 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.
---

View file

@ -501,7 +501,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
- **Retail client command families implemented 2026-07-13; live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets, and confirmation requests mount imported LayoutDesc `0x2100003C`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`.
- **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.

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": []
}

View file

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

View file

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

View file

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