fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -3,6 +3,7 @@ using AcDream.Core.Chat;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
using AcDream.Runtime.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
@ -15,6 +16,50 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class ClientCommandController
{
/// <summary>
/// Typed network effects used by retail's allegiance/house management
/// command family. Grouped separately so the main command binding remains
/// readable and every operation keeps a semantic name instead of exposing
/// raw opcodes to the UI layer.
/// </summary>
public sealed record AdministrationBindings(
Action<string, bool> BreakAllegianceBoot,
Action<string, string> AllegianceChatBoot,
Action<string, bool> AllegianceChatGag,
Action<string> AllegianceBroadcast,
Action ListAllegianceBans,
Action<string> AddAllegianceBan,
Action<string> RemoveAllegianceBan,
Action ListAllegianceOfficers,
Action ClearAllegianceOfficers,
Action<string, uint> SetAllegianceOfficer,
Action<string> RemoveAllegianceOfficer,
Action ListAllegianceOfficerTitles,
Action ClearAllegianceOfficerTitles,
Action<uint, string> SetAllegianceOfficerTitle,
Action QueryAllegianceName,
Action<string> SetAllegianceName,
Action ClearAllegianceName,
Action<uint> AllegianceLockAction,
Action<string> SetAllegianceApprovedVassal,
Action<uint> AllegianceHouseAction,
Action QueryMotd,
Action<string> SetMotd,
Action ClearMotd,
Action<bool> SetOpenHouseStatus,
Action<string> AddPermanentGuest,
Action<string> RemovePermanentGuest,
Action RemoveAllPermanentGuests,
Action<string, bool> ChangeStoragePermission,
Action AddAllStoragePermission,
Action RemoveAllStoragePermission,
Action RequestFullGuestList,
Action<string> BootSpecificHouseGuest,
Action BootEveryone,
Action<bool> SetHooksVisibility,
Action<bool> ModifyAllegianceGuestPermission,
Action<bool> ModifyAllegianceStoragePermission);
public sealed record Bindings(
Action TeleportToLifestone,
Action TeleportToMarketplace,
@ -27,6 +72,7 @@ public sealed class ClientCommandController
Action ToggleFrameRate,
Action ToggleUiLock,
Action<string> ShowSystemMessage,
Action<string> ShowClientLocalMessage,
Action<uint> ShowWeenieError,
Func<uint?> PlayerPublicWeenieBitfield,
Func<string> ClientVersion,
@ -76,13 +122,63 @@ public sealed class ClientCommandController
Action<uint> LeaveGmChannel,
Action RecallAllegianceHometown,
Action<string> RequestAllegianceInfo,
Action AbandonHouse);
Action AbandonHouse,
AdministrationBindings Administration,
Func<bool> IsPersistentDaylight,
Action<bool> SetPersistentDaylight,
Action<int> SetLandscapeRadius,
Action<float> SetFieldOfView);
private readonly Bindings _bindings;
private readonly RetailAdministrationCommandDispatcher _administration;
public ClientCommandController(Bindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
AdministrationBindings actions = bindings.Administration;
_administration = new RetailAdministrationCommandDispatcher(
new RetailAdministrationCommandDispatcher.FeedbackBindings(
bindings.ShowSystemMessage,
bindings.ShowClientLocalMessage,
bindings.SetSingleCharacterOption,
bindings.RequestAllegianceInfo),
new RetailAdministrationCommandDispatcher.ActionBindings(
actions.BreakAllegianceBoot,
actions.AllegianceChatBoot,
actions.AllegianceChatGag,
actions.AllegianceBroadcast,
actions.ListAllegianceBans,
actions.AddAllegianceBan,
actions.RemoveAllegianceBan,
actions.ListAllegianceOfficers,
actions.ClearAllegianceOfficers,
actions.SetAllegianceOfficer,
actions.RemoveAllegianceOfficer,
actions.ListAllegianceOfficerTitles,
actions.ClearAllegianceOfficerTitles,
actions.SetAllegianceOfficerTitle,
actions.QueryAllegianceName,
actions.SetAllegianceName,
actions.ClearAllegianceName,
actions.AllegianceLockAction,
actions.SetAllegianceApprovedVassal,
actions.AllegianceHouseAction,
actions.QueryMotd,
actions.SetMotd,
actions.ClearMotd,
actions.SetOpenHouseStatus,
actions.AddPermanentGuest,
actions.RemovePermanentGuest,
actions.RemoveAllPermanentGuests,
actions.ChangeStoragePermission,
actions.AddAllStoragePermission,
actions.RemoveAllStoragePermission,
actions.RequestFullGuestList,
actions.BootSpecificHouseGuest,
actions.BootEveryone,
actions.SetHooksVisibility,
actions.ModifyAllegianceGuestPermission,
actions.ModifyAllegianceStoragePermission));
}
public void Execute(ExecuteClientCommandCmd command)
@ -146,6 +242,23 @@ public sealed class ClientCommandController
case ClientCommandId.ToggleFrameRate:
_bindings.ToggleFrameRate();
break;
// ClientCommunicationSystem::DoDay @0x005706F0. Retail toggles
// LScape::m_fAlwaysDaylight, then writes the same value to the
// PersistentAtDay character option and prints one exact line.
case ClientCommandId.TogglePersistentDaylight:
{
bool enabled = !_bindings.IsPersistentDaylight();
_bindings.SetPersistentDaylight(enabled);
_bindings.ShowSystemMessage(enabled
? "Let there be light!"
: "Normality has been restored.");
break;
}
// ClientCommunicationSystem::DoRenderOption @0x0057E120 ->
// GraphicsOptions::HandleRenderOption @0x00455C30.
case ClientCommandId.RenderOption:
ExecuteRenderOption(command.Arguments);
break;
// DoLockUI @ 0x005703B0 toggles PlayerModule::LockUI and
// broadcasts the new state to every UI element.
case ClientCommandId.ToggleUiLock:
@ -341,9 +454,29 @@ public sealed class ClientCommandController
case ClientCommandId.AllegianceHometown:
_bindings.RecallAllegianceHometown();
break;
// GameActionAllegianceInfoRequest — "@allegiance info [name]".
// ClientCommunicationSystem::DoAllegiance/DoHouse management
// family. The shared Runtime dispatcher keeps graphical and
// headless grammar/refusal behavior identical.
case ClientCommandId.AllegianceInfo:
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
case ClientCommandId.AllegianceBoot:
case ClientCommandId.AllegianceBan:
case ClientCommandId.AllegianceChat:
case ClientCommandId.AllegianceBroadcast:
case ClientCommandId.AllegianceOfficer:
case ClientCommandId.AllegianceOfficerTitle:
case ClientCommandId.AllegianceName:
case ClientCommandId.AllegianceLock:
case ClientCommandId.AllegianceHouse:
case ClientCommandId.AllegianceMotd:
case ClientCommandId.AllegianceUnrecognizedSubcommand:
case ClientCommandId.HouseOpenStatus:
case ClientCommandId.HouseStorage:
case ClientCommandId.HouseBoot:
case ClientCommandId.HouseBootAll:
case ClientCommandId.HouseGuests:
case ClientCommandId.HouseHooks:
case ClientCommandId.HouseUnrecognizedSubcommand:
_ = _administration.TryExecute(command.Command, command.Arguments);
break;
// GameActionHouseAbandon — "@house abandon". Retail's abandon
// branch (DoHouse @ 0x00580D58) opens a FIRST confirmation
@ -384,6 +517,7 @@ public sealed class ClientCommandController
}
}
private void ExecuteUiProfile(string arguments, bool save)
{
string[] parts = SplitArguments(arguments);
@ -405,6 +539,94 @@ public sealed class ClientCommandController
else _bindings.LoadUi(name);
}
/// <summary>
/// Exact retail <c>GraphicsOptions::HandleRenderOption @0x00455C30</c>
/// surface. It has only two options, ignores surplus argv entries, uses
/// C <c>atoi</c> semantics for the value, and silently accepts an unknown
/// option name.
/// </summary>
private void ExecuteRenderOption(string arguments)
{
string[] parts = SplitArguments(arguments);
if (parts.Length == 0
|| parts[0].Equals("usage", StringComparison.OrdinalIgnoreCase))
{
_bindings.ShowSystemMessage(RetailCommandHelpTable.Render.TrimEnd('\n'));
return;
}
if (parts[0].Equals("radius", StringComparison.OrdinalIgnoreCase))
{
if (parts.Length < 2)
{
_bindings.ShowSystemMessage("Must specify a radius");
return;
}
int radius = RetailAtoi(parts[1]);
if (radius is < 5 or > 25)
{
_bindings.ShowSystemMessage("Radius must be between 5 and 25");
return;
}
_bindings.SetLandscapeRadius(radius);
_bindings.ShowSystemMessage("Landscape radius set");
return;
}
if (!parts[0].Equals("fov", StringComparison.OrdinalIgnoreCase))
return;
if (parts.Length < 2)
{
_bindings.ShowSystemMessage("Must specify a field of view");
return;
}
int fieldOfView = RetailAtoi(parts[1]);
if (fieldOfView is < 10 or > 160)
{
_bindings.ShowSystemMessage(
"Field of view must be between 10 and 160");
return;
}
_bindings.SetFieldOfView(fieldOfView);
_bindings.ShowSystemMessage("Field of view set");
}
private static int RetailAtoi(string value)
{
if (string.IsNullOrEmpty(value))
return 0;
int index = 0;
int sign = 1;
if (value[0] is '+' or '-')
{
if (value[0] == '-')
sign = -1;
index++;
}
long result = 0;
bool sawDigit = false;
while (index < value.Length && value[index] is >= '0' and <= '9')
{
sawDigit = true;
result = Math.Min(
(long)int.MaxValue + (sign < 0 ? 1L : 0L),
result * 10L + (value[index] - '0'));
index++;
}
if (!sawDigit)
return 0;
long signed = sign < 0 ? -result : result;
return (int)Math.Clamp(signed, int.MinValue, int.MaxValue);
}
private bool RequireNoArguments(string arguments, string usage)
{
if (string.IsNullOrWhiteSpace(arguments)) return true;

View file

@ -306,41 +306,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
// R3-3 (Campaign CC gate round 1 re-test 2): the title
// (0x100003fb, Y=435, Height=100) and description (0x100003fc,
// Y=460, Height=100) panes' own AUTHORED boxes overlap by 75px
// (live-DAT-measured) — retail relies on vertical JUSTIFICATION,
// not disjoint rects, to keep the two visually separate. Neither
// element authors dat property 0x15 (live-DAT-probe-confirmed
// absent on both), so both fall to whatever the unauthored default
// resolves to. Byte-traced against retail's own
// UIElement_Text::UIElement_Text ctor @0x004685ff
// (this->m_eVerticalJustification = 4) cross-referenced with
// UIElement_Text::CalcJustification @0x00467260 (the ACTUAL
// enum semantics: ecx_5==1 -> Center, ecx_5==3||5 -> the FAR edge
// (Bottom), any other value including the ctor's own default of 4
// -> edi=0, the NEAR edge, i.e. Top): the correct unauthored
// default is TOP, not Center. This port's shared
// ElementReader/DatWidgetFactory VJustify mapping and field
// default both currently resolve an absent 0x15 to Center — a
// client-wide mismatch with real retail semantics that is NOT
// fixed here (filed as ISSUES.md #410; the blast radius spans
// every already-shipped DAT-imported UiText that relies on the
// CURRENT Center default, so a global remap needs its own
// dedicated investigation + regression sweep, not a bundled
// fix inside this page). Scoped correction: force these two
// specific panes to the value retail's ctor actually resolves
// to. Under Top justification the title (OneLine, ~1 line) sits
// near its box's own top (global Y~435) and the description
// (multi-line, honoring the SAME justification via
// ConfigureDatState's _honorDatVerticalJustification) starts near
// ITS box's own top (global Y~460) — the two boxes' TOP edges are
// 25px apart, so short/typical content no longer collides even
// though the boxes' full 100px extents still overlap on paper.
if (_infoTitle is { } infoTitle)
infoTitle.VerticalJustify = VJustify.Top;
if (_infoText is { } infoText)
infoText.VerticalJustify = VJustify.Top;
// The two panes author no 0x15. The shared importer now applies
// retail's constructor default (raw 4 -> Top), so no page-local
// justification correction is needed.
// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's
// own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT-

View file

@ -309,40 +309,6 @@ internal sealed class CharacterCreationUiController : IDisposable
_townTab.OnClick = () => ApplyProgressState(Page.Town);
_summaryTab.OnClick = () => ApplyProgressState(Page.Summary);
// GF-13 (Campaign CC gate round 1, Batch A): honor the authored
// Invisible flag (dat property 0x3B) chargen-scoped only — see
// HideAuthoredInvisibleElements's own doc comment.
HideAuthoredInvisibleElements(Root);
}
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A). The user's live gate
/// reported an acdream-only "-Non-admin or Non-envoy" text leak below the
/// Summary name field. Root cause: elements <c>0x10000403</c> ("Non-
/// Admin") and <c>0x10000494</c> ("Non-Envoy") author dat property
/// <c>0x3B</c> (Invisible) = <see langword="true"/> — retail's
/// <c>UIElement::OnSetAttribute @0x00462d80</c> case 8
/// (<c>GetPropertyName()-0x33 == 8</c>, property id <c>0x3B</c>) hides any
/// element authoring it via <c>SetVisible(value == 0)</c>. acdream's
/// shared <see cref="LayoutImporter"/> never read this property at all
/// (it now does, into <see cref="ElementInfo.Invisible"/> /
/// <see cref="UiElement.AuthoredInvisible"/>, a pure data addition), so
/// every one of the 1,083 elements client-wide that author it rendered
/// regardless. A blanket importer-wide honor is its own separately-gated
/// visual sweep (docs/ISSUES.md #408) — this method is the NARROW,
/// chargen-scoped fix: walk this screen's own mounted subtree once at
/// construction and hide anything the dat itself marked hidden, by the
/// AUTHORED FLAG rather than a hardcoded id list, so any other
/// authored-invisible element under this root (not just the two the user
/// happened to see) is honored the same way. Register AP-230 records the
/// scoped-vs-general split.
/// </summary>
private static void HideAuthoredInvisibleElements(UiElement element)
{
if (element.AuthoredInvisible)
element.Visible = false;
foreach (UiElement child in element.Children)
HideAuthoredInvisibleElements(child);
}
internal UiElement Root => _layout.Root;

View file

@ -23,7 +23,7 @@ internal sealed class CharacterManagementUiController : IDisposable
/// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id
/// switch is keyed off <c>idElement - 0x1000039d</c> (the listbox base);
/// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers
/// (Register@0x0047a69e) — out of scope this round (finding 1 note).
/// (Register@0x004E7500).
/// </summary>
internal const uint CreditsElementId = 0x100003A3u;
/// <summary>Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250.</summary>
@ -69,6 +69,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private uint _errorDialogContext;
private uint _confirmExitDialogContext;
private bool _active;
private bool _presentationSuppressed;
private bool _restoreCommandInFlight;
private bool _suppressDialogCallbacks;
private bool _disposed;
@ -86,7 +87,8 @@ internal sealed class CharacterManagementUiController : IDisposable
UiButton exit,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
DialogStrings strings,
Action? openCredits)
{
_host = host;
_layout = layout;
@ -139,13 +141,12 @@ internal sealed class CharacterManagementUiController : IDisposable
_delete.OnClick = RequestDelete;
_restore.OnClick = RestoreSelected;
// Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of
// scope this round (finding 1 note) — same "future campaign, visibly
// ghosted, no invented action" treatment as Create above. Filed as
// issue #400.
// ListenToElementMessage @0x004ED5A0 case 6 queues UI mode
// 0x10000005, registered by gmCreditsUI::Register @0x004E7500.
// The local composition callback performs that same screen swap.
_credits.Visible = true;
_credits.Enabled = false;
_credits.OnClick = null;
_credits.Enabled = openCredits is not null;
_credits.OnClick = openCredits;
_exit.OnClick = RequestExit;
// World name (retail UpdateWorldName@0x004ec120 /
@ -168,6 +169,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{
if (_disposed)
return;
_presentationSuppressed = false;
Deactivate();
_lastRevision = long.MinValue;
}
@ -178,7 +180,8 @@ internal sealed class CharacterManagementUiController : IDisposable
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
DialogStrings strings,
Action? openCredits = null)
{
CharacterManagementUiController? controller = CreateDetached(
host,
@ -186,7 +189,8 @@ internal sealed class CharacterManagementUiController : IDisposable
templateResolver,
dialogs,
bindings,
strings);
strings,
openCredits);
if (controller is null)
return null;
@ -208,7 +212,8 @@ internal sealed class CharacterManagementUiController : IDisposable
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
DialogStrings strings,
Action? openCredits = null)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(layout);
@ -255,7 +260,8 @@ internal sealed class CharacterManagementUiController : IDisposable
exit,
dialogs,
bindings,
strings);
strings,
openCredits);
}
catch
{
@ -264,6 +270,7 @@ internal sealed class CharacterManagementUiController : IDisposable
enter.OnClick = null;
delete.OnClick = null;
restore.OnClick = null;
credits.OnClick = null;
exit.OnClick = null;
throw;
}
@ -292,6 +299,13 @@ internal sealed class CharacterManagementUiController : IDisposable
if (_disposed)
return;
if (_presentationSuppressed)
{
Deactivate();
_lastRevision = long.MinValue;
return;
}
IRuntimeCharacterSelectionView? view = _bindings.View();
RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default;
if (view is null || !snapshot.IsActive)
@ -380,6 +394,7 @@ internal sealed class CharacterManagementUiController : IDisposable
_enter.OnClick = null;
_delete.OnClick = null;
_restore.OnClick = null;
_credits.OnClick = null;
_exit.OnClick = null;
foreach (UiButton row in _rows)
{
@ -394,6 +409,21 @@ internal sealed class CharacterManagementUiController : IDisposable
}
}
/// <summary>
/// Local UI-mode seam used by <see cref="CreditsUiController"/>. Retail
/// destroys/recreates the two frameworks through QueueUIMode; hiding this
/// retained root while preserving Runtime's character-selection owner has
/// the same observable behavior without duplicating state.
/// </summary>
internal void SetPresentationSuppressed(bool suppressed)
{
if (_disposed || _presentationSuppressed == suppressed)
return;
_presentationSuppressed = suppressed;
_lastRevision = long.MinValue;
Tick();
}
private static bool TryCaptureRoster(
IRuntimeCharacterSelectionView view,
RuntimeCharacterSelectionSnapshot expected,

View file

@ -20,13 +20,15 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
private readonly Action? _openCredits;
private bool _disposed;
public CharacterManagementUiMountCoordinator(
UiRoot host,
CharacterSelectionRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterManagementUiMountResources?> loadResources)
Func<CharacterManagementUiMountResources?> loadResources,
Action? openCredits = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
@ -34,6 +36,7 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
_openCredits = openCredits;
}
public CharacterManagementUiController? Controller { get; private set; }
@ -58,9 +61,10 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
_host,
resources.Layout,
resources.TemplateResolver,
dialogs,
_bindings,
resources.Strings);
dialogs,
_bindings,
resources.Strings,
_openCredits);
if (candidate is null)
return;

View file

@ -1272,6 +1272,11 @@ public static class ConfigOptionsPageController
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh", "ID_Graphics_Value_Extreme",
};
private static readonly int[] LandscapeDrawDistanceValues =
{
3, 5, 8, 11, 15, 25,
};
private static void BindRenderingQualitySection(
UiTemplateListBox listBox,
OptionPage page,
@ -1311,17 +1316,18 @@ public static class ConfigOptionsPageController
storeOnly: true, // AP-198
resolveSprite, datFont, debugFont);
// UNRESOLVED (see class doc / register row): retail's own
// SetDefaultValue(8) does not index this 6-entry choice array.
// Reproduced as an opaque int; the menu simply shows no
// highlighted item at the default (no crash, no invented mapping).
// Retail's SetEnumChoices carries the six integer payloads
// {3,5,8,11,15,25}; captions are indices only in presentation.
// SetDefaultValue(8) therefore selects Medium, and @render radius
// writes the same preference value this row reads.
BuildMenuRow(
listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeDrawDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }),
defaultValue: 8,
storeOnly: true, // AP-198
resolveSprite, datFont, debugFont);
storeOnly: false,
resolveSprite, datFont, debugFont,
payloadValues: LandscapeDrawDistanceValues);
BuildToggleRow(
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
@ -1736,7 +1742,8 @@ public static class ConfigOptionsPageController
bool storeOnly,
Func<uint, (uint tex, int w, int h)>? resolveSprite,
UiDatFont? datFont,
BitmapFont? debugFont)
BitmapFont? debugFont,
IReadOnlyList<int>? payloadValues = null)
{
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
if (row is null)
@ -1768,7 +1775,13 @@ public static class ConfigOptionsPageController
if (tooltip is not null)
menu.TooltipText = tooltip;
if (payloadValues is not null && payloadValues.Count != choiceKeys.Length)
throw new ArgumentException(
"Menu payload count must match the choice count.",
nameof(payloadValues));
string[] choiceLabels = new string[choiceKeys.Length];
int[] choiceValues = new int[choiceKeys.Length];
var items = new UiMenu.MenuItem[choiceKeys.Length];
for (int i = 0; i < choiceKeys.Length; i++)
{
@ -1779,7 +1792,8 @@ public static class ConfigOptionsPageController
$"[D.2b] ConfigOptionsPageController: menu choice '{choiceKeys[i]}' "
+ $"(for '{labelKey}') did not resolve — item renders with no caption "
+ "rather than invented English.");
items[i] = new UiMenu.MenuItem(choiceLabels[i], i);
choiceValues[i] = payloadValues?[i] ?? i;
items[i] = new UiMenu.MenuItem(choiceLabels[i], choiceValues[i]);
}
menu.Items = items;
@ -1788,7 +1802,8 @@ public static class ConfigOptionsPageController
menu.ButtonLabelProvider = () =>
{
int current = menu.Selected is int selected ? selected : initial;
return current >= 0 && current < choiceLabels.Length ? choiceLabels[current] : string.Empty;
int choiceIndex = Array.IndexOf(choiceValues, current);
return choiceIndex >= 0 ? choiceLabels[choiceIndex] : string.Empty;
};
var row_ = new IntOptionRow(

View file

@ -0,0 +1,433 @@
using System.Numerics;
namespace AcDream.App.UI.Layout;
internal sealed record CreditsUiResources(
uint LayoutId,
ImportedLayout PictureLayout,
ImportedLayout TextLayout,
IReadOnlyList<string> TextFragments,
IReadOnlyList<uint> PictureIds,
float SectionSeconds,
string PleaseWait);
/// <summary>
/// Retained-mode port of retail <c>gmCreditsUI @0x004E6E70..0x004E79E0</c>.
/// Retail composes two selected roots from layout enum <c>0x10000004</c>,
/// scrolls the localized <c>ID_Credits1..N</c> glyph block and a cyclic strip
/// of authored pictures by the same pixel delta, then returns to character
/// management on completion or any input action.
/// </summary>
internal sealed class CreditsUiController : IDisposable
{
internal const uint RootEnum = 0x10000004u;
internal const uint PictureRootElementId = 0x10000413u;
internal const uint TextRootElementId = 0x10000410u;
internal const uint TextAreaElementId = 0x10000411u;
internal const uint DynamicPictureElementId = 0x10000415u;
private readonly UiRoot _host;
private readonly ImportedLayout _pictureLayout;
private readonly ImportedLayout _textLayout;
private readonly UiText _textArea;
private readonly IReadOnlyList<string> _textFragments;
private readonly IReadOnlyList<uint> _pictureIds;
private readonly float _sectionSeconds;
private readonly RetailDialogFactory _dialogs;
private readonly string _pleaseWait;
private readonly Func<double> _nowSeconds;
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly Action _returnToCharacterManagement;
private readonly CreditsActionSurface _actionSurface;
private readonly List<UiPanel> _pictures = [];
private readonly Vector2 _authoredCanvas;
private UiText.Line[] _lines = [];
private float _textHeight;
private double _startTime;
private double _duration;
private float _lastProgress;
private int _nextPicture;
private long _tickSequence;
private long _returnAtTick = long.MaxValue;
private uint _waitContext;
private bool _active;
private bool _returnPending;
private bool _disposed;
private CreditsUiController(
UiRoot host,
ImportedLayout pictureLayout,
ImportedLayout textLayout,
UiText textArea,
IReadOnlyList<string> textFragments,
IReadOnlyList<uint> pictureIds,
float sectionSeconds,
RetailDialogFactory dialogs,
string pleaseWait,
Func<double> nowSeconds,
Func<uint, (uint tex, int w, int h)> resolveSprite,
Action returnToCharacterManagement)
{
_host = host;
_pictureLayout = pictureLayout;
_textLayout = textLayout;
_textArea = textArea;
_textFragments = textFragments;
_pictureIds = pictureIds;
_sectionSeconds = sectionSeconds;
_dialogs = dialogs;
_pleaseWait = pleaseWait;
_nowSeconds = nowSeconds;
_resolveSprite = resolveSprite;
_returnToCharacterManagement = returnToCharacterManagement;
float width = MathF.Max(
PictureRoot.Left + PictureRoot.Width,
TextRoot.Left + TextRoot.Width);
float height = MathF.Max(
PictureRoot.Top + PictureRoot.Height,
TextRoot.Top + TextRoot.Height);
_authoredCanvas = new Vector2(
width > 0f ? width : 800f,
height > 0f ? height : 600f);
PictureRoot.Visible = false;
TextRoot.Visible = false;
_actionSurface = new CreditsActionSurface(BeginReturn)
{
Width = _authoredCanvas.X,
Height = _authoredCanvas.Y,
Visible = false,
ZOrder = int.MaxValue,
};
}
internal UiElement PictureRoot => _pictureLayout.Root;
internal UiElement TextRoot => _textLayout.Root;
internal UiText TextArea => _textArea;
internal IReadOnlyList<UiPanel> Pictures => _pictures;
internal bool IsActive => _active;
internal double DurationSeconds => _duration;
internal static CreditsUiController? CreateDetached(
UiRoot host,
CreditsUiResources resources,
RetailDialogFactory dialogs,
Func<double> nowSeconds,
Func<uint, (uint tex, int w, int h)> resolveSprite,
Action returnToCharacterManagement)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(resources);
ArgumentNullException.ThrowIfNull(dialogs);
ArgumentNullException.ThrowIfNull(nowSeconds);
ArgumentNullException.ThrowIfNull(resolveSprite);
ArgumentNullException.ThrowIfNull(returnToCharacterManagement);
if (resources.PictureLayout.Root.DatElementId != PictureRootElementId
|| resources.TextLayout.Root.DatElementId != TextRootElementId
|| resources.TextLayout.FindElement(TextAreaElementId) is not UiText textArea
|| resources.TextFragments.Count == 0
|| resources.PictureIds.Count == 0
|| !float.IsFinite(resources.SectionSeconds)
|| resources.SectionSeconds <= 0f)
{
Console.WriteLine(
"[UI] credits: authored root/text/picture contract is incomplete.");
return null;
}
return new CreditsUiController(
host,
resources.PictureLayout,
resources.TextLayout,
textArea,
resources.TextFragments,
resources.PictureIds,
resources.SectionSeconds,
dialogs,
resources.PleaseWait,
nowSeconds,
resolveSprite,
returnToCharacterManagement);
}
internal void Activate()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_active)
return;
AttachRoots();
ResetRun();
_active = true;
PictureRoot.Visible = true;
TextRoot.Visible = true;
_actionSurface.Visible = true;
_host.DeclareFixedCanvas(this, _authoredCanvas);
_host.BringToFront(PictureRoot);
_host.BringToFront(TextRoot);
_host.BringToFront(_actionSurface);
_host.SetKeyboardFocus(_actionSurface);
// Update order is retail's: ScrollText first, then ScrollPictures.
// At progress zero that creates the first picture one pixel below
// the picture field, exactly as CreateAndAddPicture @0x004E7592.
Tick();
}
internal void Tick()
{
if (_disposed || !_active)
return;
_tickSequence++;
if (_returnPending)
{
if (_tickSequence >= _returnAtTick)
CompleteReturn();
return;
}
double elapsed = Math.Max(0d, _nowSeconds() - _startTime);
float progress = _duration <= 0d
? 1f
: Math.Clamp((float)(elapsed / _duration), 0f, 1f);
// Timer::compute_time is monotonic in retail. Preserve that invariant
// even when a deterministic test clock is moved backwards.
progress = MathF.Max(progress, _lastProgress);
_lastProgress = progress;
float fieldHeight = TextRoot.Height;
int oldTop = (int)MathF.Round(_textArea.Top);
int travel = (int)MathF.Round(
(fieldHeight + _textHeight) * progress,
MidpointRounding.ToEven);
int newTop = (int)MathF.Round(fieldHeight) - travel;
_textArea.Left = 0f;
_textArea.Top = newTop;
ScrollPictures(oldTop - newTop);
if (progress >= 1f)
BeginReturn();
}
internal void ResetSession()
{
if (_disposed || !_active)
return;
Deactivate();
_returnToCharacterManagement();
}
public void Dispose()
{
if (_disposed)
return;
bool restoreCharacterManagement = _active;
_disposed = true;
Deactivate();
_host.RemoveChild(PictureRoot);
_host.RemoveChild(TextRoot);
_host.RemoveChild(_actionSurface);
if (restoreCharacterManagement)
_returnToCharacterManagement();
}
private void AttachRoots()
{
if (PictureRoot.Parent is null)
_host.AddChild(PictureRoot);
if (TextRoot.Parent is null)
_host.AddChild(TextRoot);
if (_actionSurface.Parent is null)
_host.AddChild(_actionSurface);
}
private void ResetRun()
{
CloseWait();
ClearPictures();
_returnPending = false;
_returnAtTick = long.MaxValue;
_lastProgress = 0f;
_nextPicture = 0;
_textArea.Width = TextRoot.Width;
float maximumWidth = Math.Max(
1f,
_textArea.Width
- (_textArea.Padding + _textArea.MarginLeft)
- (_textArea.Padding + _textArea.MarginRight));
Func<string, float> measure = _textArea.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
string allText = string.Concat(_textFragments);
IReadOnlyList<string> wrapped = UiText.WrapWords(
allText,
measure,
maximumWidth);
if (wrapped.Count == 0)
wrapped = [string.Empty];
_lines = [.. wrapped.Select(
line => new UiText.Line(line, _textArea.DefaultColor))];
_textArea.LinesProvider = () => _lines;
float lineHeight = _textArea.DatFont?.LineHeight ?? 16f;
_textHeight = Math.Max(lineHeight, lineHeight * _lines.Length);
_textArea.Left = 0f;
_textArea.Top = TextRoot.Height;
_textArea.Height = _textHeight;
// Initialize @0x004E726C. The loop cursor is N+1 when the first
// invalid ID_Credits key terminates enumeration, so preserve that
// exact denominator rather than substituting the valid count.
float terminatorIndex = _textFragments.Count + 1f;
_duration = _sectionSeconds
* (TextRoot.Height + _textHeight)
/ (TextRoot.Height + _textHeight / terminatorIndex);
_startTime = _nowSeconds();
}
private void ScrollPictures(int deltaPixels)
{
if (deltaPixels != 0)
foreach (UiPanel picture in _pictures)
picture.Top -= deltaPixels;
if (_pictures.Count > 0
&& _pictures[0].Top + _pictures[0].Height < 0f)
{
UiPanel expired = _pictures[0];
_pictures.RemoveAt(0);
PictureRoot.RemoveChild(expired);
}
if (_pictures.Count == 0)
AddPicture();
if (_pictures.Count > 0
&& _pictures[^1].Top < PictureRoot.Height)
{
AddPicture();
}
}
private void AddPicture()
{
if (_pictureIds.Count == 0)
return;
uint pictureId = _pictureIds[_nextPicture];
_nextPicture = (_nextPicture + 1) % _pictureIds.Count;
(uint texture, int width, int height) = _resolveSprite(pictureId);
if (texture == 0u || width <= 0 || height <= 0)
return;
float top = _pictures.Count == 0
? PictureRoot.Height + 1f
: _pictures[^1].Top + _pictures[^1].Height + 1f;
var picture = new UiPanel
{
DatElementId = DynamicPictureElementId,
Left = 0f,
Top = top,
Width = width,
Height = height,
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
BorderThickness = 0f,
BackgroundSprite = pictureId,
SpriteResolve = _resolveSprite,
ClickThrough = true,
};
PictureRoot.AddChild(picture);
_pictures.Add(picture);
}
private void BeginReturn()
{
if (_disposed || !_active || _returnPending)
return;
_returnPending = true;
_waitContext = _dialogs.MakeWait(_pleaseWait);
// QueueUIMode is asynchronous in retail. Keep the wait visible for one
// complete presented frame, then perform the local mode swap.
_returnAtTick = _tickSequence + 2;
}
private void CompleteReturn()
{
if (!_active)
return;
Deactivate();
_returnToCharacterManagement();
}
private void Deactivate()
{
_returnPending = false;
_returnAtTick = long.MaxValue;
_active = false;
PictureRoot.Visible = false;
TextRoot.Visible = false;
_actionSurface.Visible = false;
if (ReferenceEquals(_host.KeyboardFocus, _actionSurface))
_host.SetKeyboardFocus(null);
_host.RevokeFixedCanvas(this);
CloseWait();
ClearPictures();
}
private void CloseWait()
{
uint context = _waitContext;
_waitContext = 0u;
if (context != 0u)
_dialogs.CloseDialog(context);
}
private void ClearPictures()
{
foreach (UiPanel picture in _pictures)
PictureRoot.RemoveChild(picture);
_pictures.Clear();
}
private sealed class CreditsActionSurface : UiElement
{
private readonly Action _onAction;
public override bool HandlesClick => true;
public CreditsActionSurface(Action onAction)
{
_onAction = onAction ?? throw new ArgumentNullException(nameof(onAction));
AcceptsFocus = true;
ClickThrough = false;
}
public override bool OnEvent(in UiEvent e)
{
if (!Enabled || !Visible)
return false;
if (e.Type is UiEventType.KeyDown
or UiEventType.MouseDown
or UiEventType.RightDown
or UiEventType.MiddleDown
or UiEventType.Scroll)
{
_onAction();
return true;
}
return e.Type is UiEventType.KeyUp
or UiEventType.MouseUp
or UiEventType.RightUp
or UiEventType.MiddleUp
or UiEventType.Click
or UiEventType.RightClick;
}
}
}

View file

@ -706,10 +706,11 @@ public static class DatWidgetFactory
if (state.Properties.Values.TryGetValue(0x14u, out var justify)
&& justify.Kind == UiPropertyKind.Enum)
{
align = justify.UnsignedValue switch
align = ElementReader.MapHorizontalJustification(
justify.UnsignedValue) switch
{
0u or 2u => UiMeterLabelAlign.Left,
3u or 5u => UiMeterLabelAlign.Right,
HJustify.Left => UiMeterLabelAlign.Left,
HJustify.Right => UiMeterLabelAlign.Right,
_ => UiMeterLabelAlign.Center,
};
}
@ -893,12 +894,7 @@ public static class DatWidgetFactory
// afterward will override these — this is only the dat-driven default.
bool centered = info.HJustify == HJustify.Center;
bool rightAligned = info.HJustify == HJustify.Right;
var vJustify = info.VJustify switch
{
VJustify.Top => VJustify.Top,
VJustify.Bottom => VJustify.Bottom,
_ => VJustify.Center,
};
var vJustify = info.VJustify;
var t = new UiText
{

View file

@ -14,7 +14,8 @@ public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
/// <summary>
/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
/// Values: 2=Top, 4=Bottom; absent/other = Center.
/// Retail <c>CalcJustification @ 0x00467260</c> treats 1 as Center,
/// 3/5 as Bottom, and every other value (including constructor default 4) as Top.
/// </summary>
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
@ -109,18 +110,17 @@ public sealed class ElementInfo
/// <summary>
/// Horizontal text justification from dat <c>Properties[0x14]</c>
/// (<c>EnumBaseProperty</c>: 0=Left, 1=Center, 3/5=Right).
/// Default is <see cref="HJustify.Center"/> to preserve existing behavior where
/// controllers set <c>Centered=true</c> and no property was read.
/// (<c>EnumBaseProperty</c>: 1=Center, 3/5=Right, all others=Left).
/// Retail's constructor default is raw 2, which resolves to Left.
/// </summary>
public HJustify HJustify = HJustify.Center;
public HJustify HJustify = HJustify.Left;
/// <summary>
/// Vertical text justification from dat <c>Properties[0x15]</c>
/// (<c>EnumBaseProperty</c>: 2=Top, 4=Bottom; absent/other = Center).
/// Default is <see cref="VJustify.Center"/> to preserve existing behavior.
/// (<c>EnumBaseProperty</c>: 1=Center, 3/5=Bottom, all others=Top).
/// Retail's constructor default is raw 4, which resolves to Top.
/// </summary>
public VJustify VJustify = VJustify.Center;
public VJustify VJustify = VJustify.Top;
/// <summary>
/// Font color from dat <c>Properties[0x1B]</c> (<c>ColorBaseProperty</c>, ARGB bytes).
@ -251,13 +251,8 @@ public sealed class ElementInfo
/// an authored <c>true</c> HIDES the element at construction. Populated the
/// same way as <see cref="TabTable"/>/<see cref="ScrollbarElementId"/>
/// (recomputed fresh from the effective merged state every call), but this
/// is a PURE DATA ADDITION: the shared <see cref="LayoutImporter"/>/
/// <see cref="DatWidgetFactory"/> path does not act on it. 1,083 elements
/// author this flag client-wide (docs/ISSUES.md #408, its own separately-
/// gated general-honor item) — only screens that explicitly walk their own
/// mounted subtree and check this field may hide elements by it (see
/// <c>CharacterCreationUiController</c>'s chargen-scoped honor, register
/// AP-230).
/// feeds the shared <see cref="LayoutImporter"/>, which applies the retail
/// construction-time visibility write uniformly to every built widget.
/// </summary>
public bool Invisible;
@ -583,12 +578,11 @@ public static class ElementReader
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
// (the dat property was present and read); otherwise inherit the base prototype's value.
// Center is the default (= "not set by this element") so Center-derived never overrides
// a non-Center base — matching the FontDid "non-zero wins" convention.
HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
// Presence, not a semantic enum value, decides inheritance. Center
// is a legitimate authored override; using it as an "unset"
// sentinel silently lost derived raw value 1.
HJustify = HasEffectiveEnum(derived, 0x14u) ? derived.HJustify : base_.HJustify,
VJustify = HasEffectiveEnum(derived, 0x15u) ? derived.VJustify : base_.VJustify,
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
@ -650,23 +644,13 @@ public static class ElementReader
if (info.TryGetEffectiveProperty(0x14u, out var horizontal)
&& horizontal.Kind == UiPropertyKind.Enum)
{
info.HJustify = horizontal.UnsignedValue switch
{
0u or 2u => HJustify.Left,
3u or 5u => HJustify.Right,
_ => HJustify.Center,
};
info.HJustify = MapHorizontalJustification(horizontal.UnsignedValue);
}
if (info.TryGetEffectiveProperty(0x15u, out var vertical)
&& vertical.Kind == UiPropertyKind.Enum)
{
info.VJustify = vertical.UnsignedValue switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
info.VJustify = MapVerticalJustification(vertical.UnsignedValue);
}
if (info.TryGetEffectiveProperty(0x1Bu, out var color))
@ -820,6 +804,24 @@ public static class ElementReader
info.MinHeight = minHeight;
}
internal static HJustify MapHorizontalJustification(ulong raw) => raw switch
{
1UL => HJustify.Center,
3UL or 5UL => HJustify.Right,
_ => HJustify.Left,
};
internal static VJustify MapVerticalJustification(ulong raw) => raw switch
{
1UL => VJustify.Center,
3UL or 5UL => VJustify.Bottom,
_ => VJustify.Top,
};
private static bool HasEffectiveEnum(ElementInfo info, uint propertyId) =>
info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value)
&& value.Kind == UiPropertyKind.Enum;
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
{
var entries = new List<UiTabTableEntry>();

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
@ -31,15 +32,11 @@ namespace AcDream.App.UI.Layout;
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
/// action). The House-tab ownership-text closer session (also 2026-08-17)
/// wired <see cref="Bindings.Lines"/> to the minimal <c>RuntimeHouseState</c>
/// owner and ported <c>DisplayPurchaseTimeText @0x004a3110</c>'s expired
/// branch — a fresh houseless character's House tab shows the single
/// decomp-verified line "You may buy another house immediately.",
/// live-connected-gate-verified (screenshot + structural UI-tree dump
/// against the real <c>+Acdream</c> character on a local ACE server). The
/// other six <c>Display*</c> line builders <c>DisplayHouseData</c> calls
/// (owned-house-only content: buy/rent payments and times, location,
/// warning text) remain unported — ISSUES #413's surviving scope. The
/// wired <see cref="Bindings.Lines"/> to <c>RuntimeHouseState</c>; issue
/// #413 later completed all seven <c>Display*</c> builders for both
/// houseless and owned-house snapshots. <see cref="Bindings.PanelLines"/>
/// preserves retail's Normal/RentPaid/RentNotPaid font-palette index while
/// the original string callback remains a compatibility fallback. The
/// night-round review (F2, 2026-08-17) moved the outbound HouseQuery send
/// from a House-tab-open trigger to retail's real login-complete edge (see
/// <see cref="Bindings.OnShown"/>'s own doc), so by the time a player opens
@ -78,11 +75,15 @@ public sealed class HousePageController
// always returns null (no resolver = no row), so Refresh silently
// produced zero rows regardless of Lines — the gap this session
// closes alongside the text composition itself.
Func<uint, uint, UiElement?>? TemplateResolver = null);
Func<uint, uint, UiElement?>? TemplateResolver = null,
// Issue #413: typed rows preserve retail's HousePanelTextColor
// palette index. The string-only callback remains for compatibility
// with standalone fixtures and older embedding callers.
Func<IReadOnlyList<HousePanelLine>>? PanelLines = null);
private readonly UiTemplateListBox _listBox;
private readonly Bindings _bindings;
private IReadOnlyList<string> _lastLines = Array.Empty<string>();
private IReadOnlyList<HousePanelLine> _lastLines = Array.Empty<HousePanelLine>();
private HousePageController(UiTemplateListBox listBox, Bindings bindings)
{
@ -104,7 +105,7 @@ public sealed class HousePageController
listBox.TemplateResolver = bindings.TemplateResolver;
var controller = new HousePageController(listBox, bindings);
controller.Refresh(bindings.Lines());
controller.Refresh(controller.CurrentLines());
return controller;
}
@ -113,22 +114,43 @@ public sealed class HousePageController
/// other social-panel pages' revision-gated rebuild discipline).</summary>
public void Tick()
{
IReadOnlyList<string> lines = _bindings.Lines();
IReadOnlyList<HousePanelLine> lines = CurrentLines();
if (lines.SequenceEqual(_lastLines)) return;
Refresh(lines);
}
public void OnShown() => _bindings.OnShown?.Invoke();
private void Refresh(IReadOnlyList<string> lines)
private IReadOnlyList<HousePanelLine> CurrentLines()
{
if (_bindings.PanelLines is { } styled)
return styled();
IReadOnlyList<string> plain = _bindings.Lines();
if (plain.Count == 0)
return Array.Empty<HousePanelLine>();
var projected = new HousePanelLine[plain.Count];
for (int i = 0; i < plain.Count; i++)
projected[i] = new HousePanelLine(plain[i], HousePanelTextColor.Normal);
return projected;
}
private void Refresh(IReadOnlyList<HousePanelLine> lines)
{
_lastLines = lines;
_listBox.Flush();
foreach (string line in lines)
foreach (HousePanelLine line in lines)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is UiText text)
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
{
int colorIndex = (int)line.Color;
Vector4 color = colorIndex >= 0 && colorIndex < text.FontColorPalette.Count
? text.FontColorPalette[colorIndex]
: text.DefaultColor;
text.LinesProvider = () => [new UiText.Line(line.Text, color)];
}
}
}
}

View file

@ -128,9 +128,13 @@ public static class LayoutImporter
// #409: see the Build overload's own sourceLayoutDid doc comment.
w.SourceLayoutDid = sourceLayoutDid;
// GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
// doc comment for why this does NOT set Visible here.
// #408: retail applies P0x3B through UIElement::OnSetAttribute case 8
// for every element: Invisible=true means SetVisible(false). Keep the
// authored bit for diagnostics while making its initial behavior a
// property of the shared importer, not of individual screens.
w.AuthoredInvisible = info.Invisible;
if (info.Invisible)
w.Visible = false;
// #409: the six per-element tooltip properties, same pure-data-
// passthrough shape as AuthoredInvisible above. TooltipText is the
@ -223,19 +227,6 @@ public static class LayoutImporter
if (child.StateMedia.Count == 0) continue;
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is null) continue;
// F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor
// of AuthoredInvisible, scoped to children reached through
// THIS carve-out only — e.g. the chat new-text indicator
// (0x1000048C, live-DAT-confirmed Invisible=true on every
// layout it appears in) would otherwise render as a phantom
// element retail never shows, now that this carve-out
// builds it as a real widget instead of silently dropping
// it. This is NOT the general client-wide honor (#408,
// 1,083 elements) — every OTHER AuthoredInvisible consumer
// stays data-only, acted on nowhere but chargen's own
// HideAuthoredInvisibleElements walk (register AP-230).
if (cw.AuthoredInvisible)
cw.Visible = false;
w.AddChild(cw);
}
}
@ -395,9 +386,9 @@ public static class LayoutImporter
/// (the character footer's three state-groups; the tab-page content areas) manage
/// visibility purely at runtime via C++ controller code. Retail uses
/// <c>UIElement::SetState(stateId)</c> on the parent to propagate state, then
/// C++ getters access the right sub-group by element id. All groups are shipped
/// as visible in the imported widget tree; the relevant controllers
/// (<see cref="CharacterStatController"/>) perform the initial show/hide.
/// C++ getters access the right sub-group by element id. Sibling groups which
/// do not author property 0x3B start visible; the relevant controllers
/// (<see cref="CharacterStatController"/>) perform their runtime show/hide.
/// </para>
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver (see
@ -677,38 +668,6 @@ public static class LayoutImporter
if (sd.Properties is not null)
{
// HorizontalJustification (0x14): EnumBaseProperty.
// Retail CalcJustification @ 0x00467260: 1=Center, 3/5=Right,
// every other value (including constructor default 2)=Left.
// Only update if still at the default (Center); derived-wins handled in Merge.
if (info.HJustify == HJustify.Center
&& sd.Properties.TryGetValue(0x14u, out var hRaw)
&& hRaw is EnumBaseProperty hEnum)
{
info.HJustify = hEnum.Value switch
{
0u or 2u => HJustify.Left,
1u => HJustify.Center,
3u => HJustify.Right,
5u => HJustify.Right,
_ => HJustify.Left,
};
}
// VerticalJustification (0x15): EnumBaseProperty.
// Retail values: 2=Top, 4=Bottom; absent/other = Center.
if (info.VJustify == VJustify.Center
&& sd.Properties.TryGetValue(0x15u, out var vRaw)
&& vRaw is EnumBaseProperty vEnum)
{
info.VJustify = vEnum.Value switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
// ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A).
// Only read when not already set (first dat state wins; Merge propagates from base).
if (info.FontColor is null

View file

@ -652,13 +652,8 @@ public sealed class RetailTooltipPresenter : IDisposable
// elements author P0x3D" sweep only covered hover TARGETS, never
// the popup skins' text children.)
// (b) Tooltip text is LEFT-aligned: the text child authors no
// justification and retail's unauthored default is Left, while
// our importer's ElementInfo default is Center — the same
// wrong-default class as #410's VJustify finding. Point-fixed
// here (the chat transcript does the same); the client-wide
// default remains #410's scope.
text.Centered = false;
text.RightAligned = false;
// justification, so the shared importer supplies retail's raw-2
// constructor default (Left).
// (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on
// the popup skins) participate exactly as InqSizewMargins does:

View file

@ -85,6 +85,7 @@ public class UiDatElement : UiElement, IUiDatStateful
public bool TrySetRetailState(uint stateId)
{
uint appliedStateId = stateId;
UiStateInfo? selectedState = null;
if (stateId == UiStateInfo.DirectStateId)
{
@ -114,23 +115,15 @@ public class UiDatElement : UiElement, IUiDatStateful
// Normal_rollover/Highlight media but NO 'Normal' state at
// all, so the row's PassToChildren 'Normal' hover-leave
// cascade landed here and the bars never cleared. Retail's
// state-0 arm cascades state 0 to children off the BASE
// descriptor's own PassToChildren (m_desc.m_bPassToChildren,
// @0x00464eca), and the per-state Invisible honor below stays
// scoped to NAMED authored states exactly as before (the #408
// gate) — selectedState remains null on this path.
// state-0 arm applies and cascades the BASE descriptor.
ActiveState = "";
if (Info.States.TryGetValue(
UiStateInfo.DirectStateId, out UiStateInfo? baseState)
&& baseState.PassToChildren)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(UiStateInfo.DirectStateId);
}
return true;
appliedStateId = UiStateInfo.DirectStateId;
Info.States.TryGetValue(appliedStateId, out selectedState);
}
else
{
ActiveState = stateName;
}
ActiveState = stateName;
}
// Per-state Invisible (dat property 0x3B): retail's SetState applies
@ -144,21 +137,11 @@ public class UiDatElement : UiElement, IUiDatStateful
// Normal_rollover={0x3B:false}, i.e. hidden at rest, shown on
// rollover.
//
// SCOPED TO NAMED STATES ONLY: a 0x3B authored in the unnamed
// DirectState is the CONSTRUCTION-time "authored invisible" class
// (1,083 elements client-wide — docs/ISSUES.md #408, its own
// separately-gated general-honor item; ElementReader.Invisible/GF-13
// captures it and only chargen's scoped walk acts on it, register
// AP-230). Honoring it here would un-gate #408 through the back
// door: LayoutImporter.BuildWidget's post-children state reapply
// calls TrySetRetailState(DirectStateId) on every built widget, so
// a DirectState honor would hide all 1,083 at import (measured
// same-round: 10 combat-layout elements incl. 0x10000454 went
// un-hit-testable, breaking the spell-favorite drag tests). The
// NAMED-state flip below is a live visibility state machine that
// cannot work at all without the honor — that is this port's line.
if (stateId != UiStateInfo.DirectStateId
&& selectedState is not null
// #408: DirectState is not a special exception. It reaches the same
// OnSetAttribute switch during construction and whenever retail falls
// back to state 0, so it must be able to restore authored visibility
// after a named state changed it.
if (selectedState is not null
&& selectedState.Properties.TryGetValue(0x3Bu, out var invisibleProp)
&& invisibleProp.Kind == UiPropertyKind.Bool)
Visible = !invisibleProp.BoolValue;
@ -167,7 +150,7 @@ public class UiDatElement : UiElement, IUiDatStateful
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
stateful.TrySetRetailState(appliedStateId);
}
return true;
}

View file

@ -169,8 +169,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// the absent authored sprite exactly rather than inventing one.
/// </para>
/// <para>
/// <b>G5 correction (vendor gate finding): it is a SCROLLABLE single
/// column, not a 3-column grid.</b> The F1 review's "column-major grid"
/// <b>G5 correction (vendor gate finding): it is a single scrollable
/// ListBox, not a 3-column grid.</b> The F1 review's "column-major grid"
/// framing was wrong — a live-dat scan (<c>tools/VendorLayoutScan</c>,
/// <c>dump</c>/<c>resolved 0x21000043 0x1000034F</c>) shows
/// <c>0x1000034F</c> has TWO children, not one: the ListBox
@ -183,15 +183,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <c>0x06004C60</c>/<c>63</c>/<c>66</c>, up button (element
/// <c>0x10000072</c>, retail-seated on top) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>, down button
/// (element <c>0x10000071</c>, retail-seated on the bottom) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>,
/// track <c>0x06004C5F</c>). With 18 authored categories and only 6
/// visible rows, retail's actual rendering is a single scrolling column
/// (matching the user's reference screenshot: ~visible rows + scrollbar +
/// highlight — not our earlier 3-column x 6-row grid showing all 18 at
/// once). <see cref="UiMenu.Scrollable"/> switches the popup to this
/// shape; <see cref="UiMenu.RowsPerColumn"/> keeps its existing meaning
/// as the authored visible-row count (still 6 — 108px ListBox height /
/// 18px row height, now interpreted as "rows before scrolling" instead
/// of "rows before wrapping to a new column"). Chat's own popup
/// track <c>0x06004C5F</c>). The later named-retail trace resolves the
/// apparent fixed-six-row ambiguity: <c>OpenVendor</c> inserts only present
/// categories, <c>UIElement_ListBox::UpdateLayout @0x0046e460</c> sums their
/// row heights, <c>ResizeScrollableArea</c> broadcasts message <c>0x32</c>,
/// and <c>UIElement_Menu::RecalculatePopupSize @0x0046caf0</c> resizes this
/// four-edge-docked popup to that content, uncapped. The sibling scrollbar's
/// installed-DAT property <c>0x79=true</c> hides it when the resized content
/// fits. <see cref="UiMenu.Scrollable"/> keeps the authored single-column
/// structure while <see cref="UiMenu.PopupSizeToContent"/> and
/// <see cref="UiMenu.PopupScrollbarHideWhenDisabled"/> port those two retail
/// behaviors. Chat's own popup
/// (LayoutDesc <c>0x21000006</c>, element <c>0x1000001C</c>) has NO
/// sibling scrollbar element and is unaffected —
/// <see cref="ChatWindowController"/> never sets <c>Scrollable</c>, so
@ -572,6 +574,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// scrollbar, not a column-major grid — see the class doc's "G5
// correction" paragraph above.
_typeMenu.Scrollable = true;
_typeMenu.PopupSizeToContent = true;
_typeMenu.PopupScrollbarHideWhenDisabled = true;
_typeMenu.ScrollbarWidth = TypeMenuScrollbarWidth;
_typeMenu.ScrollButtonExtent = TypeMenuScrollButtonExtent;
_typeMenu.ScrollTrackSprite = TypeMenuScrollTrackSprite;

View file

@ -321,7 +321,8 @@ public sealed record SocialRuntimeBindings(
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the
/// two-tab Map/House panel. <see cref="HousePosition"/> defaults to
/// "no house" and <see cref="HouseLines"/> to empty when the caller doesn't
/// "no house" and <see cref="HouseLines"/>/<see cref="HousePanelLines"/>
/// to empty when the caller doesn't
/// wire the House wire groundwork — the panel still mounts and the Map tab
/// still works standalone.
/// </summary>
@ -330,7 +331,8 @@ public sealed record MapHouseRuntimeBindings(
Func<uint> PlayerCellId,
Func<CreateObject.ServerPosition?>? HousePosition = null,
Func<IReadOnlyList<string>>? HouseLines = null,
Action? HouseShown = null);
Action? HouseShown = null,
Func<IReadOnlyList<AcDream.Runtime.Gameplay.HousePanelLine>>? HousePanelLines = null);
/// <summary>
/// Campaign QT slice QT5: what the Journal panel's Contracts page reads —
@ -559,6 +561,7 @@ public sealed class RetailUiRuntime : IDisposable
private ProjectileDebugOverlayController? _projectileDebugOverlay;
private Layout.VitalsSideBySideController? _vitalsSideBySide;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CreditsUiController? _creditsController;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private PluginSidePanel? _pluginSidePanel;
private IDisposable? _characterSheetSubscription;
@ -780,6 +783,7 @@ public sealed class RetailUiRuntime : IDisposable
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CreditsUiController? CreditsController => _creditsController;
internal CharacterCreationUiController? CharacterCreationController =>
_characterCreationMount?.Controller;
@ -964,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
_creditsController?.Tick();
_characterCreationMount?.Tick();
CharacterCreationController?.Tick();
DialogFactory?.Tick();
@ -1359,6 +1364,7 @@ public sealed class RetailUiRuntime : IDisposable
{
try
{
_creditsController?.ResetSession();
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
TooltipPresenter?.HideCurrent();
@ -4173,7 +4179,8 @@ public sealed class RetailUiRuntime : IDisposable
// Same generic template resolver the Map tab's town
// hotspots use — see HousePageController.Bindings.
// TemplateResolver's own doc for why reusing it is correct.
TemplateResolver: ResolveHotspotTemplate));
TemplateResolver: ResolveHotspotTemplate,
PanelLines: mh.HousePanelLines));
Layout.MapHousePanelController? controller;
lock (_bindings.Assets.DatLock)
@ -5099,7 +5106,194 @@ public sealed class RetailUiRuntime : IDisposable
Host.Root,
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
EnsureDialogFactory,
LoadCharacterManagementResources);
LoadCharacterManagementResources,
OpenCredits);
}
private void OpenCredits()
{
if (_disposed)
return;
CharacterManagementUiController? characters =
CharacterManagementController;
if (characters is null)
return;
if (_creditsController is null)
{
RetailDialogFactory? dialogs = EnsureDialogFactory();
CreditsUiResources? resources = LoadCreditsResources();
if (dialogs is null || resources is null)
return;
_creditsController = CreditsUiController.CreateDetached(
Host.Root,
resources,
dialogs,
static () => System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency,
_bindings.Assets.ResolveSprite,
ReturnFromCredits);
if (_creditsController is null)
return;
}
characters.SetPresentationSuppressed(true);
try
{
_creditsController.Activate();
}
catch (Exception error)
{
characters.SetPresentationSuppressed(false);
Console.WriteLine(
$"[UI] credits activation failed: {error.Message}");
}
}
private void ReturnFromCredits()
=> CharacterManagementController?.SetPresentationSuppressed(false);
private CreditsUiResources? LoadCreditsResources()
{
uint layoutId;
ElementInfo? pictureInfo;
ElementInfo? textInfo;
ImportedLayout? pictureLayout;
ImportedLayout? textLayout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
CreditsUiController.RootEnum,
5u);
pictureInfo = layoutId == 0u
? null
: LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.PictureRootElementId);
textInfo = layoutId == 0u
? null
: LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.TextRootElementId);
pictureLayout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.PictureRootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
textLayout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.TextRootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (pictureInfo is null
|| textInfo is null
|| pictureLayout is null
|| textLayout is null
|| !TryGetCreditsDataId(
textInfo,
0x10000002u,
out uint textAreaId)
|| textAreaId != CreditsUiController.TextAreaElementId
|| !TryGetCreditsDataId(
textInfo,
0x10000003u,
out uint stringTableId)
|| !textInfo.TryGetEffectiveFloat(
0x10000004u,
out float sectionSeconds)
|| !pictureInfo.TryGetEffectiveProperty(
0x10000005u,
out UiPropertyValue pictureProperty)
|| pictureProperty.Kind != UiPropertyKind.Array)
{
Console.WriteLine(
"[UI] credits: enum-table-5 properties could not be imported.");
return null;
}
uint[] pictureIds =
[
.. pictureProperty.ArrayValue
.Where(static value => value.Kind is
UiPropertyKind.DataId or UiPropertyKind.Enum)
.Select(static value => checked((uint)value.UnsignedValue))
.Where(static value => value != 0u),
];
if (pictureIds.Length == 0)
return null;
var textFragments = new List<string>();
string? pleaseWait;
lock (_bindings.Assets.DatLock)
{
for (int index = 1; index <= 4096; index++)
{
string? fragment = strings.Resolve(
stringTableId,
DatStringResolver.ComputeHash($"ID_Credits{index}"));
if (fragment is null)
break;
textFragments.Add(fragment);
}
// MakePleaseWaitDialog @0x004E76F0 resolves table enum
// 0x10000001, the installed EoR table DID 0x23000001.
pleaseWait = strings.Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Wait_PleaseWait"));
}
if (textFragments.Count == 0 || pleaseWait is null)
{
Console.WriteLine(
"[UI] credits: localized credit/wait strings are unavailable.");
return null;
}
Console.WriteLine(
$"[UI] retail credits ready (layout 0x{layoutId:X8}, "
+ $"{textFragments.Count} text fragments, {pictureIds.Length} pictures). ");
return new CreditsUiResources(
layoutId,
pictureLayout,
textLayout,
textFragments,
pictureIds,
sectionSeconds,
pleaseWait);
}
private static bool TryGetCreditsDataId(
ElementInfo info,
uint propertyId,
out uint value)
{
if (info.TryGetEffectiveProperty(propertyId, out UiPropertyValue property)
&& property.Kind is UiPropertyKind.DataId or UiPropertyKind.Enum
&& property.UnsignedValue <= uint.MaxValue)
{
value = (uint)property.UnsignedValue;
return true;
}
value = 0u;
return false;
}
private RetailDialogFactory? EnsureDialogFactory()
@ -5392,6 +5586,7 @@ public sealed class RetailUiRuntime : IDisposable
() => _itemConfirmationController?.Dispose(),
() =>
{
_creditsController?.Dispose();
_characterManagementMount?.Dispose();
_characterCreationMount?.Dispose();
_gameplayConfirmationController?.Dispose();

View file

@ -385,11 +385,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight)
{
Selected = stateId == UiButtonStateMachine.Highlight;
ApplyStateVisibility(stateId);
return true;
}
if (stateId == UiButtonStateMachine.Ghosted)
{
Enabled = false;
ApplyStateVisibility(stateId);
return true;
}
if (!Enabled && stateId != UiButtonStateMachine.Ghosted)
@ -424,12 +426,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (!HasStateMedia(""))
return false;
ActiveState = "";
ApplyStateVisibility(stateId);
CascadeStateToChildren(stateId);
return true;
}
if (TryFindState(stateId, out var state))
{
ActiveState = state.Name;
ApplyStateVisibility(stateId);
CascadeStateToChildren(stateId);
return true;
}
@ -439,12 +443,28 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
{
ActiveState = stateName;
ApplyStateVisibility(stateId);
CascadeStateToChildren(stateId);
return true;
}
return false;
}
/// <summary>
/// #408: UIElement::SetState applies the committed descriptor's properties
/// through UIElement::OnSetAttribute for buttons too. Property 0x3B is the
/// shared Invisible switch; it is not a generic-container-only behavior.
/// </summary>
private void ApplyStateVisibility(uint stateId)
{
if (_info.States.TryGetValue(stateId, out var state)
&& state.Properties.Values.TryGetValue(0x3Bu, out var invisible)
&& invisible.Kind == UiPropertyKind.Bool)
{
Visible = !invisible.BoolValue;
}
}
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>

View file

@ -59,14 +59,11 @@ public abstract class UiElement
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
/// <c>ElementInfo.Invisible</c> (dat property <c>0x3B</c>) — a PURE DATA
/// PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c> at construction.
/// The shared importer does NOT act on this flag (1,083 elements author
/// it client-wide, docs/ISSUES.md #408); it exists only so a screen that
/// owns its own mounted subtree can honor it explicitly, the way
/// <c>CharacterCreationUiController</c> does for the chargen screen
/// (register AP-230). Reading this never changes <see cref="Visible"/> by
/// itself.
/// <c>ElementInfo.Invisible</c> (dat property <c>0x3B</c>), retained for
/// diagnostics after <c>LayoutImporter.BuildWidget</c> applies retail's
/// construction-time <c>SetVisible(value == 0)</c> behavior client-wide.
/// Runtime controllers may subsequently call <see cref="Visible"/> just
/// as retail may issue a later <c>SetVisible</c>.
/// </summary>
public bool AuthoredInvisible { get; internal set; }

View file

@ -85,21 +85,20 @@ public sealed class UiMenu : UiElement
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
/// <summary>
/// G5 (vendor gate finding): retail's authored vendor category popup
/// Retail's authored vendor category popup
/// (LayoutDesc <c>0x21000043</c>, root <c>0x1000034F</c>) pairs its
/// ListBox (element <c>0x10000350</c>, type <c>0x5</c>) with a SIBLING
/// <c>UIElement_Scrollbar</c> (element <c>0x10000351</c>, type <c>0xB</c>,
/// 16px wide, docked immediately right of the list at x=100) — verified
/// via a live-dat scan (<c>tools/VendorLayoutScan</c>) against
/// <c>client_local_English.dat</c>: the ListBox reads a single-column
/// shape (attributes resolving to <c>m_nCols=1</c>/<c>m_nRows=6</c>) and
/// the row template (<c>0x10000352</c>) is 100×18 — a SCROLLABLE single
/// column with 6 visible rows, not our earlier column-major grid
/// approximation (which showed all 18 categories at once across 3
/// columns, never matching the retail screenshot's ~one-column-with-
/// scrollbar look). <see cref="RowsPerColumn"/> becomes the VISIBLE ROW
/// COUNT in this mode (still authored-driven — 108px ListBox height / 18px
/// row height = 6). Chat's own popup (LayoutDesc <c>0x21000006</c>) has
/// via installed-DAT inspection against <c>client_local_English.dat</c>.
/// The authored 100x108 ListBox starts with a six-row viewport, but retail
/// <c>UIElement_ListBox::UpdateLayout @0x0046e460</c> resizes its scrollable
/// content to the number of inserted categories. Because all four edges are
/// docked, message <c>0x32</c> reaches
/// <c>UIElement_Menu::RecalculatePopupSize @0x0046caf0</c> and grows or
/// shrinks the popup to that content. The sibling scrollbar authors property
/// <c>0x79=true</c>, so it disappears once the resized viewport fits the
/// content. Chat's own popup (LayoutDesc <c>0x21000006</c>) has
/// NO sibling scrollbar element and keeps the class default false — the
/// legacy column-major grid path below is untouched for it.
/// </summary>
@ -140,6 +139,24 @@ public sealed class UiMenu : UiElement
public uint ScrollUpSprite { get; set; }
public uint ScrollDownSprite { get; set; }
/// <summary>
/// Retail scrollbar property <c>0x79</c> for the popup's authored sibling
/// scrollbar. When the content fits and the scrollbar is therefore disabled,
/// hide the sibling completely: no chrome, pointer target, or reserved width.
/// Vendor popup element <c>0x10000351</c> authors this true. Retail owns a real
/// sibling widget and calls <c>SetVisible(false)</c> from
/// <c>UIElement_Scrollbar::UpdateLayout @0x004710d0</c>; this procedural popup
/// must also remove the sibling's width from its flattened geometry to produce
/// the same visible result.
/// </summary>
public bool PopupScrollbarHideWhenDisabled { get; set; }
/// <summary>Presentation projection shared by drawing and pointer dispatch.
/// Internal for the same focused-test purpose as
/// <see cref="UiScrollbar.IsPresentationVisible"/>.</summary>
internal bool IsPopupScrollbarPresentationVisible
=> !PopupScrollbarHideWhenDisabled || PopupContentOverflows;
private bool _draggingPopupThumb;
private float _popupThumbDragOffset;
@ -252,9 +269,8 @@ public sealed class UiMenu : UiElement
/// popup ListBox (0x21000043/0x10000358) reads edges L=T=R=B=1
/// (menuprobe3, <c>OptionsPanelLiveMountProbeTests</c>), so
/// <see cref="AcDream.App.UI.Layout.ConfigOptionsPageController"/> sets
/// this true; chat's grid popup and vendor's shipped 6-row window keep
/// the class default false (vendor's authored ListBox is ALSO docked —
/// tracked as its own issue, not silently reworked here).
/// this true. Vendor's ListBox has the same four docked edges and therefore
/// enables it too; chat's grid popup keeps the class default false.
/// When set, <see cref="RowsPerColumn"/> stops being the visible-window
/// height and the popup shows every item with no scroll overflow.
/// </summary>
@ -333,12 +349,14 @@ public sealed class UiMenu : UiElement
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
// Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window,
// not a wrap threshold), widened by the docked scrollbar's own authored width.
// not a wrap threshold), widened by the docked scrollbar only while that
// sibling is presentation-visible. Retail property 0x79 removes a disabled
// scrollbar completely; keeping its width caused #386's empty placeholder.
private int ColumnCount => Scrollable
? 1
: (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
private float InteriorW => Scrollable
? ColumnWidth + ScrollbarWidth
? ColumnWidth + EffectiveScrollbarWidth
: ColumnCount * ColumnWidth;
/// <summary>The popup's visible row count. Size-to-content (retail's
@ -352,6 +370,15 @@ public sealed class UiMenu : UiElement
? System.Math.Max(1, Items.Count)
: RowsPerColumn;
/// <summary>UiMenu rows have a fixed authored height, so this is the same
/// overflow decision <see cref="ConfigurePopupScroll"/> publishes to
/// <see cref="PopupScroll"/>, but is stable before the first draw/event has
/// configured that model.</summary>
private bool PopupContentOverflows => Items.Count > EffectiveVisibleRows;
private float EffectiveScrollbarWidth
=> IsPopupScrollbarPresentationVisible ? ScrollbarWidth : 0f;
private float InteriorH => EffectiveVisibleRows * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
@ -362,6 +389,11 @@ public sealed class UiMenu : UiElement
/// a full render pass.</summary>
public float PopupOuterHeight => OuterH;
/// <summary>The popup's outer (bevel-inclusive) width. Exposed alongside
/// <see cref="PopupOuterHeight"/> so the retail hide-disabled scrollbar rule
/// can be pinned without a GPU render harness.</summary>
public float PopupOuterWidth => OuterW;
/// <summary>
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
/// <c>UIElement_Menu::Open</c> (pc:120210-120252, <c>0x0046cc30</c>)'s Y placement:
@ -558,10 +590,11 @@ public sealed class UiMenu : UiElement
/// <summary>
/// G5: single-column popup with a docked scrollbar — port of the vendor category
/// dropdown's authored shape (LayoutDesc <c>0x21000043</c>, see <see cref="Scrollable"/>'s
/// doc comment). Draws exactly <see cref="RowsPerColumn"/> rows (the authored visible
/// window), sliced from <see cref="Items"/> starting at <see cref="VisibleTopRow"/>, plus
/// the scrollbar chrome using the SAME thumb geometry <see cref="UiScrollbar"/> itself
/// uses (<see cref="UiScrollbar.ThumbRect"/>).
/// doc comment). Draws <see cref="EffectiveVisibleRows"/> rows, sliced from
/// <see cref="Items"/> starting at <see cref="VisibleTopRow"/>, plus the scrollbar
/// chrome when its authored disabled-presentation rule allows it. Thumb geometry
/// is the same helper <see cref="UiScrollbar"/> itself uses
/// (<see cref="UiScrollbar.ThumbRect"/>).
/// </summary>
private void DrawScrollablePopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
@ -602,9 +635,9 @@ public sealed class UiMenu : UiElement
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
PopupScroll.LineHeight = lineHeight;
// Size-to-content: view == content, so HasOverflow is false and the
// scrollbar draws its chrome with no thumb (retail's authored
// scrollbar sibling stretches with the docked popup the same way).
// Size-to-content: view == content, so HasOverflow is false. Whether
// the disabled scrollbar remains visible is its authored 0x79 property,
// projected by IsPopupScrollbarPresentationVisible.
PopupScroll.SetExtents(Items.Count * lineHeight, EffectiveVisibleRows * lineHeight);
}
@ -625,6 +658,8 @@ public sealed class UiMenu : UiElement
private void DrawPopupScrollbar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve, float x, float y)
{
if (!IsPopupScrollbarPresentationVisible) return;
DrawSprite(ctx, resolve, ScrollTrackSprite, x, y, ScrollbarWidth, InteriorH);
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
@ -801,7 +836,9 @@ public sealed class UiMenu : UiElement
}
float scrollbarX = ColumnWidth;
if (ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth && iy >= 0 && iy < InteriorH)
if (IsPopupScrollbarPresentationVisible
&& ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth
&& iy >= 0 && iy < InteriorH)
{
ConfigurePopupScroll();
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);

View file

@ -485,15 +485,13 @@ public sealed class UiText : UiElement, IUiDatStateful
// Per-state Invisible (dat property 0x3B): retail's SetState applies the
// committed state's properties through UIElement::OnSetAttribute, whose
// case 8 (@0x00462DAE, property id 0x33 + 8 = 0x3B) is
// `SetVisible(value == 0)`. Same NAMED-states-only scoping as
// UiDatElement.TrySetRetailState (a DirectState 0x3B is the
// construction-time "authored invisible" class — #408, separately
// gated). First consumer here: the vitals cur/max number labels
// `SetVisible(value == 0)`. #408 includes DirectState: returning to
// state 0 must restore its authored visibility after a named state
// changed it. First consumer here: the vitals cur/max number labels
// (0x100000EB/ED/EF) author HideDetail={0x3B:false} /
// ShowDetail={0x3B:true} — the numbers hide when the click toggle
// switches the window to the graphical icon mode.
if (stateId != UiStateInfo.DirectStateId
&& state is not null
if (state is not null
&& state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp)
&& invisibleProp.Kind == UiPropertyKind.Bool)
Visible = !invisibleProp.BoolValue;