fix(ui): restore retail vitals and window interactions
This commit is contained in:
parent
4d84456c21
commit
1bd2b30291
36 changed files with 1462 additions and 86 deletions
File diff suppressed because one or more lines are too long
|
|
@ -129,9 +129,9 @@ implementer per slice against a pinned contract (per
|
|||
dissolved with the `RetailWindowChrome.Imported` mount (0x2100006F's
|
||||
own border art IS the window chrome — no nine-slice wrapper, no
|
||||
content crop, frame==content) using the DAT's real
|
||||
minH=100/maxH=2000/minW=300/maxW=2000. Register row AP-185 files the
|
||||
one accepted simplification (the `_Locked` cosmetic border-art swap on
|
||||
`UiLocked` is not ported; the live grip skin shows unconditionally).
|
||||
minH=100/maxH=2000/minW=300/maxW=2000. The originally deferred AP-185
|
||||
`_Locked` cosmetic border-art swap was subsequently ported through the
|
||||
shared registered-window lock presenter on 2026-08-20.
|
||||
- **CH6b — floating windows 1–4.** Mount `0x2100005B` ×4 as
|
||||
always-resident children per `gmGamePlayUI::SetupChildren
|
||||
@0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510);
|
||||
|
|
|
|||
|
|
@ -509,10 +509,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
|
||||
// bit at mount time — see CharacterOptionCombatSettingsSource's
|
||||
// doc comment. Live toggling afterward still flows through
|
||||
// RuntimeSettingsController.SetUiLocked's existing
|
||||
// _runtimeTargets?.ApplyUiLock push (ToggleUiLock's Bindings
|
||||
// site, LiveSessionRuntimeFactory.cs, now also sends the wire
|
||||
// bit — see its own comment).
|
||||
// RuntimeSettingsController.RequestUiLocked's authoritative
|
||||
// option-command then immediate ApplyUiLock push. Server reseeds
|
||||
// use SetUiLocked directly so they never echo the bit to the wire.
|
||||
host.Root.UiLocked = d.Character.Options.GetOptionBit(
|
||||
CharacterOptionId.LockUI);
|
||||
|
||||
|
|
@ -695,7 +694,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
Radar: new RadarRuntimeBindings(
|
||||
late.Radar.Snapshot,
|
||||
d.Actions.Selection,
|
||||
d.Settings.SetUiLocked),
|
||||
d.Settings.RequestUiLocked),
|
||||
Combat: new CombatRuntimeBindings(
|
||||
d.Actions.Combat,
|
||||
combatAttack),
|
||||
|
|
|
|||
|
|
@ -712,7 +712,8 @@ public sealed class GameWindow :
|
|||
_runtimeSettings = new RuntimeSettingsController(
|
||||
new JsonRuntimeSettingsStorage(
|
||||
_applicationPaths.SettingsFile),
|
||||
log: Console.WriteLine);
|
||||
log: Console.WriteLine,
|
||||
characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit);
|
||||
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
|
||||
_uiRegistry = uiRegistry;
|
||||
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ internal sealed class RuntimeSettingsController :
|
|||
|
||||
private readonly IRuntimeSettingsStorage _storage;
|
||||
private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
|
||||
private readonly Func<uint, bool>? _characterOptionValue;
|
||||
private readonly Action<string> _log;
|
||||
private IRuntimeSettingsTargets? _runtimeTargets;
|
||||
private CharacterSettings _defaultCharacter;
|
||||
|
|
@ -156,11 +157,13 @@ internal sealed class RuntimeSettingsController :
|
|||
public RuntimeSettingsController(
|
||||
IRuntimeSettingsStorage storage,
|
||||
Func<QualityPreset, QualitySettings>? resolveQuality = null,
|
||||
Action<string>? log = null)
|
||||
Action<string>? log = null,
|
||||
Func<uint, bool>? characterOptionValue = null)
|
||||
{
|
||||
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||
_resolveQuality = resolveQuality ?? ResolveQuality;
|
||||
_log = log ?? Console.WriteLine;
|
||||
_characterOptionValue = characterOptionValue;
|
||||
|
||||
Display = _storage.LoadDisplay();
|
||||
Audio = _storage.LoadAudio();
|
||||
|
|
@ -293,6 +296,34 @@ internal sealed class RuntimeSettingsController :
|
|||
_lastAppliedUiLocked = locked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a user-originated request to change retail's LockUI character
|
||||
/// option. The authoritative Runtime option write/send is published first;
|
||||
/// the retained-root presentation follows immediately, matching retail's
|
||||
/// PlayerModule write before global UI message <c>0x0D</c> broadcast.
|
||||
/// Server-seed convergence must continue to call <see cref="SetUiLocked"/>
|
||||
/// directly so receiving an authoritative bit never echoes it to the wire.
|
||||
/// </summary>
|
||||
public void RequestUiLocked(bool locked)
|
||||
{
|
||||
IRuntimeSettingsTargets? targets = _runtimeTargets;
|
||||
if (targets is null || _characterOptionValue is null)
|
||||
return;
|
||||
|
||||
targets.SetSingleCharacterOption(
|
||||
(uint)CharacterOptionId.LockUI,
|
||||
locked);
|
||||
|
||||
// LiveSessionCommandRouter applies RuntimeCharacterOptionsState's
|
||||
// local write synchronously before the autosave send. If the route is
|
||||
// inactive/displaced, Publish is intentionally dropped and the bit
|
||||
// remains unchanged; do not split root presentation from authority.
|
||||
if (_characterOptionValue((uint)CharacterOptionId.LockUI) != locked)
|
||||
return;
|
||||
|
||||
SetUiLocked(locked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>
|
||||
/// flips the live flag and sends the framerate-display UI notice. acdream
|
||||
|
|
|
|||
|
|
@ -132,9 +132,12 @@ public sealed class CursorFeedbackController
|
|||
// found flag driving the Default/Combat/Use/Examine/Busy Found
|
||||
// variants too.
|
||||
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
|
||||
uint hoverTarget = hover is null
|
||||
? _worldTargetProvider?.Invoke() ?? 0u
|
||||
: FindHoveredItemSlot(hover)?.ItemId ?? 0u;
|
||||
UiItemSlot? hoveredItem = FindHoveredItemSlot(hover);
|
||||
uint hoverTarget = hoveredItem is not null
|
||||
? hoveredItem.ItemId
|
||||
: FindRepresentedObject(hover) is { } represented
|
||||
? represented
|
||||
: _worldTargetProvider?.Invoke() ?? 0u;
|
||||
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
|
||||
&& hoverTarget != 0
|
||||
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)
|
||||
|
|
@ -377,4 +380,19 @@ public sealed class CursorFeedbackController
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static uint? FindRepresentedObject(UiElement? element)
|
||||
{
|
||||
while (element is not null)
|
||||
{
|
||||
if (element.FoundObjectGuidProvider is { } provider)
|
||||
{
|
||||
uint guid = provider();
|
||||
if (guid != 0u)
|
||||
return guid;
|
||||
}
|
||||
element = element.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,14 +73,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
private const uint Indicator3Id = 0x10000524u;
|
||||
private const uint Indicator4Id = 0x10000525u;
|
||||
|
||||
// The 8 cosmetic "_Locked" border-art twins
|
||||
// (gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these in for the 8
|
||||
// live Resizebar/Dragbar grips above when PlayerModule::LockUI is true — see
|
||||
// research doc §1.6). CH6a does not implement the lock-state art swap (register
|
||||
// row AP-185 — UiRoot.UiLocked already gates INTERACTION generically, independent
|
||||
// of which art is shown); default to the unlocked visual (hide these, show the
|
||||
// live grips) to match UiRoot's own UiLocked=false default and avoid double
|
||||
// rendering two overlapping border-art layers.
|
||||
// The 8 cosmetic "_Locked" border-art twins. Retail's
|
||||
// gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these with the 8
|
||||
// live Resizebar/Dragbar grips. Bind seeds the ordinary unlocked visual for
|
||||
// standalone/unregistered layouts; RetailWindowLockPresentationController
|
||||
// takes authoritative ownership as soon as the window registers.
|
||||
private static readonly uint[] LockedTwinIds =
|
||||
{
|
||||
0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u,
|
||||
|
|
@ -259,10 +256,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
_windowFilters = windowFilters,
|
||||
};
|
||||
|
||||
// The 8 cosmetic "_Locked" border-art twins default HIDDEN — CH6a does not
|
||||
// implement retail's UiLocked-driven art swap (see the class doc + the
|
||||
// LockedTwinIds field comment); the 8 live grip/dragbar elements (which
|
||||
// occupy the SAME rects one ReadOrder layer above) are the ones shown.
|
||||
// Seed the unlocked skin until the common registered-window presenter
|
||||
// applies the canonical UiLocked state. The live and locked sets occupy
|
||||
// identical rects, so only one set may be visible at a time.
|
||||
foreach (uint id in LockedTwinIds)
|
||||
if (layout.FindElement(id) is { } twin)
|
||||
twin.Visible = false;
|
||||
|
|
|
|||
|
|
@ -571,8 +571,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_selection.Select(payload.ObjId, SelectionChangeSource.Inventory);
|
||||
}
|
||||
|
||||
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid
|
||||
/// drops accept; a side-bag/main-pack drop rejects only when that container is known full.</summary>
|
||||
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral. Retail keeps
|
||||
/// loose-item slots and contained-container slots as different drag classes: a pack is rejected
|
||||
/// by the contents grid and accepted by the player's pack-slot list, while an ordinary item is
|
||||
/// accepted by the contents grid or by a non-full occupied pack.</summary>
|
||||
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// UIElement_ItemList::ItemList_DragOver @ 0x004E3400 only evaluates
|
||||
|
|
@ -583,10 +585,22 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
return ItemDragAcceptance.None;
|
||||
if (payload.ObjId == 0)
|
||||
return ItemDragAcceptance.Reject;
|
||||
bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source);
|
||||
if (targetList == _contentsGrid)
|
||||
return ItemDragAcceptance.Accept;
|
||||
return sourceIsBag
|
||||
? ItemDragAcceptance.Reject
|
||||
: ItemDragAcceptance.Accept;
|
||||
if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
// UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the
|
||||
// dragged object's container flag before interpreting this list.
|
||||
// A container drag addresses the player's contained-container
|
||||
// list itself; an empty authored slot is therefore a valid pack
|
||||
// destination rather than "no target".
|
||||
if (sourceIsBag)
|
||||
return targetCell.ItemId == payload.ObjId
|
||||
? ItemDragAcceptance.Reject
|
||||
: ItemDragAcceptance.Accept;
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
|
||||
return ItemDragAcceptance.Reject;
|
||||
return IsContainerFull(targetCell.ItemId)
|
||||
|
|
@ -610,6 +624,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint item = payload.ObjId;
|
||||
if (item == 0) return;
|
||||
|
||||
// DropReleased is still delivered to the list after a reject overlay;
|
||||
// pin the release to the same retail policy instead of relying on the
|
||||
// advisory color alone.
|
||||
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
|
||||
return;
|
||||
|
||||
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
|
||||
// release while m_pendingItem exists, before merge, split, or ordinary
|
||||
// placement. ItemList_DragOver has no equivalent gate, so hover may
|
||||
|
|
@ -635,6 +655,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
&& TryMergeStacks(item, targetCell.ItemId))
|
||||
return;
|
||||
|
||||
bool sourceIsBag = _objects.Get(item) is { } dragged && IsBag(dragged);
|
||||
uint container; int placement;
|
||||
if (targetList == _contentsGrid)
|
||||
{
|
||||
|
|
@ -645,10 +666,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
else if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
|
||||
container = targetCell.ItemId; // the bag / main pack
|
||||
if (IsContainerFull(container)) return; // red already shown
|
||||
placement = _objects.GetContents(container).Count; // append into it
|
||||
if (sourceIsBag)
|
||||
{
|
||||
// A pack dropped on the pack selector is inserted into the
|
||||
// player's contained-container list at that selector slot.
|
||||
// Sending the open item container here makes ACE correctly
|
||||
// reject the pickup with "can't be picked up".
|
||||
container = _playerGuid();
|
||||
if (container == 0u) return;
|
||||
placement = Math.Max(0, targetCell.SlotIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
|
||||
container = targetCell.ItemId; // the bag / main pack
|
||||
if (IsContainerFull(container)) return; // red already shown
|
||||
placement = _objects.GetContents(container).Count; // append into it
|
||||
}
|
||||
}
|
||||
else return;
|
||||
|
||||
|
|
|
|||
|
|
@ -612,11 +612,15 @@ public static class LayoutImporter
|
|||
bool imageRead = false;
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
if (!imageRead && m is MediaDescImage img && img.File != 0)
|
||||
if (m is MediaDescImage img)
|
||||
{
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
|
||||
imageRead = true;
|
||||
state.ImageMediaCount++;
|
||||
if (!imageRead && img.File != 0)
|
||||
{
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
|
||||
imageRead = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (m is MediaDescCursor cursor && cursor.File != 0)
|
||||
|
|
|
|||
|
|
@ -70,8 +70,15 @@ public sealed class RadarController : IRetainedPanelController
|
|||
_lockButton = layout.FindElement(LockButtonId) as UiButton;
|
||||
_dragButton = layout.FindElement(DragButtonId);
|
||||
|
||||
if (_lockButton is not null && _setUiLocked is not null)
|
||||
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
|
||||
if (_lockButton is not null)
|
||||
{
|
||||
// LockedUI/UnlockedUI are persistent semantic faces. The button's
|
||||
// authored, media-less Normal_pressed state must not replace them
|
||||
// during pointer traffic after the global lock transition.
|
||||
_lockButton.ControllerOwnsVisualState = true;
|
||||
if (_setUiLocked is not null)
|
||||
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
|
||||
}
|
||||
|
||||
_tokens =
|
||||
[
|
||||
|
|
|
|||
|
|
@ -178,6 +178,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
|
||||
if (_inventoryButton is not null)
|
||||
{
|
||||
// Retail's inventory button is also the player/self backpack
|
||||
// target: target-mode clicks call OfferSelfPrimaryClick and drops
|
||||
// resolve to the player's inventory. Expose that same represented
|
||||
// object to the global cursor path so ordinary hover uses the
|
||||
// DefaultFound cursor instead of the unlit Default cursor.
|
||||
_inventoryButton.FoundObjectGuidProvider = () =>
|
||||
_playerGuid?.Invoke() ?? _itemInteraction?.PlayerGuid ?? 0u;
|
||||
// gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the
|
||||
// authored child overlay's Accept state (0x10000046 = 0x060011F7).
|
||||
// UiButton owns the retained drop-target seam; the panel owns policy.
|
||||
|
|
|
|||
|
|
@ -138,6 +138,15 @@ public sealed class UiStateInfo
|
|||
/// </summary>
|
||||
public int MediaCount;
|
||||
|
||||
/// <summary>
|
||||
/// Number of image media entries in the effective retail state, including
|
||||
/// an image whose file id is invalid/zero and therefore means
|
||||
/// draw-nothing. This is deliberately separate from <see cref="MediaCount"/>:
|
||||
/// a cursor-, sound-, or message-only state resets retail's media machine
|
||||
/// but does not replace the image already installed on the element.
|
||||
/// </summary>
|
||||
public int ImageMediaCount;
|
||||
|
||||
public UiStateInfo Clone()
|
||||
=> new()
|
||||
{
|
||||
|
|
@ -149,6 +158,7 @@ public sealed class UiStateInfo
|
|||
Cursor = Cursor,
|
||||
Properties = Properties.Clone(),
|
||||
MediaCount = MediaCount,
|
||||
ImageMediaCount = ImageMediaCount,
|
||||
};
|
||||
|
||||
public static UiStateInfo Merge(UiStateInfo baseState, UiStateInfo derivedState)
|
||||
|
|
@ -161,11 +171,9 @@ public sealed class UiStateInfo
|
|||
Image = derivedState.Image ?? baseState.Image,
|
||||
Cursor = derivedState.Cursor ?? baseState.Cursor,
|
||||
Properties = UiPropertyBag.Merge(baseState.Properties, derivedState.Properties),
|
||||
// Media arrays do not merge entry-wise in retail (a derived
|
||||
// StateDesc replaces the base one); the derived count wins when
|
||||
// the derived state authors ANY media, else the base's carries.
|
||||
MediaCount = derivedState.MediaCount != 0
|
||||
? derivedState.MediaCount
|
||||
: baseState.MediaCount,
|
||||
// StateDesc::Incorporate @0x0069CCA0 calls ConcatenateMedia;
|
||||
// inherited media arrays append rather than replace one another.
|
||||
MediaCount = baseState.MediaCount + derivedState.MediaCount,
|
||||
ImageMediaCount = baseState.ImageMediaCount + derivedState.ImageMediaCount,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -529,6 +529,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
// ChatInterface base-constructor values, 0x004F4550 — when no store is
|
||||
// wired or nothing has been saved yet).
|
||||
ChatSettings chatSettings = bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default;
|
||||
WindowLockPresentation = new RetailWindowLockPresentationController(
|
||||
bindings.Host.Root.WindowManager);
|
||||
WindowOpacity = new RetailWindowOpacityController(
|
||||
bindings.Host.Root.WindowManager,
|
||||
chatSettings.DefaultOpacity,
|
||||
|
|
@ -643,6 +645,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
/// </summary>
|
||||
public RetailWindowOpacityController WindowOpacity { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Global retail UI-lock chrome presenter. It is attached before the first
|
||||
/// window mount so late registration observes the current lock state before
|
||||
/// the window's first rendered frame.
|
||||
/// </summary>
|
||||
public RetailWindowLockPresentationController WindowLockPresentation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared dat/sprite/font resolvers this runtime was built with.
|
||||
/// Campaign CH user-gate round 3: lets a controller built OUTSIDE this
|
||||
|
|
@ -903,7 +912,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
else if (_screenSizeSettling)
|
||||
{
|
||||
_screenSizeSettling = false;
|
||||
_persistence?.RestoreAll(saveBack: false);
|
||||
_persistence?.RestoreAfterDisplayChange();
|
||||
}
|
||||
|
||||
Host.Draw(screenSize);
|
||||
|
|
@ -4542,6 +4551,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
_characterSheetSubscription?.Dispose();
|
||||
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
||||
WindowLockPresentation.Dispose();
|
||||
WindowOpacity.Dispose();
|
||||
if (SecureTradeController is { } trade)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,6 +48,27 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
|||
/// mid-drag reload would litter settings.json with intermediate-resolution
|
||||
/// keys.</summary>
|
||||
public void RestoreAll(bool saveBack = true)
|
||||
=> RestoreAllCore(
|
||||
saveBack,
|
||||
restoreVisibility: true);
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the current resolution's saved geometry after a live display
|
||||
/// change. Retail's <c>UI-<char>-<world>-<W>-<H>.txt</c>
|
||||
/// records only X/Y/W/H; global message <c>0xE</c> therefore cannot hide
|
||||
/// a window. Keeping live visibility is especially load-bearing for the Options window:
|
||||
/// hiding Config while its Resolution row is still uncommitted invokes
|
||||
/// <c>PlayerOptionPage::OnVisibilityChanged(false)</c> and restores the
|
||||
/// previous resolution, producing a resize-out/resize-back blip.
|
||||
/// </summary>
|
||||
public void RestoreAfterDisplayChange()
|
||||
=> RestoreAllCore(
|
||||
saveBack: false,
|
||||
restoreVisibility: false);
|
||||
|
||||
private void RestoreAllCore(
|
||||
bool saveBack,
|
||||
bool restoreVisibility)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
string character = _characterKey();
|
||||
|
|
@ -72,7 +93,9 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
|||
handle,
|
||||
layout,
|
||||
screen,
|
||||
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
|
||||
restoreVisibility:
|
||||
restoreVisibility
|
||||
&& !_stateManagedVisibilityWindows.Contains(handle.Name));
|
||||
// Lazily migrate legacy position-only entries into the complete schema.
|
||||
if (saveBack)
|
||||
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
|
||||
|
|
|
|||
139
src/AcDream.App/UI/RetailWindowLockPresentationController.cs
Normal file
139
src/AcDream.App/UI/RetailWindowLockPresentationController.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Applies retail's global UI-lock presentation to every registered retained
|
||||
/// window. Interaction remains owned by <see cref="UiRoot.UiLocked"/>; this
|
||||
/// controller owns only the corresponding chrome swap.
|
||||
/// </summary>
|
||||
public sealed class RetailWindowLockPresentationController : IDisposable
|
||||
{
|
||||
private static readonly (uint LockedStart, uint LiveStart)[] AuthoredChromeBlocks =
|
||||
[
|
||||
(0x10000633u, 0x1000063Bu),
|
||||
(0x10000643u, 0x1000064Bu),
|
||||
(0x10000653u, 0x1000065Bu),
|
||||
(0x10000663u, 0x1000066Bu),
|
||||
(0x10000673u, 0x1000067Bu),
|
||||
(0x10000683u, 0x1000068Bu),
|
||||
(0x10000693u, 0x1000069Bu),
|
||||
(0x100006A5u, 0x100006ADu),
|
||||
];
|
||||
|
||||
private const uint SmartBoxLiveChromeStart = 0x100006CAu;
|
||||
|
||||
private readonly RetailWindowManager _manager;
|
||||
private readonly Dictionary<RetailWindowHandle, WindowPresentation> _windows = new();
|
||||
private bool _disposed;
|
||||
|
||||
public RetailWindowLockPresentationController(RetailWindowManager manager)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
_manager.WindowRegistering += OnWindowRegistered;
|
||||
_manager.WindowUnregistered += OnWindowUnregistered;
|
||||
|
||||
foreach (RetailWindowHandle handle in _manager.Windows)
|
||||
Attach(handle);
|
||||
}
|
||||
|
||||
private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle);
|
||||
|
||||
private void OnWindowUnregistered(RetailWindowHandle handle) => Detach(handle);
|
||||
|
||||
private void Attach(RetailWindowHandle handle)
|
||||
{
|
||||
if (_windows.ContainsKey(handle))
|
||||
return;
|
||||
|
||||
var presentation = WindowPresentation.Capture(handle.OuterFrame);
|
||||
_windows.Add(handle, presentation);
|
||||
handle.LockChanged += OnLockChanged;
|
||||
presentation.Apply(_manager.IsLocked);
|
||||
}
|
||||
|
||||
private void Detach(RetailWindowHandle handle)
|
||||
{
|
||||
handle.LockChanged -= OnLockChanged;
|
||||
_windows.Remove(handle);
|
||||
}
|
||||
|
||||
private void OnLockChanged(RetailWindowHandle handle, bool locked)
|
||||
{
|
||||
if (_windows.TryGetValue(handle, out WindowPresentation? presentation))
|
||||
presentation.Apply(locked);
|
||||
}
|
||||
|
||||
private static bool IsAuthoredLockedChrome(uint id)
|
||||
{
|
||||
foreach ((uint start, _) in AuthoredChromeBlocks)
|
||||
if (id >= start && id < start + 8u)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsAuthoredLiveChrome(uint id)
|
||||
{
|
||||
foreach ((_, uint start) in AuthoredChromeBlocks)
|
||||
if (id >= start && id < start + 8u)
|
||||
return true;
|
||||
return id >= SmartBoxLiveChromeStart && id < SmartBoxLiveChromeStart + 8u;
|
||||
}
|
||||
|
||||
private sealed class WindowPresentation
|
||||
{
|
||||
private readonly List<UiElement> _authoredLockedChrome = new();
|
||||
private readonly List<(UiElement Element, bool VisibleWhenUnlocked)> _liveChrome = new();
|
||||
private readonly List<(UiNineSlicePanel Panel, bool VisibleWhenUnlocked)> _nineSlices = new();
|
||||
|
||||
public static WindowPresentation Capture(UiElement outerFrame)
|
||||
{
|
||||
var presentation = new WindowPresentation();
|
||||
presentation.CaptureElement(outerFrame);
|
||||
return presentation;
|
||||
}
|
||||
|
||||
private void CaptureElement(UiElement element)
|
||||
{
|
||||
if (element is UiNineSlicePanel nineSlice)
|
||||
_nineSlices.Add((nineSlice, nineSlice.DrawResizeAffordances));
|
||||
|
||||
if (IsAuthoredLockedChrome(element.DatElementId))
|
||||
{
|
||||
_authoredLockedChrome.Add(element);
|
||||
}
|
||||
else if (IsAuthoredLiveChrome(element.DatElementId) || element is UiResizeGrip)
|
||||
{
|
||||
_liveChrome.Add((element, element.Visible));
|
||||
}
|
||||
|
||||
foreach (UiElement child in element.Children)
|
||||
CaptureElement(child);
|
||||
}
|
||||
|
||||
public void Apply(bool locked)
|
||||
{
|
||||
foreach (UiElement element in _authoredLockedChrome)
|
||||
element.Visible = locked;
|
||||
|
||||
foreach ((UiElement element, bool visibleWhenUnlocked) in _liveChrome)
|
||||
element.Visible = !locked && visibleWhenUnlocked;
|
||||
|
||||
foreach ((UiNineSlicePanel panel, bool visibleWhenUnlocked) in _nineSlices)
|
||||
panel.DrawResizeAffordances = !locked && visibleWhenUnlocked;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
|
||||
_manager.WindowRegistering -= OnWindowRegistered;
|
||||
_manager.WindowUnregistered -= OnWindowUnregistered;
|
||||
foreach (RetailWindowHandle handle in new List<RetailWindowHandle>(_windows.Keys))
|
||||
Detach(handle);
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,12 @@ public sealed class RetailWindowManager : IDisposable
|
|||
/// </summary>
|
||||
public event Action<RetailWindowHandle>? WindowRegistered;
|
||||
|
||||
/// <summary>
|
||||
/// Internal pre-exposure hook for presentation state that must be correct
|
||||
/// before a newly mounted window's controller receives <c>OnShown</c>.
|
||||
/// </summary>
|
||||
internal event Action<RetailWindowHandle>? WindowRegistering;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when a window is REMOVED from the registry (<see cref="Unregister"/>),
|
||||
/// after teardown (<c>NotifyClosed</c>/<c>DisposeController</c>) but before the
|
||||
|
|
@ -99,6 +105,7 @@ public sealed class RetailWindowManager : IDisposable
|
|||
authoredGeometryRevision);
|
||||
_byName.Add(name, handle);
|
||||
_byFrame.Add(outerFrame, handle);
|
||||
WindowRegistering?.Invoke(handle);
|
||||
handle.NotifyInitialState();
|
||||
WindowRegistered?.Invoke(handle);
|
||||
return handle;
|
||||
|
|
@ -258,6 +265,7 @@ public sealed class RetailWindowManager : IDisposable
|
|||
_defaultInputs.Remove(handle);
|
||||
handle.NotifyClosed();
|
||||
handle.DisposeController();
|
||||
WindowUnregistered?.Invoke(handle);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -308,6 +308,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// </summary>
|
||||
public bool SuppressSelfToggle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gives the owning controller exclusive responsibility for the rendered
|
||||
/// state. Automatic pointer, selection, and enabled-state transitions no
|
||||
/// longer replace <see cref="ActiveState"/>; input and click delivery remain
|
||||
/// unchanged. This is used by buttons such as the radar UI lock whose face
|
||||
/// represents durable application state rather than momentary interaction.
|
||||
/// </summary>
|
||||
public bool ControllerOwnsVisualState { get; set; }
|
||||
|
||||
public bool Selected
|
||||
{
|
||||
get => _selected;
|
||||
|
|
@ -509,14 +518,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
|
||||
/// <summary>
|
||||
/// Retail's SetState media rule (<c>UIElement::SetState @0x00464E70</c>
|
||||
/// tail, the <c>m_media.m_num != 0</c> gate @0x004651c0): a committed
|
||||
/// state replaces the playing media ONLY when its media array is
|
||||
/// non-empty — an authored empty-media state keeps the PREVIOUS media
|
||||
/// (why an empty <c>Normal_pressed</c> never blanks a Normal-art
|
||||
/// button), while an authored File=0 draw-nothing image counts as media
|
||||
/// and clears the face (#416: the roster-row bar children's base
|
||||
/// state). An UNAUTHORED committed state runs retail's state-0 arm
|
||||
/// against the base media array. Face segments model retail's
|
||||
/// tail plus <c>MediaMachine::Update_Image @0x00465870</c>): a committed
|
||||
/// state replaces the playing IMAGE only when its effective media array
|
||||
/// contains image media. An empty state—or a cursor/sound/message-only
|
||||
/// state—keeps the PREVIOUS image (why the toolbar inventory button's
|
||||
/// inherited non-image <c>Normal_pressed</c> media never blanks its closed
|
||||
/// backpack art), while an authored File=0 draw-nothing image clears the
|
||||
/// face (#416: the roster-row bar children's base state). An UNAUTHORED
|
||||
/// committed state runs retail's state-0 arm against the base media array.
|
||||
/// Face segments model retail's
|
||||
/// PassToChildren children, so each segment resolves the rule against
|
||||
/// its OWN authored states. Synced lazily on the first draw after any
|
||||
/// <see cref="ActiveState"/> write so every commit path (the visual
|
||||
|
|
@ -553,7 +563,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
string current)
|
||||
{
|
||||
if (info.States.TryGetValue(committedId, out UiStateInfo? state))
|
||||
return state.MediaCount != 0 ? committedName : current;
|
||||
return HasImageMedia(info, state, committedName) ? committedName : current;
|
||||
// Synthetic/test infos may carry StateMedia without States entries;
|
||||
// a drawable entry for the committed name counts as authored media.
|
||||
if (info.StateMedia.ContainsKey(committedName))
|
||||
|
|
@ -562,10 +572,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
// otherwise the previous media keeps playing.
|
||||
if (info.States.TryGetValue(
|
||||
UiStateInfo.DirectStateId, out UiStateInfo? baseState))
|
||||
return baseState.MediaCount != 0 ? "" : current;
|
||||
return HasImageMedia(info, baseState, "") ? "" : current;
|
||||
return info.StateMedia.ContainsKey("") ? "" : current;
|
||||
}
|
||||
|
||||
private static bool HasImageMedia(
|
||||
ElementInfo info,
|
||||
UiStateInfo state,
|
||||
string stateName)
|
||||
// StateMedia keeps committed pre-ImageMediaCount fixtures and compact
|
||||
// synthetic tests faithful for ordinary non-zero image entries.
|
||||
=> state.ImageMediaCount != 0 || info.StateMedia.ContainsKey(stateName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the File id the media rule selected for this face; 0 draws
|
||||
/// nothing (an authored File=0 image reaches this as a media-state whose
|
||||
|
|
@ -1037,6 +1055,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
|
||||
private void UpdateVisualState()
|
||||
{
|
||||
if (ControllerOwnsVisualState)
|
||||
return;
|
||||
|
||||
uint requested = ComputeRequestedStateId();
|
||||
if (_hasCustomSelectionPair)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -121,6 +121,16 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
public uint SourceLayoutDid { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional game object represented by this retained widget for retail's
|
||||
/// global found-object cursor. Most widgets leave this unset. The toolbar
|
||||
/// inventory/backpack button supplies the player object: retail treats that
|
||||
/// button as the self/backpack target (the same target used by its click and
|
||||
/// drop paths), so hovering it selects the Found cursor family even though
|
||||
/// the authored element is a Button rather than a UIItem.
|
||||
/// </summary>
|
||||
public Func<uint>? FoundObjectGuidProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat
|
||||
/// property <c>0x4A</c>). Meaningful only when read off a tooltip
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ public class UiNineSlicePanel : UiPanel
|
|||
/// </summary>
|
||||
public bool DrawCenterFill { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to paint the live gold resize edges and corner studs over the
|
||||
/// ordinary retail bevel. UI lock disables only this affordance layer;
|
||||
/// the frame and optional center fill remain visible.
|
||||
/// </summary>
|
||||
public bool DrawResizeAffordances { get; set; } = true;
|
||||
|
||||
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
_resolve = resolve;
|
||||
|
|
@ -90,6 +97,9 @@ public class UiNineSlicePanel : UiPanel
|
|||
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR);
|
||||
|
||||
if (!DrawResizeAffordances)
|
||||
return;
|
||||
|
||||
// Resize-grip overlay (gold ridged edges + square corner studs) on top of the
|
||||
// bevel — the second border layer the vitals LayoutDesc carries (0x1000063B–0x10000642).
|
||||
DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Player;
|
||||
|
|
@ -305,7 +306,7 @@ public sealed class LocalPlayerState
|
|||
/// </summary>
|
||||
public uint? GetMaxApprox(VitalKind kind)
|
||||
{
|
||||
uint? baseValue = GetBaseMaxApprox(kind);
|
||||
uint? baseValue = GetMaxBeforeSecondaryEnchantments(kind);
|
||||
if (baseValue is not uint unbuffed) return null;
|
||||
// Preserve the "no data" sentinel — when the unbuffed max is 0
|
||||
// we lack the inputs to compute anything reasonable. The retail
|
||||
|
|
@ -315,12 +316,13 @@ public sealed class LocalPlayerState
|
|||
var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind))
|
||||
?? EnchantmentMath.VitalMod.Identity;
|
||||
// Apply: (unbuffed * mult) + additive, then clamp to retail's
|
||||
// min-vital floor (5 if base >= 5 else 1) — matches
|
||||
// CreatureVital::GetMaxValue at PDB 0x0058F2DD.
|
||||
// min-vital floor (5 if base >= 5 else 1). The final cast is
|
||||
// retail CEnchantmentRegistry::EnchantAttribute2nd's _ftol2
|
||||
// conversion at 0x00594787: truncate toward zero, do not round.
|
||||
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
|
||||
uint minFloor = unbuffed >= 5 ? 5u : 1u;
|
||||
if (buffed < minFloor) buffed = minFloor;
|
||||
return (uint)System.Math.Round(buffed);
|
||||
return (uint)buffed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -333,7 +335,27 @@ public sealed class LocalPlayerState
|
|||
if (vital is null) return null;
|
||||
return vital.Value.Ranks
|
||||
+ vital.Value.Start
|
||||
+ AttributeContribution(kind);
|
||||
+ AttributeContribution(kind, effective: false)
|
||||
+ GearHealthBonus(kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CACQualities::InqAttribute2nd @ 0x00592020</c> computes a
|
||||
/// maximum vital's formula contribution through <c>InqAttribute</c> with
|
||||
/// enchantments enabled, adds <c>GearMaxHealth</c> (property 379) for
|
||||
/// health, and only then calls <c>EnchantAttribute2nd</c>. Keeping this
|
||||
/// stage separate prevents primary-attribute records from leaking into
|
||||
/// the secondary-attribute modifier while still allowing buffed
|
||||
/// Endurance/Self to feed the vital formula.
|
||||
/// </summary>
|
||||
private uint? GetMaxBeforeSecondaryEnchantments(VitalKind kind)
|
||||
{
|
||||
VitalSnapshot? vital = Get(kind);
|
||||
if (vital is null) return null;
|
||||
return vital.Value.Ranks
|
||||
+ vital.Value.Start
|
||||
+ AttributeContribution(kind, effective: true)
|
||||
+ GearHealthBonus(kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -622,21 +644,37 @@ public sealed class LocalPlayerState
|
|||
// coefficients, but the values themselves don't change between dat
|
||||
// versions in retail.
|
||||
|
||||
private uint AttributeContribution(VitalKind kind)
|
||||
private uint AttributeContribution(VitalKind kind, bool effective)
|
||||
{
|
||||
uint endurance = GetAttrCurrent(AttributeKind.Endurance, effective);
|
||||
uint self = GetAttrCurrent(AttributeKind.Self, effective);
|
||||
switch (kind)
|
||||
{
|
||||
case VitalKind.Health:
|
||||
return GetAttrCurrent(AttributeKind.Endurance) / 2u;
|
||||
// SecondaryAttributeTable's SkillFormula is
|
||||
// floor((Endurance / 2) + 0.5), not integer truncation.
|
||||
return (endurance / 2u) + (endurance & 1u);
|
||||
case VitalKind.Stamina:
|
||||
return GetAttrCurrent(AttributeKind.Endurance);
|
||||
return endurance;
|
||||
case VitalKind.Mana:
|
||||
return GetAttrCurrent(AttributeKind.Self);
|
||||
return self;
|
||||
default:
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
|
||||
private uint GetAttrCurrent(AttributeKind kind) =>
|
||||
_attrs.TryGetValue(kind, out var a) ? a.Current : 0u;
|
||||
private uint GetAttrCurrent(AttributeKind kind, bool effective)
|
||||
{
|
||||
if (!_attrs.TryGetValue(kind, out var attribute)) return 0u;
|
||||
if (!effective || _spellbook is null) return attribute.Current;
|
||||
int value = GetEffectiveAttribute(kind) ?? 0;
|
||||
return value > 0 ? (uint)value : 0u;
|
||||
}
|
||||
|
||||
private uint GearHealthBonus(VitalKind kind)
|
||||
{
|
||||
if (kind != VitalKind.Health) return 0u;
|
||||
int value = _properties.GetInt((uint)PropertyInt.GearMaxHealth);
|
||||
return value > 0 ? (uint)value : 0u;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,12 @@ public sealed class Spellbook
|
|||
return cached;
|
||||
}
|
||||
|
||||
EnchantmentMath.VitalMod calculated =
|
||||
EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey);
|
||||
EnchantmentMath.VitalMod calculated = EnchantmentMath.GetMod(
|
||||
ActiveEnchantments,
|
||||
_table,
|
||||
statKey,
|
||||
EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
|
||||
includeVitae: true);
|
||||
_vitalModCache.Add(statKey, calculated);
|
||||
return calculated;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.Composition;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Settings;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.Content;
|
||||
using AcDream.App.UI;
|
||||
|
|
@ -35,6 +36,25 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionRetainedUiCompositionPoint.InventoryContainerBound,
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void RadarLockBindingUsesAuthoritativeRequestInsteadOfPresentationOnlySetter()
|
||||
{
|
||||
MethodInfo compose = typeof(RetailInteractionRetainedUiCompositionFactory)
|
||||
.GetMethod(nameof(
|
||||
RetailInteractionRetainedUiCompositionFactory.CreateRetainedUi))!;
|
||||
IReadOnlyList<CompiledCall> references =
|
||||
CompiledCallGraph.ReadMethodReferences(compose);
|
||||
|
||||
Assert.Contains(
|
||||
references,
|
||||
call => call.Target.DeclaringType == typeof(RuntimeSettingsController)
|
||||
&& call.Target.Name == nameof(RuntimeSettingsController.RequestUiLocked));
|
||||
Assert.DoesNotContain(
|
||||
references,
|
||||
call => call.Target.DeclaringType == typeof(RuntimeSettingsController)
|
||||
&& call.Target.Name == nameof(RuntimeSettingsController.SetUiLocked));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledUiPublishesOneExactResultAfterFrozenConstructionOrder()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -628,6 +628,43 @@ public sealed class LiveSessionCommandRouterTests
|
|||
isOlthoiPlayer: false).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsRouteLockUi_UpdatesCanonicalBitSynchronouslyAndSendsAutosave()
|
||||
{
|
||||
var characterState = new RuntimeCharacterState();
|
||||
characterState.Options.SetOptionBit(
|
||||
(uint)CharacterOptionId.LockUI,
|
||||
false);
|
||||
var sent = new List<(uint OptionId, bool Value)>();
|
||||
LiveSessionCommandRouter router = NewRouter(
|
||||
characterState: characterState,
|
||||
sendSingleCharacterOption: (id, value) =>
|
||||
characterState.Options.TrySetOption(
|
||||
id,
|
||||
value,
|
||||
sendAutoSave: (sentId, sentValue) =>
|
||||
sent.Add((sentId, sentValue))));
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new SetSingleCharacterOptionRuntimeCmd(
|
||||
(uint)CharacterOptionId.LockUI,
|
||||
true));
|
||||
|
||||
Assert.True(characterState.Options.GetOptionBit(CharacterOptionId.LockUI));
|
||||
Assert.Equal([((uint)CharacterOptionId.LockUI, true)], sent);
|
||||
|
||||
router.Publish(new SetSingleCharacterOptionRuntimeCmd(
|
||||
(uint)CharacterOptionId.LockUI,
|
||||
false));
|
||||
|
||||
Assert.False(characterState.Options.GetOptionBit(CharacterOptionId.LockUI));
|
||||
Assert.Equal(
|
||||
[
|
||||
((uint)CharacterOptionId.LockUI, true),
|
||||
((uint)CharacterOptionId.LockUI, false),
|
||||
], sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowWeenieErrorFriendsFull_ResolvesThroughAddText_AndLandsInSpewBoxNotChat()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -941,6 +941,85 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.Equal(["target-quality"], events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUiLocked_PublishesAuthoritativeOptionBeforePresentation()
|
||||
{
|
||||
var events = new List<string>();
|
||||
bool authoritativeLock = false;
|
||||
var controller = new RuntimeSettingsController(
|
||||
new FakeStorage(),
|
||||
log: events.Add,
|
||||
characterOptionValue: optionId =>
|
||||
optionId == (uint)CharacterOptionId.LockUI && authoritativeLock);
|
||||
var targets = new FakeRuntimeTargets(events);
|
||||
targets.SingleOptionApplied = (optionId, value) =>
|
||||
{
|
||||
if (optionId == (uint)CharacterOptionId.LockUI)
|
||||
authoritativeLock = value;
|
||||
};
|
||||
controller.BindRuntimeTargets(targets);
|
||||
|
||||
controller.RequestUiLocked(true);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
$"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:True",
|
||||
"target-ui-lock:True",
|
||||
], events);
|
||||
Assert.Equal(
|
||||
[((uint)CharacterOptionId.LockUI, true)],
|
||||
targets.SingleOptionCalls);
|
||||
Assert.Equal(1, targets.UiLockCalls);
|
||||
|
||||
events.Clear();
|
||||
controller.RequestUiLocked(false);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
$"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:False",
|
||||
"target-ui-lock:False",
|
||||
], events);
|
||||
Assert.Equal(
|
||||
[
|
||||
((uint)CharacterOptionId.LockUI, true),
|
||||
((uint)CharacterOptionId.LockUI, false),
|
||||
], targets.SingleOptionCalls);
|
||||
Assert.Equal(2, targets.UiLockCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUiLocked_InactiveRouteDoesNotSplitPresentation_AndServerSeedDoesNotEcho()
|
||||
{
|
||||
var events = new List<string>();
|
||||
bool authoritativeLock = false;
|
||||
var controller = new RuntimeSettingsController(
|
||||
new FakeStorage(),
|
||||
log: events.Add,
|
||||
characterOptionValue: optionId =>
|
||||
optionId == (uint)CharacterOptionId.LockUI && authoritativeLock);
|
||||
var targets = new FakeRuntimeTargets(events);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
|
||||
// The fake records the publish but deliberately does not mutate the
|
||||
// canonical option, modeling an inactive/displaced live router.
|
||||
controller.RequestUiLocked(true);
|
||||
|
||||
Assert.Equal(
|
||||
[$"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:True"],
|
||||
events);
|
||||
Assert.Equal(0, targets.UiLockCalls);
|
||||
|
||||
// A later PlayerDescription has already replaced Runtime authority;
|
||||
// its convergence call is presentation-only and must not echo 0x0005.
|
||||
authoritativeLock = true;
|
||||
events.Clear();
|
||||
controller.SetUiLocked(true);
|
||||
|
||||
Assert.Equal(["target-ui-lock:True"], events);
|
||||
Assert.Single(targets.SingleOptionCalls);
|
||||
Assert.Equal(1, targets.UiLockCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetUiLocked_AppliesOnFirstCallThenNoOpsOnRepeatedSameValue()
|
||||
{
|
||||
|
|
@ -1121,10 +1200,13 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = [];
|
||||
|
||||
public Action<uint, bool>? SingleOptionApplied { get; set; }
|
||||
|
||||
public void SetSingleCharacterOption(uint optionId, bool value)
|
||||
{
|
||||
SingleOptionCalls.Add((optionId, value));
|
||||
events.Add($"target-single-option:0x{optionId:X}:{value}");
|
||||
SingleOptionApplied?.Invoke(optionId, value);
|
||||
}
|
||||
|
||||
public List<(float DefaultOpacity, float ActiveOpacity)> ChatOpacityCalls { get; } = [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Tests.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
|
|
@ -354,7 +356,7 @@ public sealed class CursorFeedbackControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
|
||||
public void UpdateFromRoot_worldProviderContinuesBehindNonItemUi()
|
||||
{
|
||||
var objects = SeedTargetObjects();
|
||||
var interaction = new ItemInteractionController(
|
||||
|
|
@ -376,11 +378,38 @@ public sealed class CursorFeedbackControllerTests
|
|||
// World hover (retail SmartBox found object) drives valid/invalid…
|
||||
Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
|
||||
|
||||
// …but UI occludes the world: hovering a plain panel → PENDING even
|
||||
// though the world provider would return a valid target.
|
||||
// UIElement_SmartBoxWrapper::FindObject @0x004E5430 only returns
|
||||
// early for UIElement_UIItem. Ordinary UI chrome falls through to
|
||||
// SmartBox::find_object, so it does not occlude the world pick.
|
||||
var panel = new UiPanel { Left = 390, Top = 290, Width = 40, Height = 40 };
|
||||
root.AddChild(panel);
|
||||
Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind);
|
||||
Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromRoot_toolbarBackpackRepresentsSelfAndShowsFoundCursorWithoutWorldHit()
|
||||
{
|
||||
ImportedLayout toolbar = FixtureLoader.LoadToolbar();
|
||||
using ToolbarController controller = ToolbarController.Bind(
|
||||
toolbar,
|
||||
new ClientObjectTable(),
|
||||
new ShortcutStore(),
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
useItem: static _ => { },
|
||||
playerGuid: () => Player);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(toolbar.Root);
|
||||
var backpack = Assert.IsType<UiButton>(toolbar.FindElement(0x100001B1u));
|
||||
System.Numerics.Vector2 position = backpack.ScreenPosition;
|
||||
root.OnMouseMove(
|
||||
(int)(position.X + backpack.Width * 0.5f),
|
||||
(int)(position.Y + backpack.Height * 0.5f));
|
||||
var c = new CursorFeedbackController(worldTargetProvider: () => 0u);
|
||||
|
||||
CursorFeedback feedback = c.Update(root);
|
||||
|
||||
Assert.Equal(CursorFeedbackKind.Default, feedback.Kind);
|
||||
Assert.Equal(RetailGlobalCursorKind.DefaultFound, feedback.GlobalKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -200,11 +200,11 @@ public class ChatLayoutConformanceTests
|
|||
[InlineData(0x10000698u)]
|
||||
[InlineData(0x10000699u)]
|
||||
[InlineData(0x1000069Au)]
|
||||
public void MountedChatWindow_LockedTwinBorderArt_DefaultsHidden(uint lockedTwinId)
|
||||
public void BoundChatWindow_LockedTwinBorderArt_SeedsUnlockedUntilRegistration(uint lockedTwinId)
|
||||
{
|
||||
// Register row AP-185: CH6a shows only the live (unlocked) grip/dragbar
|
||||
// border-art set by default, matching UiRoot.UiLocked's own false
|
||||
// default and avoiding a double-rendered border.
|
||||
// Bind seeds the unlocked skin for standalone layouts. Once registered,
|
||||
// RetailWindowLockPresentationController applies the canonical UiLocked
|
||||
// state; its real-fixture swap is covered by that controller's tests.
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
var controller = ChatWindowController.Bind(
|
||||
|
|
|
|||
|
|
@ -1483,6 +1483,80 @@ public class InventoryControllerTests
|
|||
ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot()
|
||||
{
|
||||
const uint droppedPack = 0x700000C0u;
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Type = ItemType.Creature,
|
||||
ItemsCapacity = 102,
|
||||
ContainersCapacity = 7,
|
||||
});
|
||||
SeedBag(objects, 0x500000C1u, slot: 0);
|
||||
SeedBag(objects, 0x500000C2u, slot: 1);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = droppedPack,
|
||||
Name = "Dropped Pack",
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
});
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(
|
||||
new InventoryTransactionState(objects)),
|
||||
new InteractionState(),
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => droppedPack,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
using var controller = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)),
|
||||
itemInteraction: interaction);
|
||||
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
|
||||
source.SetItem(droppedPack, 0u);
|
||||
var payload = new ItemDragPayload(
|
||||
droppedPack,
|
||||
ItemDragSource.Ground,
|
||||
SourceSlot: 0,
|
||||
SourceCell: source);
|
||||
|
||||
Assert.Equal(
|
||||
ItemDragAcceptance.Reject,
|
||||
controller.OnDragOver(grid, grid.GetItem(0)!, payload));
|
||||
controller.HandleDropRelease(grid, grid.GetItem(0)!, payload);
|
||||
Assert.Empty(puts);
|
||||
|
||||
UiItemSlot emptyPackSlot = containers.GetItem(2)!;
|
||||
Assert.Equal(0u, emptyPackSlot.ItemId);
|
||||
Assert.Equal(
|
||||
ItemDragAcceptance.Accept,
|
||||
controller.OnDragOver(containers, emptyPackSlot, payload));
|
||||
controller.HandleDropRelease(containers, emptyPackSlot, payload);
|
||||
|
||||
Assert.Equal(new[] { (droppedPack, Player, 2) }, puts);
|
||||
Assert.True(interaction.TryGetPendingBackpackPlacement(droppedPack, out var pending));
|
||||
Assert.Equal(Player, pending.ContainerId);
|
||||
Assert.Equal(2, pending.Placement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_selectsItem_butKeepsItUntilServerConfirms()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
|
@ -101,6 +103,111 @@ public sealed class RadarControllerTests
|
|||
Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_RealFixture_RootPointerLockCycleRoundTripsAuthorityAndMedia()
|
||||
{
|
||||
var layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadRadarInfos(),
|
||||
static file => (file, 8, 8),
|
||||
null);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(layout.Root);
|
||||
var state = UiRadarSnapshot.Empty with { UiLocked = false };
|
||||
var requestedLocks = new List<bool>();
|
||||
using var controller = RadarController.Bind(
|
||||
layout,
|
||||
() => state,
|
||||
setUiLocked: value =>
|
||||
{
|
||||
// Production's corrected contract: update the authoritative
|
||||
// character option before pushing the retained-root message.
|
||||
requestedLocks.Add(value);
|
||||
state = state with { UiLocked = value };
|
||||
root.UiLocked = value;
|
||||
});
|
||||
|
||||
var radar = Assert.IsType<UiRadar>(layout.Root);
|
||||
var lockButton = Assert.IsType<UiButton>(
|
||||
layout.FindElement(RadarController.LockButtonId));
|
||||
var dragButton = Assert.IsType<UiDatElement>(
|
||||
layout.FindElement(RadarController.DragButtonId));
|
||||
|
||||
radar.Refresh();
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
|
||||
Assert.True(lockButton.Visible);
|
||||
Assert.False(lockButton.ClickThrough);
|
||||
Assert.True(dragButton.Visible);
|
||||
Assert.True(radar.Draggable);
|
||||
|
||||
ClickThroughRoot(root, lockButton);
|
||||
radar.Refresh();
|
||||
|
||||
Assert.Equal([true], requestedLocks);
|
||||
Assert.True(root.UiLocked);
|
||||
Assert.Equal("LockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton));
|
||||
Assert.True(lockButton.Visible);
|
||||
Assert.False(lockButton.ClickThrough);
|
||||
Assert.False(dragButton.Visible);
|
||||
Assert.False(radar.Draggable);
|
||||
|
||||
MoveAwayAndBack(root, lockButton);
|
||||
Assert.Equal("LockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton));
|
||||
|
||||
// UiLocked suppresses move/resize only. The same radar button remains
|
||||
// hit-testable and must be able to publish the authoritative unlock.
|
||||
ClickThroughRoot(root, lockButton);
|
||||
radar.Refresh();
|
||||
|
||||
Assert.Equal([true, false], requestedLocks);
|
||||
Assert.False(root.UiLocked);
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
|
||||
Assert.True(lockButton.Visible);
|
||||
Assert.False(lockButton.ClickThrough);
|
||||
Assert.True(dragButton.Visible);
|
||||
Assert.True(radar.Draggable);
|
||||
|
||||
MoveAwayAndBack(root, lockButton);
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
|
||||
}
|
||||
|
||||
private static void ClickThroughRoot(UiRoot root, UiButton button)
|
||||
{
|
||||
Vector2 position = button.ScreenPosition;
|
||||
int x = (int)(position.X + button.Width * 0.5f);
|
||||
int y = (int)(position.Y + button.Height * 0.5f);
|
||||
root.OnMouseMove(x, y);
|
||||
root.OnMouseDown(UiMouseButton.Left, x, y);
|
||||
root.OnMouseUp(UiMouseButton.Left, x, y);
|
||||
}
|
||||
|
||||
private static void MoveAwayAndBack(UiRoot root, UiButton button)
|
||||
{
|
||||
root.OnMouseMove(700, 500);
|
||||
Vector2 position = button.ScreenPosition;
|
||||
root.OnMouseMove(
|
||||
(int)(position.X + button.Width * 0.5f),
|
||||
(int)(position.Y + button.Height * 0.5f));
|
||||
}
|
||||
|
||||
private static uint DrawnFaceFile(UiButton button)
|
||||
{
|
||||
var renderer = new TextRenderer(
|
||||
new RecordingGpuDevice(), new NullGpuFrameSource(), "unused");
|
||||
renderer.Begin(new Vector2(200f, 200f));
|
||||
button.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(200f, 200f)));
|
||||
return Assert.Single(renderer.DebugSpriteSegments).Texture;
|
||||
}
|
||||
|
||||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||
{
|
||||
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
private static ImportedLayout BuildRadarLayout()
|
||||
{
|
||||
var root = new ElementInfo
|
||||
|
|
|
|||
|
|
@ -465,6 +465,73 @@ public class ToolbarControllerTests
|
|||
Assert.Equal("Normal", characterButton.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_inventoryButtonPressKeepsClosedFaceThenOpensDirectly()
|
||||
{
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadToolbarInfos(),
|
||||
static file => (file, 8, 8),
|
||||
null);
|
||||
var controller = ToolbarController.Bind(
|
||||
layout,
|
||||
new ClientObjectTable(),
|
||||
new ShortcutStore(),
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
useItem: _ => { });
|
||||
controller.BindPanelButtons(
|
||||
_ => true,
|
||||
panelId => controller.SetPanelOpen(panelId, open: true));
|
||||
var inventory = Assert.IsType<UiButton>(layout.FindElement(InventoryButtonId));
|
||||
|
||||
Assert.Equal("Normal", inventory.ActiveState);
|
||||
Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory));
|
||||
|
||||
inventory.OnEvent(new UiEvent(
|
||||
inventory.EventId,
|
||||
inventory,
|
||||
UiEventType.MouseDown,
|
||||
Data1: 31,
|
||||
Data2: 29));
|
||||
|
||||
Assert.Equal("Normal_pressed", inventory.ActiveState);
|
||||
Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory));
|
||||
|
||||
inventory.OnEvent(new UiEvent(
|
||||
inventory.EventId,
|
||||
inventory,
|
||||
UiEventType.MouseUp,
|
||||
Data1: 31,
|
||||
Data2: 29));
|
||||
|
||||
Assert.Equal("Highlight", inventory.ActiveState);
|
||||
Assert.Equal(0x06004CF8u, DrawnFaceFile(inventory));
|
||||
}
|
||||
|
||||
[InstalledDatFact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void InstalledDat_inventoryButtonPressStateKeepsTheCurrentFace()
|
||||
{
|
||||
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new AcDream.App.Tests.BoundedTestDatCollection(
|
||||
datDirectory,
|
||||
DatReaderWriter.Options.DatAccessType.Read);
|
||||
ElementInfo root = Assert.IsType<ElementInfo>(
|
||||
LayoutImporter.ImportInfos(dats, 0x21000016u));
|
||||
ElementInfo inventory = FindInfo(root, InventoryButtonId);
|
||||
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].MediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].ImageMediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.NormalPressed].MediaCount);
|
||||
Assert.Equal(0, inventory.States[UiButtonStateMachine.NormalPressed].ImageMediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].MediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].ImageMediaCount);
|
||||
Assert.DoesNotContain("Normal_pressed", inventory.StateMedia.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_panelButtonsExposeExactDatPanelIds()
|
||||
{
|
||||
|
|
@ -1526,4 +1593,39 @@ public class ToolbarControllerTests
|
|||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||||
}
|
||||
|
||||
private static uint DrawnFaceFile(UiButton button)
|
||||
{
|
||||
var renderer = new AcDream.App.Rendering.TextRenderer(
|
||||
new AcDream.App.Tests.Rendering.Gpu.RecordingGpuDevice(),
|
||||
new NullGpuFrameSource(),
|
||||
"unused");
|
||||
renderer.Begin(new System.Numerics.Vector2(200f, 200f));
|
||||
button.DrawSelfAndChildren(new UiRenderContext(
|
||||
renderer,
|
||||
new System.Numerics.Vector2(200f, 200f)));
|
||||
return Assert.Single(renderer.DebugSpriteSegments).Texture;
|
||||
}
|
||||
|
||||
private sealed class NullGpuFrameSource
|
||||
: AcDream.App.Rendering.ICurrentGpuFrameSource
|
||||
{
|
||||
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
private static ElementInfo FindInfo(ElementInfo root, uint id)
|
||||
=> TryFindInfo(root, id)
|
||||
?? throw new InvalidOperationException($"Element 0x{id:X8} was not found.");
|
||||
|
||||
private static ElementInfo? TryFindInfo(ElementInfo root, uint id)
|
||||
{
|
||||
if (root.Id == id)
|
||||
return root;
|
||||
foreach (ElementInfo child in root.Children)
|
||||
{
|
||||
if (TryFindInfo(child, id) is { } found)
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1163,6 +1163,7 @@
|
|||
"File": 100693175,
|
||||
"DrawMode": 3
|
||||
},
|
||||
"MediaCount": 1,
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
"Values": {}
|
||||
|
|
@ -1177,6 +1178,7 @@
|
|||
"File": 100693176,
|
||||
"DrawMode": 3
|
||||
},
|
||||
"MediaCount": 1,
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
"Values": {}
|
||||
|
|
@ -1269,4 +1271,4 @@
|
|||
"LedCheckedSprite": 0,
|
||||
"LedUncheckedSprite": 0,
|
||||
"ScrollbarElementId": 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6569,6 +6569,8 @@
|
|||
"Name": "Normal_pressed",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 0,
|
||||
"MediaCount": 1,
|
||||
"ImageMediaCount": 0,
|
||||
"Image": null,
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
|
|
@ -6580,6 +6582,8 @@
|
|||
"Name": "Normal",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 0,
|
||||
"MediaCount": 1,
|
||||
"ImageMediaCount": 1,
|
||||
"Image": {
|
||||
"File": 100682999,
|
||||
"DrawMode": 3
|
||||
|
|
@ -6594,6 +6598,8 @@
|
|||
"Name": "Highlight",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 0,
|
||||
"MediaCount": 1,
|
||||
"ImageMediaCount": 1,
|
||||
"Image": {
|
||||
"File": 100683000,
|
||||
"DrawMode": 3
|
||||
|
|
@ -12574,4 +12580,4 @@
|
|||
"LedCheckedSprite": 0,
|
||||
"LedUncheckedSprite": 0,
|
||||
"ScrollbarElementId": 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreAll_WithoutSaveBack_WritesNothingToTheStore()
|
||||
public void RestoreAfterDisplayChange_WritesNothingToTheStore()
|
||||
{
|
||||
// The live display-change reload (#390) must not persist — retail
|
||||
// saves layouts only via @saveui/@saveautoui, and a mid-drag reload
|
||||
|
|
@ -346,19 +346,70 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
using var persistence = new RetailWindowLayoutPersistence(
|
||||
root.WindowManager, store, () => "Alice", () => (800, 600));
|
||||
|
||||
persistence.RestoreAll(saveBack: false);
|
||||
persistence.RestoreAfterDisplayChange();
|
||||
|
||||
Assert.Null(store.LoadWindowLayout(
|
||||
"Alice", "800x600", WindowNames.Examination, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreAfterDisplayChange_RestoresGeometryButPreservesLiveVisibility()
|
||||
{
|
||||
// Retail's global-message-0xE auto-layout file contains only X/Y/W/H.
|
||||
// Our richer ambient settings schema also remembers visibility and
|
||||
// collapsed/maximized state, but replaying visibility during a
|
||||
// resolution change used to hide an open Options panel. Config's OnHidden reset
|
||||
// then restored the previous resolution, creating the user-observed
|
||||
// resize-out/resize-back blip.
|
||||
var store = new SettingsStore(PathName);
|
||||
store.SaveWindowLayout(
|
||||
"Alice",
|
||||
"1280x720",
|
||||
WindowNames.Options,
|
||||
new UiWindowLayout(
|
||||
X: 700f,
|
||||
Y: 200f,
|
||||
Width: 310f,
|
||||
Height: 400f,
|
||||
Visible: false,
|
||||
Collapsed: true,
|
||||
Maximized: true));
|
||||
|
||||
var root = new UiRoot { Width = 1280, Height = 720 };
|
||||
var state = new FakeWindowState();
|
||||
var lifecycle = new FakePanelController();
|
||||
RetailWindowHandle handle = Mount(
|
||||
root,
|
||||
WindowNames.Options,
|
||||
state,
|
||||
controller: lifecycle,
|
||||
width: 300f,
|
||||
height: 300f);
|
||||
using var persistence = new RetailWindowLayoutPersistence(
|
||||
root.WindowManager,
|
||||
store,
|
||||
() => "Alice",
|
||||
() => (1280, 720));
|
||||
|
||||
persistence.RestoreAfterDisplayChange();
|
||||
|
||||
Assert.Equal((700f, 200f, 310f, 400f),
|
||||
(handle.Left, handle.Top, handle.Width, handle.Height));
|
||||
Assert.True(handle.IsVisible);
|
||||
Assert.Equal(0, lifecycle.HiddenCount);
|
||||
Assert.Equal(1, state.RestoreCount);
|
||||
Assert.True(state.Restored.Collapsed);
|
||||
Assert.True(state.Restored.Maximized);
|
||||
}
|
||||
|
||||
private static RetailWindowHandle Mount(
|
||||
UiRoot root,
|
||||
string name,
|
||||
IRetainedWindowStateController? state = null,
|
||||
int authoredGeometryRevision = 0,
|
||||
float width = 200f,
|
||||
float height = 100f)
|
||||
float height = 100f,
|
||||
IRetainedPanelController? controller = null)
|
||||
{
|
||||
var frame = new UiPanel
|
||||
{
|
||||
|
|
@ -377,6 +428,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
return root.RegisterWindow(
|
||||
name,
|
||||
frame,
|
||||
controller: controller,
|
||||
stateController: state,
|
||||
authoredGeometryRevision: authoredGeometryRevision);
|
||||
}
|
||||
|
|
@ -389,7 +441,23 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
private sealed class FakeWindowState : IRetainedWindowStateController
|
||||
{
|
||||
public RetainedWindowState Restored { get; private set; }
|
||||
public int RestoreCount { get; private set; }
|
||||
public RetainedWindowState CaptureWindowState() => Restored;
|
||||
public void RestoreWindowState(RetainedWindowState state) => Restored = state;
|
||||
public void RestoreWindowState(RetainedWindowState state)
|
||||
{
|
||||
Restored = state;
|
||||
RestoreCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakePanelController : IRetainedPanelController
|
||||
{
|
||||
public int HiddenCount { get; private set; }
|
||||
|
||||
public void OnShown() { }
|
||||
|
||||
public void OnHidden() => HiddenCount++;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RetailWindowLockPresentationControllerTests
|
||||
{
|
||||
private static readonly uint[] LockedChatChromeIds =
|
||||
[
|
||||
0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u,
|
||||
0x10000697u, 0x10000698u, 0x10000699u, 0x1000069Au,
|
||||
];
|
||||
|
||||
private static readonly uint[] LiveChatChromeIds =
|
||||
[
|
||||
0x1000069Bu, 0x1000069Cu, 0x1000069Du, 0x1000069Eu,
|
||||
0x1000069Fu, 0x100006A0u, 0x100006A1u, 0x100006A2u,
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void ExistingAndFutureNineSliceWindows_TrackCurrentLockPresentationExactly()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var existing = NewNineSlice();
|
||||
root.AddChild(existing);
|
||||
root.RegisterWindow("existing", existing);
|
||||
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
Assert.True(existing.DrawResizeAffordances);
|
||||
Assert.True(existing.DrawCenterFill);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.False(existing.DrawResizeAffordances);
|
||||
Assert.True(existing.DrawCenterFill);
|
||||
|
||||
var registeredWhileLocked = NewNineSlice();
|
||||
registeredWhileLocked.DrawCenterFill = false;
|
||||
root.AddChild(registeredWhileLocked);
|
||||
root.RegisterWindow("future", registeredWhileLocked);
|
||||
|
||||
Assert.False(registeredWhileLocked.DrawResizeAffordances);
|
||||
Assert.False(registeredWhileLocked.DrawCenterFill);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.False(existing.DrawResizeAffordances);
|
||||
Assert.False(registeredWhileLocked.DrawResizeAffordances);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.True(existing.DrawResizeAffordances);
|
||||
Assert.True(registeredWhileLocked.DrawResizeAffordances);
|
||||
Assert.True(existing.DrawCenterFill);
|
||||
Assert.False(registeredWhileLocked.DrawCenterFill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockedNineSlice_DrawsCenterAndEightBevelPieces_ButNoEightGripOverlays()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = NewNineSlice();
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("frame", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
TextRenderer unlocked = Draw(frame);
|
||||
Assert.Equal(17, SpriteCallCount(unlocked));
|
||||
Assert.Equal(8, GripCallCount(unlocked));
|
||||
|
||||
root.UiLocked = true;
|
||||
TextRenderer locked = Draw(frame);
|
||||
|
||||
Assert.Equal(9, SpriteCallCount(locked));
|
||||
Assert.Equal(0, GripCallCount(locked));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CenterFill));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.TopEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.BottomEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.LeftEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.RightEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTL));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTR));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBL));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBR));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredChatChrome_SwapsLiveAndLockedSets_AndUnlockRestoresExactly()
|
||||
{
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
_ = ChatWindowController.Bind(
|
||||
infos,
|
||||
layout,
|
||||
new ChatVM(new ChatLog()),
|
||||
() => NullCommandBus.Instance,
|
||||
new ChatWindowState(),
|
||||
null,
|
||||
null,
|
||||
NoTex);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(layout.Root);
|
||||
root.RegisterWindow("chat", layout.Root, layout.Root);
|
||||
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
AssertChrome(layout, liveVisible: true);
|
||||
|
||||
root.UiLocked = true;
|
||||
AssertChrome(layout, liveVisible: false);
|
||||
|
||||
root.UiLocked = true;
|
||||
AssertChrome(layout, liveVisible: false);
|
||||
|
||||
root.UiLocked = false;
|
||||
AssertChrome(layout, liveVisible: true);
|
||||
|
||||
root.UiLocked = false;
|
||||
AssertChrome(layout, liveVisible: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredWindowRegisteredWhileLocked_StartsWithLockedChrome()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600, UiLocked = true };
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
_ = ChatWindowController.Bind(
|
||||
infos,
|
||||
layout,
|
||||
new ChatVM(new ChatLog()),
|
||||
() => NullCommandBus.Instance,
|
||||
new ChatWindowState(),
|
||||
null,
|
||||
null,
|
||||
NoTex);
|
||||
|
||||
root.AddChild(layout.Root);
|
||||
root.RegisterWindow("late-chat", layout.Root, layout.Root);
|
||||
|
||||
AssertChrome(layout, liveVisible: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FloatingChat_LockHidesAllResizeGrips_ButLeavesTitleDragElementVisible()
|
||||
{
|
||||
var layout = FixtureLoader.LoadFloatyChat();
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(layout.Root);
|
||||
root.RegisterWindow("floaty", layout.Root, layout.Root);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
UiElement? titleDrag = layout.FindElement(0x10000529u);
|
||||
Assert.NotNull(titleDrag);
|
||||
UiResizeGrip[] grips = DescendantsAndSelf(layout.Root).OfType<UiResizeGrip>().ToArray();
|
||||
Assert.Equal(8, grips.Length);
|
||||
Assert.All(grips, grip => Assert.True(grip.Visible));
|
||||
Assert.True(titleDrag.Visible);
|
||||
|
||||
root.UiLocked = true;
|
||||
|
||||
Assert.All(grips, grip => Assert.False(grip.Visible));
|
||||
Assert.True(titleDrag.Visible);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.All(grips, grip => Assert.True(grip.Visible));
|
||||
Assert.True(titleDrag.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredChrome_MissingPairMembersAndUnrelatedDecoration_TransitionSafely()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Width = 200, Height = 100 };
|
||||
var lockedOnly = new UiPanel { DatElementId = 0x10000633u, Visible = false };
|
||||
var liveOnly = new UiPanel { DatElementId = 0x1000063Cu, Visible = true };
|
||||
var unrelated = new UiPanel { DatElementId = 0x10000632u, Visible = true };
|
||||
var initiallyHiddenLive = new UiPanel { DatElementId = 0x1000063Du, Visible = false };
|
||||
frame.AddChild(lockedOnly);
|
||||
frame.AddChild(liveOnly);
|
||||
frame.AddChild(unrelated);
|
||||
frame.AddChild(initiallyHiddenLive);
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("partial", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.True(lockedOnly.Visible);
|
||||
Assert.False(liveOnly.Visible);
|
||||
Assert.True(unrelated.Visible);
|
||||
Assert.False(initiallyHiddenLive.Visible);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.False(lockedOnly.Visible);
|
||||
Assert.True(liveOnly.Visible);
|
||||
Assert.True(unrelated.Visible);
|
||||
Assert.False(initiallyHiddenLive.Visible);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000633u, 0x1000063Bu)]
|
||||
[InlineData(0x10000643u, 0x1000064Bu)]
|
||||
[InlineData(0x10000653u, 0x1000065Bu)]
|
||||
[InlineData(0x10000663u, 0x1000066Bu)]
|
||||
[InlineData(0x10000673u, 0x1000067Bu)]
|
||||
[InlineData(0x10000683u, 0x1000068Bu)]
|
||||
[InlineData(0x10000693u, 0x1000069Bu)]
|
||||
[InlineData(0x100006A5u, 0x100006ADu)]
|
||||
public void EveryRetailAuthoredChromeBlock_SwapsAllEightMembers(
|
||||
uint lockedStart,
|
||||
uint liveStart)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Width = 200, Height = 100 };
|
||||
UiElement[] lockedChrome = Enumerable.Range(0, 8)
|
||||
.Select(i => new UiPanel { DatElementId = lockedStart + (uint)i })
|
||||
.Cast<UiElement>()
|
||||
.ToArray();
|
||||
UiElement[] liveChrome = Enumerable.Range(0, 8)
|
||||
.Select(i => new UiPanel { DatElementId = liveStart + (uint)i })
|
||||
.Cast<UiElement>()
|
||||
.ToArray();
|
||||
foreach (UiElement element in lockedChrome.Concat(liveChrome))
|
||||
frame.AddChild(element);
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("authored", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
Assert.All(lockedChrome, element => Assert.False(element.Visible));
|
||||
Assert.All(liveChrome, element => Assert.True(element.Visible));
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.All(lockedChrome, element => Assert.True(element.Visible));
|
||||
Assert.All(liveChrome, element => Assert.False(element.Visible));
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.All(lockedChrome, element => Assert.False(element.Visible));
|
||||
Assert.All(liveChrome, element => Assert.True(element.Visible));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmartBoxLiveOnlyChrome_HidesAndRestores_WithoutTouchingOtherType2OrType3Decoration()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Width = 200, Height = 100 };
|
||||
UiElement[] smartBoxChrome = Enumerable.Range(0, 8)
|
||||
.Select(i => new UiPanel { DatElementId = 0x100006CAu + (uint)i })
|
||||
.Cast<UiElement>()
|
||||
.ToArray();
|
||||
var unrelatedType2 = new UiPanel { DatElementId = 0x10000529u };
|
||||
var unrelatedType3 = new UiPanel { DatElementId = 0x100006D2u };
|
||||
foreach (UiElement chrome in smartBoxChrome)
|
||||
frame.AddChild(chrome);
|
||||
frame.AddChild(unrelatedType2);
|
||||
frame.AddChild(unrelatedType3);
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("smartbox", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.All(smartBoxChrome, chrome => Assert.False(chrome.Visible));
|
||||
Assert.True(unrelatedType2.Visible);
|
||||
Assert.True(unrelatedType3.Visible);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.All(smartBoxChrome, chrome => Assert.True(chrome.Visible));
|
||||
Assert.True(unrelatedType2.Visible);
|
||||
Assert.True(unrelatedType3.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockedLateRegistration_AppliesChromeBeforeControllerOnShown()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600, UiLocked = true };
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
var frame = NewNineSlice();
|
||||
root.AddChild(frame);
|
||||
var observer = new OnShownObserver(() => !frame.DrawResizeAffordances);
|
||||
|
||||
root.RegisterWindow("late", frame, frame, observer);
|
||||
|
||||
Assert.True(observer.ObservedLockedChrome);
|
||||
Assert.Equal(1, observer.ShownCount);
|
||||
}
|
||||
|
||||
private static UiNineSlicePanel NewNineSlice() =>
|
||||
new(static id => (id, 5, 5)) { Width = 200, Height = 100 };
|
||||
|
||||
private static TextRenderer Draw(UiElement element)
|
||||
{
|
||||
var renderer = new TextRenderer(new RecordingGpuDevice(), new NullGpuFrameSource(), "unused");
|
||||
renderer.Begin(new Vector2(800f, 600f));
|
||||
element.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(800f, 600f)));
|
||||
return renderer;
|
||||
}
|
||||
|
||||
private static int SpriteCallCount(TextRenderer renderer) =>
|
||||
renderer.DebugSpriteSegments.Sum(segment => segment.VertexCount / 6);
|
||||
|
||||
private static int CallsFor(TextRenderer renderer, uint texture) =>
|
||||
renderer.DebugSpriteSegments
|
||||
.Where(segment => segment.Texture == texture)
|
||||
.Sum(segment => segment.VertexCount / 6);
|
||||
|
||||
private static int GripCallCount(TextRenderer renderer) =>
|
||||
CallsFor(renderer, RetailChromeSprites.GripTop)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripBottom)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripLeft)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripRight)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripCorner);
|
||||
|
||||
private static IEnumerable<UiElement> DescendantsAndSelf(UiElement root)
|
||||
{
|
||||
yield return root;
|
||||
foreach (UiElement child in root.Children)
|
||||
foreach (UiElement descendant in DescendantsAndSelf(child))
|
||||
yield return descendant;
|
||||
}
|
||||
|
||||
private static void AssertChrome(ImportedLayout layout, bool liveVisible)
|
||||
{
|
||||
foreach (uint id in LiveChatChromeIds)
|
||||
{
|
||||
UiElement? element = layout.FindElement(id);
|
||||
Assert.NotNull(element);
|
||||
Assert.Equal(liveVisible, element.Visible);
|
||||
}
|
||||
foreach (uint id in LockedChatChromeIds)
|
||||
{
|
||||
UiElement? element = layout.FindElement(id);
|
||||
Assert.NotNull(element);
|
||||
Assert.Equal(!liveVisible, element.Visible);
|
||||
}
|
||||
}
|
||||
|
||||
private static (uint tex, int width, int height) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||
{
|
||||
public IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
private sealed class OnShownObserver(Func<bool> observe) : IRetainedPanelController
|
||||
{
|
||||
public int ShownCount { get; private set; }
|
||||
public bool ObservedLockedChrome { get; private set; }
|
||||
|
||||
public void OnShown()
|
||||
{
|
||||
ShownCount++;
|
||||
ObservedLockedChrome = observe();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Tests.Player;
|
||||
|
|
@ -170,6 +171,123 @@ public sealed class LocalPlayerStateTests
|
|||
Assert.Equal(1f, s.StaminaPercent!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMaxApprox_PrimaryAttributeModifierWithCollidingKeyDoesNotAffectHealth()
|
||||
{
|
||||
// Regression: PropertyAttribute.Strength and
|
||||
// PropertyAttribute2nd.MaxHealth both use numeric key 1. The HUD's
|
||||
// GetVitalMod path previously omitted retail's SecondAtt domain
|
||||
// filter, so a near-identity Strength modifier reproduced the live
|
||||
// report exactly: 99999 current / 99998 max.
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(1u)]));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 1u,
|
||||
LayerId: 1u,
|
||||
Duration: 60d,
|
||||
CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
|
||||
StatModKey: 1u,
|
||||
StatModValue: 0.99999f,
|
||||
Bucket: 1u));
|
||||
var s = new LocalPlayerState(book);
|
||||
s.OnVitalUpdate(
|
||||
vitalId: 7u,
|
||||
ranks: 99_999u,
|
||||
start: 0u,
|
||||
xp: 0u,
|
||||
current: 99_999u);
|
||||
|
||||
Assert.Equal(99_999u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
|
||||
Assert.Equal(99_999u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
|
||||
Assert.Equal(1f, s.HealthPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMaxApprox_SecondaryAttributeModifierTruncatesLikeRetail()
|
||||
{
|
||||
// CEnchantmentRegistry::EnchantAttribute2nd ends in _ftol2. A
|
||||
// fractional secondary-attribute result is truncated, not rounded.
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(1u)]));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 1u,
|
||||
LayerId: 1u,
|
||||
Duration: 60d,
|
||||
CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
|
||||
StatModKey: EnchantmentMath.StatKey.MaxHealth,
|
||||
StatModValue: 0.75f,
|
||||
Bucket: 2u));
|
||||
var s = new LocalPlayerState(book);
|
||||
s.OnVitalUpdate(
|
||||
vitalId: 7u,
|
||||
ranks: 100u,
|
||||
start: 0u,
|
||||
xp: 0u,
|
||||
current: 100u);
|
||||
|
||||
Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMaxApprox_PrimaryAttributeBuffsFeedVitalFormula_ExactLiveRegression()
|
||||
{
|
||||
// Live screenshot regression: the server-authoritative currents were
|
||||
// 38/75/25 while the HUD showed the raw-formula maxima 30/60/10.
|
||||
// Retail InqAttribute2nd evaluates Endurance/Self through enchanted
|
||||
// InqAttribute before applying the separate secondary-attribute mod.
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 1u,
|
||||
LayerId: 1u,
|
||||
Duration: 60d,
|
||||
CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
|
||||
StatModKey: 2u, // Endurance
|
||||
StatModValue: 15f,
|
||||
Bucket: 2u));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 2u,
|
||||
LayerId: 2u,
|
||||
Duration: 60d,
|
||||
CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
|
||||
StatModKey: 6u, // Self
|
||||
StatModValue: 15f,
|
||||
Bucket: 2u));
|
||||
var s = new LocalPlayerState(book);
|
||||
s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 30u, xp: 0u);
|
||||
s.OnAttributeUpdate(atType: 6u, ranks: 0u, start: 10u, xp: 0u);
|
||||
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 15u, xp: 0u, current: 38u);
|
||||
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 30u, xp: 0u, current: 75u);
|
||||
s.OnVitalUpdate(vitalId: 9u, ranks: 0u, start: 0u, xp: 0u, current: 25u);
|
||||
|
||||
Assert.Equal(30u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
|
||||
Assert.Equal(60u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Stamina));
|
||||
Assert.Equal(10u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Mana));
|
||||
Assert.Equal(38u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
|
||||
Assert.Equal(75u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina));
|
||||
Assert.Equal(25u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana));
|
||||
Assert.Equal(1f, s.HealthPercent);
|
||||
Assert.Equal(1f, s.StaminaPercent);
|
||||
Assert.Equal(1f, s.ManaPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMaxApprox_HealthRoundsHalfEnduranceAndIncludesGearMaxHealth()
|
||||
{
|
||||
// SkillFormula::Calculate @ 0x00591960 rounds 45/2 to 23, and
|
||||
// InqAttribute2nd @ 0x00592020 adds property 379 before enchantment.
|
||||
var s = new LocalPlayerState();
|
||||
var properties = new PropertyBundle();
|
||||
properties.Ints[(uint)PropertyInt.GearMaxHealth] = 7;
|
||||
s.OnProperties(properties);
|
||||
s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 45u, xp: 0u);
|
||||
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 10u, xp: 0u, current: 40u);
|
||||
|
||||
Assert.Equal(40u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
|
||||
Assert.Equal(40u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ public sealed class SpellbookTests
|
|||
LayerId: 7u,
|
||||
Duration: 300f,
|
||||
CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
|
||||
StatModKey: EnchantmentMath.StatKey.MaxHealth,
|
||||
StatModValue: 1.5f,
|
||||
Bucket: 1u));
|
||||
|
|
@ -134,6 +135,7 @@ public sealed class SpellbookTests
|
|||
LayerId: 7u,
|
||||
Duration: 300f,
|
||||
CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
|
||||
StatModKey: EnchantmentMath.StatKey.MaxHealth,
|
||||
StatModValue: 1.25f,
|
||||
Bucket: 1u));
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ public sealed class RuntimeCharacterStateTests
|
|||
Duration: 60f,
|
||||
CasterGuid: 2u,
|
||||
Bucket: 2u,
|
||||
StatModType: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
|
||||
StatModKey: EnchantmentMath.StatKey.MaxHealth,
|
||||
StatModValue: 25f));
|
||||
state.Spellbook.SetDesiredComponent(0x68000001u, 12u);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue