acdream/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs
Erik f532f28c5b feat(ui): Campaign CT slice CT5 — attribute/skill row geometry + selection media
Aligns the hand-built attribute/skill rows in CharacterStatController with
the authored shared row template 0x10000248 (LayoutDesc 0x21000045,
InfoRegion::InfoRegion @0x004F1450 template index 0 — the same template
gmAttributeUI and gmSkillUI both instantiate):

- Row geometry replaced with AUTHORED PIXEL VALUES instead of derived
  fractions: icon flush left 20x20 (was 16x16 at X=4, vertically
  centered), name column X=25 W=150 fixed (was RowPadX+IconSize+IconGap
  offset with a width*0.60 fraction), value column X=175 W=100
  right-justified (its right edge sits 7px short of the row's 282px
  right edge — the authored gutter the owner reported). Row width itself
  now clamps to the authored 282px template width (RowContentWidth)
  rather than the ListBox's raw 300px container width. Attribute-row
  height fixed at 20px (was 22px, no dat basis); SkillRowHeight folded
  into the same RowHeight constant since both row kinds share H=20.

- RowHighlightSprite corrected from 0x06001397 to 0x06000F93 — CT1's
  ground-truth research sealed the verdict that gmAttributeUI::
  UpdateSelection @0x0049DEE0 (SetState(6) -> InfoRegion::SetState
  @0x004F0EE0) swaps the row's Highlight-state media (0x06000F93), a
  full-row background swap. 0x06001397 belongs to a different mechanism
  entirely (the spellbook row's UIElement_UIItem::SetSelectedState
  overlay child) and SpellbookRowStyle.cs is untouched.

- UiClickablePanel.UseSelectionBars/SelectionBarHeight retired outright
  (UiPanel.cs): they existed only to emulate 0x06001397's dark-bars art;
  the correct retail rendering is the full-panel sprite stretch the base
  UiPanel.OnDraw already performs, so the override is dead code once the
  correct sprite is used. No consumer existed outside
  CharacterStatController.

- Per-attribute/per-vital icon DIDs now resolve through the live
  DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve, AP-235's
  unification seam) when a resolver is supplied — RetailUiRuntime.
  MountCharacter wires one under the shared DatLock — falling back to
  the hardcoded AttrRows/VitalRows column otherwise (tests, no dat).
  gmAttributeUI::PostInit @0x0049DB70 read verbatim: attributes resolve
  via category 0x10000002 (statId order 1,2,4,3,5,6, matching AttrRows'
  authored display order exactly); vitals via category 0x10000003.
  Live-DAT-verified: every hardcoded fallback value already matched the
  resolved DID byte-exact (new InstalledDat pin
  AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain).

- RetailAppraisalNameResolver.ResolveHeritage's independent
  re-implementation of the 2/5/13 heritage overrides deleted; it now
  delegates straight to CharacterIdentityText.HeritageGroupDisplayName
  (which already bakes in the same overrides) — one owner, byte-identical
  behavior. AP-235's register row updated to reflect the single-owner fix
  (the underlying hardcoded-vs-live-DAT mechanism divergence itself
  stays open — out of CT5's scope).

Hand-built-vs-template ruling: rows stay HAND-BUILT rather than
converting to UiTemplateListBox instantiation. The hand-built path hits
every authored number byte-exact (proven by the CT1 InstalledDat pin
AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns),
while conversion would touch ~15 call sites (raise-button affordability,
footer State A/B, per-row tooltip, section bucketing, live-refresh,
selection-highlight) for a geometry-only slice — smaller-risk path per
the task's own judgment-call guidance.

Tests: CharacterStatControllerTests' sprite/UseSelectionBars assertions
corrected to the authored geometry; new InstalledDat pin for the icon-DID
chain. Full hermetic solution suite green (App/Core/Runtime/Headless/
Launcher/Content/etc., 0 failures) and the full InstalledDat lane green
(203 App.Tests pins, TowerAscentReplayTests' known Status=KnownFailure
case excluded per the acceptance filter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 01:15:32 +02:00

122 lines
4.7 KiB
C#

using AcDream.Content;
using AcDream.Core.Items;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Resolves the authored enum display names consumed by retail's appraisal
/// helpers. Material names follow
/// <c>MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500</c>;
/// creature and heritage names follow
/// <c>AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0</c> and
/// <c>InqHeritageGroupDisplayName @ 0x005B4710</c>.
/// </summary>
public sealed class RetailAppraisalNameResolver
{
private const uint MaterialClientEnum = 0x10000001u;
private const uint MaterialSubEnum = 1u;
public static RetailAppraisalNameResolver Empty { get; } = new(
new Dictionary<uint, string>(),
new CreatureDisplayNameResolver(new Dictionary<uint, string>()));
private readonly IReadOnlyDictionary<uint, string> _materials;
private readonly CreatureDisplayNameResolver _creatures;
public RetailAppraisalNameResolver(
IReadOnlyDictionary<uint, string> materials,
CreatureDisplayNameResolver creatures)
{
_materials = materials
?? throw new ArgumentNullException(nameof(materials));
_creatures = creatures
?? throw new ArgumentNullException(nameof(creatures));
}
public static RetailAppraisalNameResolver Load(
IDatReaderWriter dats,
CreatureDisplayNameResolver creatures)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(creatures);
var materials = new Dictionary<uint, string>();
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
EnumIDMap? master = masterDid == 0u
? null
: dats.Get<EnumIDMap>(masterDid);
if (master is not null
&& master.ClientEnumToID.TryGetValue(
MaterialClientEnum,
out uint materialRootMapDid)
&& dats.Get<EnumIDMap>(materialRootMapDid) is { } materialRootMap
&& materialRootMap.ClientEnumToID.TryGetValue(
MaterialSubEnum,
out uint materialMapDid)
&& dats.Get<DualEnumIDMap>(materialMapDid) is { } materialMap)
{
foreach ((uint id, PStringBase<byte> text)
in materialMap.ClientEnumToName)
{
materials.TryAdd(id, Normalize(text.Value));
}
}
return new RetailAppraisalNameResolver(materials, creatures);
}
public string ResolveCreature(int creatureType)
=> _creatures.Resolve(creatureType);
// Campaign CT slice CT5 (2026-08-25): the three hardcoded overrides this
// method used to re-implement inline (2/5/13 -> "Gharu'ndim"/"Umbraen"/
// "Olthoi") are dead duplication — CharacterIdentityText.HeritageGroupDisplayName
// already bakes the identical AppraisalSystem::InqHeritageGroupDisplayName
// overrides into its own switch (including id 12, which this method used
// to leave to the fallback branch and get the same "Olthoi" answer
// anyway). One owner for the override table now; behavior is unchanged
// (byte-identical for every heritage id — verified by the existing
// GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain pin,
// which exercises the shared table directly).
public string ResolveHeritage(int heritageGroup)
=> CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) ?? string.Empty;
public string ResolveMaterial(int materialType)
=> materialType > 0
&& _materials.TryGetValue((uint)materialType, out string? name)
? name
: string.Empty;
/// <summary>
/// Port of the material-decoration tail in
/// <c>ACCWeenieObject::GetObjectName @ 0x0058E6E0</c>. Retail first
/// chooses the singular/plural base name, removes an already-authored
/// occurrence of the resolved material, trims it, then prefixes the
/// material exactly once.
/// </summary>
public string ResolveAppropriateName(ClientObject obj)
{
ArgumentNullException.ThrowIfNull(obj);
string baseName = obj.GetAppropriateName();
if (obj.MaterialType is not { } materialType)
return baseName;
string material = ResolveMaterial(unchecked((int)materialType));
if (string.IsNullOrEmpty(material))
return baseName;
string remainder = baseName
.Replace(material, string.Empty, StringComparison.Ordinal)
.Trim();
return string.IsNullOrEmpty(remainder)
? material
: $"{material} {remainder}";
}
private static string Normalize(string value)
=> value.Replace('_', ' ');
}