Roster: SocialFellowshipPageController now builds one row per fellow
from the authored template (0x21000030/0x10000281, live-DAT verified),
diffing the member GUID set on each revision-gated Tick -- an unchanged
set updates every row's bound widgets in place (no ListBox mutation, so
scroll position is untouched by construction); only a real join/leave/
disband triggers a rebuild, via UiTemplateListBox.FlushPreservingScroll
(FA3 carry-forward 1, both the widget-level fix and the controller-level
diff). Health/stamina/mana meters bind Fill+Label; the leader's name
tints gold (lane A's row template has no dedicated leader marker, so
this is a flagged adaptation, not a ported mechanism). Row-click
selection (SelectFellow) drives Dismiss/Leader targeting and the world
selection (SelectionChangeSource.Social).
D4: SocialPanelController now tracks "is the social window shown AND is
Fellowship the active tab" via UiTabPanel.ActivePageChanged +
OnShown/OnHidden, and calls SetPageVisible on every transition, which
sends 0x00A6 (idempotent, no-op while disconnected) -- the prerequisite
ACE gates its 0x02C0 vitals stream on.
Create flow: the inline name field (0x1000026F, an authored Editable
UiField -- live-DAT verified) gates the Create button's enabled state
exactly like retail (empty name = disabled = the whole refusal
mechanism, no separate error text); FellowshipShareXP's live value is
read at click time.
Actions + confirmations: Recruit/Dismiss/Quit/Disband/AssignLeader/
SetOpen all route through DeferredGameRuntimeStateCommands (new
Fellowship* methods) rather than a raw WorldSession send, so Quit
correctly picks up RuntimeFellowshipState's leader hand-off rule.
Button enable states port gmFellowshipUI::UpdateButtons verbatim. The
Open/Close button's caption swaps between the two DAT-resolved strings
cached once at Bind (never per-tick -- DatCollection is not safe to
touch unprotected from the render loop). RetailUiRuntime intercepts a
type-4 confirmation request before it reaches the generic
GameplayConfirmationController: IgnoreFellowshipRequests auto-declines,
FellowshipAutoAcceptRequests auto-accepts, neither set falls through to
the existing dialog machinery unchanged (D6).
D5 display: the per-fellow stats line uses retail's byte-decoded
even-split percentage table verbatim (1.0/.../.3111111/.28, default
0.0); the proportional branch omits the percentage rather than
inventing a formula (no acdream ExperienceToRaiseLevel table exists
yet). Both StringInfo variable substitution (row/stats/vitals text) and
ACCharGenData::FormatName (create-flow name canonicalization) are
unported prerequisites, so row text renders as plain numeric composites
-- register rows AD-80/AD-81 (docs commit).
D7: un-dims IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
(consumed by the D6 auto-decline/accept) and FellowshipShareXP/
FellowshipShareLoot (consumed by Create + the page's own second
checkbox surface) on the Character tab -- 4 of 35 store-only rows
promoted to Live (31 remain dimmed).
Carry-forwards from the FA3 re-review, folded into this slice's
contract:
- UiTemplateListBox.FlushPreservingScroll -- preserves scroll offset
across a rebuild instead of resetting to 0 (Flush's existing
contract, unchanged, for Friends/Squelch).
- RowTemplateResolver -- the FA3 caching row-template resolver
extracted from a MountSocialPanel local function into its own
hermetically-testable class; now shared by Friends/Squelch/
Fellowship's row families.
- Friends/Squelch scrollbars now resolve via the built
UiTemplateListBox.ScrollbarElementId (DAT property 0x72) instead of
a hardcoded literal, matching ConfigOptionsPageController's own OP6
precedent.
- The Fellowship roster path never advances its revision latch on a
partial resolver failure until the NEXT real membership change --
never a per-frame retry loop.
Live-DAT verified (ACDREAM_PROBE_LIVE_MOUNT=1, extended
SocialPanelLiveMountProbeTests): the name field builds as UiField, all
11 buttons/checkboxes resolve, the row template's 5 checked fields
resolve to the right widget types, every checkbox label/tooltip and the
Open/Close captions resolve to real retail strings ("Open"/"Close"),
and a full production-path Bind() against live DATs produces zero
"not found" warnings.
App tests: +30 (7 UiTemplateListBox/RowTemplateResolver unit tests, 23
SocialFellowshipPageControllerTests covering roster diff/rebuild,
button enable rules, checkbox wiring, create-flow gating, D4
idempotency, and D5 formatting) plus 2 CharacterOptionsPageController
counts updated for the D7 un-dim (35->31 dimmed, 15->19 live).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
300 lines
16 KiB
C#
300 lines
16 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using AcDream.App.UI.Layout;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Retail <c>UIElement_ListBox</c> (dat class Type <c>5</c>) with an authored row-template
|
||
/// list (dat property <c>0x64</c>). Port of <c>AddItemFromTemplateList(index)</c>:
|
||
/// instantiates row <paramref name="index"/>'s template — a <see cref="UiTemplateListEntry"/>
|
||
/// naming a cross-layout <c>{LayoutDesc DID, element id}</c> pair, per
|
||
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.5 — through the SAME
|
||
/// import machinery every other retained window uses, and appends it as one row.
|
||
///
|
||
/// <para>
|
||
/// <b>OP2 rework (2026-08-11):</b> derives from <see cref="UiDatElement"/> and stays
|
||
/// DORMANT until a controller adds its first row. The OP2 REJECT-review blast-radius
|
||
/// finding (`docs/research/2026-08-11-op2-review-blast.md`) established that EVERY
|
||
/// pre-existing Type-5 element reaching <see cref="DatWidgetFactory"/> already authors a
|
||
/// non-empty <c>0x64</c> template array (character `0x1000023D`/`0x10000532`, effects
|
||
/// positive/negative `0x10000123`, examine `0x10000149`/`0x10000335`/`0x1000032D`,
|
||
/// mini-game `0x10000174`, spellbook `0x10000464`) — so the original Type-5 factory
|
||
/// guard's premise ("none currently reach this factory") was false, and the old
|
||
/// unconditional viewport injection in the constructor (<c>base.AddChild(_viewport)</c>)
|
||
/// gave every one of those ten elements a spurious hit-testable
|
||
/// <see cref="UiScrollablePanel"/> child plus lost their authored media (the old class
|
||
/// derived from bare <see cref="UiPanel"/>, zeroed its background/border, and drew
|
||
/// nothing dat-authored). None of the ten pre-existing elements author real dat
|
||
/// children of their own (verified against every regenerated fixture — their row
|
||
/// content comes ONLY from the cross-layout <c>0x64</c> array, never from a literal
|
||
/// child in their own LayoutDesc subtree), so making this class dormant-by-default is a
|
||
/// pure behavior restoration: an un-activated instance renders and hit-tests EXACTLY
|
||
/// like the pre-OP2 <see cref="UiDatElement"/> fallback. The Options panel's three
|
||
/// ListBoxes (Character/Config/Chat, Campaign OP slice OP4+) are the only elements a
|
||
/// controller will ever call <see cref="AddItemFromTemplateList"/> against.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <see cref="ConsumesDatChildren"/> is <c>true</c>: retail ListBox rows come ONLY from
|
||
/// <c>AddItemFromTemplateList</c>, never from static dat children of the ListBox element
|
||
/// itself, so this widget owns its own child construction the same way
|
||
/// Meter/Menu/Button/Scrollbar/Text/Field do — <c>LayoutImporter</c> does not attempt to
|
||
/// recurse into any literal dat children this element might (incorrectly) author.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Row content is stacked inside a lazily-created <see cref="UiScrollablePanel"/> —
|
||
/// created on the FIRST successful <see cref="AddItemFromTemplateList"/> call, not in the
|
||
/// constructor. This is what makes dormancy free: an instance that never gets a row
|
||
/// (every pre-existing panel today) never allocates or attaches the viewport, so there is
|
||
/// no spurious hit-testable child competing with whatever the controller separately adds
|
||
/// via <see cref="UiElement.AddChild"/> (the same "resolve → not a UiItemList → build one
|
||
/// and attach it directly to the host" pattern <c>EffectsUiController</c>/
|
||
/// <c>AppraisalUiController</c>/<c>SpellbookWindowController</c>/
|
||
/// <c>CharacterStatController</c> already use for these exact ten elements).
|
||
/// <see cref="UiScrollablePanel"/> is sealed, hence composition rather than inheritance —
|
||
/// the SAME "controller-built row list" viewport pattern
|
||
/// <c>CharacterStatController.RebuildActiveList</c> already uses for the skill list.
|
||
/// The wrapped panel is anchored to fill this box, so scrolling, per-row visibility
|
||
/// clipping, and the pixel scroll model (<see cref="Scroll"/>) all come from the SAME
|
||
/// code CH6/the character sheet already exercise — no new scroll model. Rows stack in
|
||
/// call order: each new row's <see cref="UiElement.Top"/> is set to the viewport's
|
||
/// current <see cref="UiScrollablePanel.ContentHeight"/> before it is added, exactly
|
||
/// retail's own ListBox layout (each row is authored at its own template-local Y=0; the
|
||
/// box stacks instances).
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <see cref="TemplateResolver"/> is the seam a page controller wires with real DAT
|
||
/// access — e.g. <c>(layoutId, elementId) => LayoutImporter.Build(
|
||
/// LayoutImporter.ImportInfos(dats, layoutId, elementId), resolve, datFont,
|
||
/// fontResolve, stringResolve)?.Root</c> (the SAME "one selected root from a
|
||
/// catalog-style LayoutDesc" overload <c>RetailDialogFactory</c> already uses for the
|
||
/// shared dialog catalog — the Options panel's row templates are top-level siblings
|
||
/// of the tab control in <c>0x2100002B</c>, structurally identical to that catalog
|
||
/// shape). Left null by <c>DatWidgetFactory</c> itself: OP2 ships the mechanism, a
|
||
/// page controller (Campaign OP slice OP4+) supplies the resolver once it has a live
|
||
/// <c>IDatReaderWriter</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class UiTemplateListBox : UiDatElement
|
||
{
|
||
/// <summary>Retail element id this class was ported for: Type 5 = 5.</summary>
|
||
public const uint RetailTypeId = 5u;
|
||
|
||
private const int DefaultLineHeight = 16;
|
||
|
||
private UiScrollablePanel? _viewport;
|
||
private int _pendingLineHeight = DefaultLineHeight;
|
||
|
||
/// <summary>The authored row-template list (dat property 0x64), in authored array order.</summary>
|
||
public IReadOnlyList<UiTemplateListEntry> Templates { get; }
|
||
|
||
/// <summary>
|
||
/// Element id of this ListBox's linked scrollbar (dat property 0x72; e.g. the
|
||
/// Character page's ListBox 0x100001FA names scrollbar 0x100001FB). 0 when the
|
||
/// dat authors no scrollbar reference. A page controller resolves this id against
|
||
/// the imported tree and sets the found <see cref="UiScrollbar"/>'s
|
||
/// <see cref="UiScrollbar.Model"/> to <see cref="Scroll"/> — the same linkage
|
||
/// <c>ChatWindowController</c> wires for the chat transcript.
|
||
/// </summary>
|
||
public uint ScrollbarElementId { get; }
|
||
|
||
/// <summary>The wrapped viewport's pixel scroll model — link a page controller's
|
||
/// resolved scrollbar (<see cref="ScrollbarElementId"/>) to this. Creates the
|
||
/// viewport on first access (see class doc — dormancy).</summary>
|
||
public UiScrollable Scroll => Viewport.Scroll;
|
||
|
||
/// <summary>Total stacked row height in px — the same value <see cref="Scroll"/>'s
|
||
/// content extent uses. 0 while dormant (no viewport created yet).</summary>
|
||
public int ContentHeight => _viewport?.ContentHeight ?? 0;
|
||
|
||
/// <summary>Row height for the scroll model's line-scroll quantum. Set once template
|
||
/// heights are known; defaults to the viewport's own default (16px). Safe to set
|
||
/// before the viewport exists — the value is applied once it's created.</summary>
|
||
public int LineHeight
|
||
{
|
||
get => _viewport?.LineHeight ?? _pendingLineHeight;
|
||
set
|
||
{
|
||
_pendingLineHeight = value;
|
||
if (_viewport is not null) _viewport.LineHeight = value;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Instantiates a row template's subtree (its LayoutDesc DID + element id) into a
|
||
/// built <see cref="UiElement"/>. Null (the default) means
|
||
/// <see cref="AddItemFromTemplateList"/> cannot build rows yet.
|
||
/// </summary>
|
||
public Func<uint, uint, UiElement?>? TemplateResolver { get; set; }
|
||
|
||
public UiTemplateListBox(
|
||
ElementInfo info,
|
||
Func<uint, (uint tex, int w, int h)> resolve,
|
||
IReadOnlyList<UiTemplateListEntry> templates,
|
||
uint scrollbarElementId)
|
||
: base(info, resolve)
|
||
{
|
||
Templates = templates;
|
||
ScrollbarElementId = scrollbarElementId;
|
||
}
|
||
|
||
/// <summary>Retail ListBox rows never come from static dat children — see class doc.</summary>
|
||
public override bool ConsumesDatChildren => true;
|
||
|
||
/// <summary>The lazily-created row viewport, or null before the first row is added.
|
||
/// Exposed for #372's regression test (the viewport must fill this ListBox, not
|
||
/// collapse to 0×0).</summary>
|
||
internal UiScrollablePanel? ViewportForTest => _viewport;
|
||
|
||
private UiScrollablePanel Viewport
|
||
{
|
||
get
|
||
{
|
||
if (_viewport is null)
|
||
{
|
||
// #372: the viewport MUST start filling this ListBox. It is created
|
||
// lazily (post-Build, during a page controller's Bind), so it misses
|
||
// the Build-time sizing that authored static children get. With
|
||
// Left|Top|Right|Bottom fill-anchors but a 0×0 initial rect, the
|
||
// anchor system captures a degenerate baseline (mR = parentW - (0+0)
|
||
// = parentW) and ComputeAnchoredRect then keeps it 0×0 forever
|
||
// (w = parentW - mR - mL = 0) — a 0-tall viewport makes
|
||
// UiScrollablePanel.LayoutScrollableChildren cull every row, so every
|
||
// ListBox-backed Options tab renders BLANK (Gameplay, which has no
|
||
// viewport, was the only tab that worked). Seeding the viewport to
|
||
// this ListBox's current size makes the fill-anchor baseline correct
|
||
// (mR = parentW - (0 + parentW) = 0) and self-maintaining as the
|
||
// ListBox re-anchors each frame. The ListBox is a static dat child
|
||
// sized at Build, so its extent is already authored here.
|
||
_viewport = new UiScrollablePanel
|
||
{
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||
LineHeight = _pendingLineHeight,
|
||
Width = Width,
|
||
Height = Height,
|
||
};
|
||
base.AddChild(_viewport);
|
||
}
|
||
return _viewport;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>UIElement_ListBox::AddItemFromTemplateList(m_pOptionBox, index,
|
||
/// nullptr)</c>: resolves <c>Templates[index]</c> through <see cref="TemplateResolver"/>
|
||
/// and appends the built subtree as the next row, stacked below the previous one.
|
||
/// Lazily creates the internal viewport on the FIRST successful call (see class doc —
|
||
/// dormancy). Returns the built row widget, or null when <paramref name="index"/> is
|
||
/// out of range, no resolver is wired, or the resolver produced nothing.
|
||
/// </summary>
|
||
public UiElement? AddItemFromTemplateList(int index)
|
||
{
|
||
if (index < 0 || index >= Templates.Count) return null;
|
||
Func<uint, uint, UiElement?>? resolver = TemplateResolver;
|
||
if (resolver is null) return null;
|
||
|
||
UiTemplateListEntry entry = Templates[index];
|
||
UiElement? row = resolver(entry.TemplateLayoutId, entry.TemplateElementId);
|
||
if (row is null) return null;
|
||
|
||
return AddPrebuiltRow(row);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AP-195 (Campaign OP slice OP5): stacks an ALREADY-BUILT row using the exact
|
||
/// same "below the previous row, then grow ContentHeight" placement
|
||
/// <see cref="AddItemFromTemplateList"/> uses internally, WITHOUT resolving it
|
||
/// through <see cref="TemplateResolver"/> first. For a row whose own final size
|
||
/// is not known until AFTER it is fully populated — the Chat tab's five
|
||
/// <see cref="UiCheckboxBitfield64"/> filter blocks, which self-size to fit
|
||
/// their N rows (retail's own <c>CreateChildren</c> tail,
|
||
/// <c>docs/architecture/retail-divergence-register.md</c> AP-195) — a page
|
||
/// controller resolves the block directly through the SAME
|
||
/// <see cref="TemplateResolver"/> delegate (bypassing
|
||
/// <see cref="AddItemFromTemplateList"/>'s own resolve step), calls
|
||
/// <c>SetDefaultValue</c> + N <c>AddChild</c> on it (growing its
|
||
/// <see cref="UiElement.Height"/> to its final, fully-populated extent), THEN
|
||
/// calls this method to insert it. Because the row is placed at its FINAL height
|
||
/// instead of its authored pre-build extent, this viewport's own
|
||
/// <see cref="UiScrollablePanel.ContentHeight"/> — and every row placed AFTER
|
||
/// it — accounts for the real size from the start; no retroactive reflow of
|
||
/// already-placed siblings is needed (this class has no such reflow mechanism —
|
||
/// each row's <see cref="UiElement.Top"/> is fixed at insertion time). This is
|
||
/// literally "reuse UiTemplateListBox's stacking," the disposition AP-195 itself
|
||
/// names as an acceptable alternative to a full ListBox-unification pass.
|
||
/// </summary>
|
||
public UiElement AddPrebuiltRow(UiElement row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
UiScrollablePanel viewport = Viewport;
|
||
row.Left = 0f;
|
||
row.Top = viewport.ContentHeight;
|
||
viewport.AddChild(row);
|
||
return row;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FA slice FA3: removes every row previously added via
|
||
/// <see cref="AddItemFromTemplateList"/>/<see cref="AddPrebuiltRow"/>, resetting
|
||
/// <see cref="ContentHeight"/> to 0 AND the scroll position to 0
|
||
/// (<see cref="UiScrollablePanel.ClearContent"/> calls
|
||
/// <c>Scroll.SetScrollY(0)</c> — fix-round blast SF-5: the ORIGINAL doc here
|
||
/// only mentioned the height reset) — the "Gap found" prerequisite lane A/D
|
||
/// flagged (docs/research/2026-08-11-fa-panel-structure.md §6.6: "no Flush, no
|
||
/// selection model, no per-row instance-id"). Needed for a poll-and-rebuild
|
||
/// binding (Friends/Squelch read-only lists) where the row COUNT can shrink
|
||
/// between refreshes — <see cref="AddItemFromTemplateList"/> alone only ever
|
||
/// grows the stack. A no-op, never allocating the viewport, when the box is
|
||
/// still dormant (no row has ever been added) — mirrors every other dormancy
|
||
/// guard on this class (see class doc).
|
||
///
|
||
/// <para>
|
||
/// <b>Diverges from the sibling <see cref="UiItemList.Flush"/> it shares a
|
||
/// name with</b> (fix-round blast SF-5): that method does NOT touch scroll
|
||
/// position. For a poll-and-rebuild Friends/Squelch list, this means a user
|
||
/// scrolled partway into a long roster is yanked back to the top on every
|
||
/// server-side change (a friend's online status flips, etc.) — a real,
|
||
/// user-visible UX cost once <see cref="ScrollbarElementId"/> is actually
|
||
/// wired (it now is — see <see cref="AcDream.App.UI.Layout.SocialFriendsPageController"/>).
|
||
/// Not fixed here: doing so would require this class to preserve/re-clamp
|
||
/// the offset across a rebuild whose row COUNT can shrink, which is a
|
||
/// bigger behavior change than a fix-round doc correction should make
|
||
/// unasked; flagged for whoever revisits Friends/Squelch scrolling next.
|
||
/// </para>
|
||
/// </summary>
|
||
public void Flush() => _viewport?.ClearContent();
|
||
|
||
/// <summary>
|
||
/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): like
|
||
/// <see cref="Flush"/>, but preserves the CURRENT scroll offset instead
|
||
/// of resetting it to 0. <see cref="Flush"/>'s <c>ClearContent</c> zeroes
|
||
/// <see cref="UiScrollablePanel.Scroll"/>'s position — fine for
|
||
/// Friends/Squelch (revision-polled rebuild while visible, no mid-scroll
|
||
/// disturbance concern documented) but wrong for a roster whose rebuild
|
||
/// can be driven by a per-vitals-tick revision bump: resetting to the
|
||
/// top on every incoming <c>0x02C0</c> would fight the user's own
|
||
/// scrolling the instant they touch the fellowship list.
|
||
/// <see cref="SocialFellowshipPageController"/> only calls this method
|
||
/// when the member SET actually changed (join/leave/disband) — a plain
|
||
/// vitals/stat refresh updates the existing rows' bound widgets in place
|
||
/// and never touches the ListBox structure at all, so this path is the
|
||
/// rare case, not the common one. The restored offset is clamped against
|
||
/// the STALE (pre-rebuild) content height at the moment of the call;
|
||
/// <see cref="UiScrollablePanel.LayoutScrollableChildren"/> re-clamps it
|
||
/// against the fresh height on the very next draw, before anything is
|
||
/// ever painted with a stale bound — the same "clamp now, correct at
|
||
/// next layout" contract <see cref="UiScrollablePanel.RecomputeContentHeight"/>
|
||
/// already relies on for every ordinary <see cref="AddItemFromTemplateList"/>
|
||
/// call. A no-op (same as <see cref="Flush"/>) while dormant.
|
||
/// </summary>
|
||
public void FlushPreservingScroll()
|
||
{
|
||
if (_viewport is null) return;
|
||
int savedScrollY = _viewport.Scroll.ScrollY;
|
||
_viewport.ClearContent();
|
||
_viewport.Scroll.SetScrollY(savedScrollY);
|
||
}
|
||
}
|