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>
122 lines
4.7 KiB
C#
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('_', ' ');
|
|
}
|