acdream/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

525 lines
18 KiB
C#

using System.Globalization;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
public enum CreatureAppraisalValueStyle
{
Normal,
Positive,
Negative,
Incomplete,
}
public readonly record struct CreatureAppraisalRow(
string Label,
string Value,
CreatureAppraisalValueStyle Style);
public enum CreatureAppraisalRowLayer
{
Combined,
Background,
Foreground,
}
/// <summary>
/// Pure projection of retail's six <c>AttributeInfoRegion</c> and three
/// <c>Attribute2ndInfoRegion</c> tokens. Ordering and formatting come from
/// <c>BasicCreatureExamineUI</c> and the two Update overloads at
/// 0x004F1D90/0x004F1E80.
/// </summary>
public static class CreatureAppraisalRows
{
private const string Unknown = "???";
private const uint DamageRating = 0x133u;
private const uint DamageResistRating = 0x134u;
private const uint CritRating = 0x139u;
private const uint CritDamageRating = 0x13Au;
private const uint CritResistRating = 0x13Bu;
private const uint CritDamageResistRating = 0x13Cu;
private const uint HealingBoostRating = 0x143u;
private const uint DotResistRating = 0x15Eu;
private const uint LifeResistRating = 0x15Fu;
public static IReadOnlyList<CreatureAppraisalRow> Build(
AppraiseInfoParser.CreatureProfile profile,
bool success)
{
return
[
Primary("Strength", profile.Strength, 0, profile, success),
Primary("Endurance", profile.Endurance, 1, profile, success),
Primary("Coordination", profile.Coordination, 3, profile, success),
Primary("Quickness", profile.Quickness, 2, profile, success),
Primary("Focus", profile.Focus, 4, profile, success),
Primary("Self", profile.Self, 5, profile, success),
Secondary(
"Health",
profile.Health,
profile.HealthMax,
showPercent: true,
enchantmentBit: 6,
profile,
success),
Secondary(
"Stamina",
profile.Stamina,
profile.StaminaMax,
showPercent: false,
enchantmentBit: 7,
profile,
success),
Secondary(
"Mana",
profile.Mana,
profile.ManaMax,
showPercent: false,
enchantmentBit: 8,
profile,
success),
];
}
/// <summary>
/// Port of <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c>'s
/// separate miscellaneous list. Retail reads all nine rating properties,
/// emits a leading and trailing blank row when any display group exists,
/// and uses Crit/CritResist only to decide whether their paired row exists.
/// HealingBoost is read but not displayed by this retail build.
/// </summary>
public static IReadOnlyList<CreatureAppraisalRow> BuildExtra(
PropertyBundle properties)
{
ArgumentNullException.ThrowIfNull(properties);
int damage = Get(properties, DamageRating);
int damageResist = Get(properties, DamageResistRating);
int crit = Get(properties, CritRating);
int critDamage = Get(properties, CritDamageRating);
int critResist = Get(properties, CritResistRating);
int critDamageResist = Get(properties, CritDamageResistRating);
_ = Get(properties, HealingBoostRating);
int dotResist = Get(properties, DotResistRating);
int lifeResist = Get(properties, LifeResistRating);
bool showRating = damage > 0 || crit > 0 || critDamage > 0;
bool showResist =
damageResist > 0 || critResist > 0 || critDamageResist > 0;
bool showDotLife = dotResist > 0 || lifeResist > 0;
if (!showRating && !showResist && !showDotLife)
return Array.Empty<CreatureAppraisalRow>();
var rows = new List<CreatureAppraisalRow>(5)
{
Blank(),
};
if (showRating)
{
rows.Add(new CreatureAppraisalRow(
"Dmg/CritDmg",
$"Rating: {Number(damage)}/{Number(critDamage)}",
CreatureAppraisalValueStyle.Normal));
}
if (showResist)
{
rows.Add(new CreatureAppraisalRow(
"Dmg/CritDmg",
$"Resist: {Number(damageResist)}/{Number(critDamageResist)}",
CreatureAppraisalValueStyle.Normal));
}
if (showDotLife)
{
rows.Add(new CreatureAppraisalRow(
"DoT/Life:",
$"Resist: {Number(dotResist)}/{Number(lifeResist)}",
CreatureAppraisalValueStyle.Normal));
}
rows.Add(Blank());
return rows;
}
private static CreatureAppraisalRow Blank() =>
new(string.Empty, string.Empty, CreatureAppraisalValueStyle.Normal);
private static int Get(PropertyBundle properties, uint id) =>
properties.Ints.TryGetValue(id, out int value) ? value : 0;
private static string Number(int value) =>
value.ToString(CultureInfo.CurrentCulture);
private static CreatureAppraisalRow Primary(
string label,
uint? value,
int enchantmentBit,
AppraiseInfoParser.CreatureProfile profile,
bool success)
=> new(
label,
value is > 0
? value.Value.ToString(CultureInfo.CurrentCulture)
: Unknown,
Style(enchantmentBit, profile, success));
private static CreatureAppraisalRow Secondary(
string label,
uint? current,
uint? maximum,
bool showPercent,
int enchantmentBit,
AppraiseInfoParser.CreatureProfile profile,
bool success)
{
string value = Unknown;
if (current is > 0 && maximum is > 0)
{
int percent = RoundedPercent(current.Value, maximum.Value);
if (success)
{
value = showPercent
? $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)} ({percent.ToString(CultureInfo.CurrentCulture)} %)"
: $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)}";
}
else if (showPercent)
{
value = $"{percent.ToString(CultureInfo.CurrentCulture)} %";
}
}
return new CreatureAppraisalRow(
label,
value,
Style(enchantmentBit, profile, success));
}
private static int RoundedPercent(uint numerator, uint denominator)
=> denominator == 0u
? 0
: (int)Math.Min(
int.MaxValue,
((100L * numerator) + denominator / 2L) / denominator);
private static CreatureAppraisalValueStyle Style(
int bit,
AppraiseInfoParser.CreatureProfile profile,
bool success)
{
if (!success)
return CreatureAppraisalValueStyle.Incomplete;
ushort highlight = profile.AttributeHighlights ?? 0;
if ((highlight & (1 << bit)) == 0)
return CreatureAppraisalValueStyle.Normal;
ushort color = profile.AttributeColors ?? 0;
return (color & (1 << bit)) != 0
? CreatureAppraisalValueStyle.Positive
: CreatureAppraisalValueStyle.Negative;
}
}
/// <summary>
/// Instantiates LayoutDesc 0x2100006B's InfoRegion token template
/// 0x10000166, matching <c>UIElement_ListBox::AddItemFromTemplateList</c>.
/// </summary>
public sealed class CreatureAppraisalRowTemplateFactory
{
public const uint TemplateId = 0x10000166u;
public const uint LabelId = 0x1000012Au;
public const uint ValueId = 0x1000012Bu;
private readonly ElementInfo _template;
private readonly Func<uint, (uint Texture, int Width, int Height)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
public CreatureAppraisalRowTemplateFactory(
ElementInfo template,
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
{
_template = template ?? throw new ArgumentNullException(nameof(template));
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
}
public float Width => _template.Width;
public float Height => _template.Height;
public static CreatureAppraisalRowTemplateFactory? TryLoad(
IDatReaderWriter dats,
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
AppraisalUiController.LayoutId,
TemplateId);
if (template is null
|| Find(template, LabelId) is null
|| Find(template, ValueId) is null
|| template.Width <= 0f
|| template.Height <= 0f)
{
return null;
}
var fonts = new Dictionary<uint, UiDatFont?>();
CaptureFonts(template, resolveFont, fonts);
return new CreatureAppraisalRowTemplateFactory(
template,
resolveSprite,
defaultFont,
fonts);
}
public UiTemplateListSlot Create(
CreatureAppraisalRow row,
CreatureAppraisalRowLayer layer = CreatureAppraisalRowLayer.Combined)
{
ImportedLayout content = LayoutImporter.Build(
_template,
_resolveSprite,
_defaultFont,
did => _fonts.TryGetValue(did, out UiDatFont? font)
? font
: _defaultFont);
UiText label = Required<UiText>(content, LabelId);
UiText value = Required<UiText>(content, ValueId);
if (layer == CreatureAppraisalRowLayer.Background)
{
label.LinesProvider = static () => Array.Empty<UiText.Line>();
value.LinesProvider = static () => Array.Empty<UiText.Line>();
}
else
{
label.LinesProvider = () =>
[new UiText.Line(row.Label, label.DefaultColor)];
value.LinesProvider = () =>
[new UiText.Line(row.Value, ResolveColor(row.Style, value.DefaultColor))];
}
if (layer == CreatureAppraisalRowLayer.Foreground
&& content.Root is UiDatElement root)
{
root.MediaVisible = false;
}
return new UiTemplateListSlot(
content,
entryId: 0u,
content.Root is IUiDatStateful stateful
? stateful.ActiveRetailStateId
: UiStateInfo.DirectStateId,
selectedState: null);
}
private static Vector4 ResolveColor(
CreatureAppraisalValueStyle style,
Vector4 normal)
{
// Retail selects one of four authored FontInfo entries here. Keep the
// semantic state in the row model, but do not invent substitute RGB
// values while that FontInfo-list binding remains unported (AP-110).
_ = style;
return normal;
}
private static T Required<T>(ImportedLayout content, uint id)
where T : UiElement
=> content.FindElement(id) as T
?? throw new InvalidOperationException(
$"Retail creature appraisal template element 0x{id:X8} did not resolve to {typeof(T).Name}.");
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id)
return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } found)
return found;
return null;
}
private static void CaptureFonts(
ElementInfo info,
Func<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> fonts)
{
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
foreach (ElementInfo child in info.Children)
CaptureFonts(child, resolveFont, fonts);
}
}
/// <summary>
/// Reproduces retail viewport compositing with the modern RTT seam:
/// authored row chrome is below the creature viewport, while a media-free
/// instance of the same authored template keeps label/value text above it.
/// Both lists share one pixel scroll model so their rows cannot drift.
/// </summary>
public sealed class CreatureAppraisalLayeredList
{
public const float TextInset = 8f;
private readonly CreatureAppraisalRowTemplateFactory _templates;
private CreatureAppraisalLayeredList(
CreatureAppraisalRowTemplateFactory templates,
UiItemList background,
UiItemList foreground)
{
_templates = templates;
Background = background;
Foreground = foreground;
}
public UiItemList Background { get; }
public UiItemList Foreground { get; }
public static CreatureAppraisalLayeredList Create(
UiElement panel,
UiElement backgroundHost,
UiViewport viewport,
CreatureAppraisalRowTemplateFactory templates,
int backgroundZOrder)
{
ArgumentNullException.ThrowIfNull(panel);
ArgumentNullException.ThrowIfNull(backgroundHost);
ArgumentNullException.ThrowIfNull(viewport);
ArgumentNullException.ThrowIfNull(templates);
int foregroundZOrder = backgroundHost.ZOrder;
backgroundHost.ZOrder = backgroundZOrder;
var scroll = new UiScrollable();
var background = NewList(
// The authored row chrome keeps its LayoutDesc origin. Only the
// foreground text is inset; moving both layers clips the left
// edge of the retail separator/background media.
left: 0f,
top: 0f,
width: templates.Width,
height: backgroundHost.Height,
zOrder: 0,
scroll: scroll);
background.ClickThrough = true;
backgroundHost.AddChild(background);
var foreground = NewList(
left: backgroundHost.Left + TextInset,
top: backgroundHost.Top,
width: templates.Width,
height: backgroundHost.Height,
zOrder: foregroundZOrder,
scroll: scroll);
foreground.Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom;
panel.AddChild(foreground);
return new CreatureAppraisalLayeredList(
templates,
background,
foreground);
}
public void Rebuild(IReadOnlyList<CreatureAppraisalRow> rows)
{
ArgumentNullException.ThrowIfNull(rows);
using (Background.DeferLayout())
using (Foreground.DeferLayout())
{
Background.Flush();
Foreground.Flush();
foreach (CreatureAppraisalRow row in rows)
{
Background.AddItem(_templates.Create(
row,
CreatureAppraisalRowLayer.Background));
Foreground.AddItem(_templates.Create(
row,
CreatureAppraisalRowLayer.Foreground));
}
}
}
public void Flush()
{
Background.Flush();
Foreground.Flush();
}
public void ResetScroll() => Foreground.Scroll.SetScrollY(0);
private static UiItemList NewList(
float left,
float top,
float width,
float height,
int zOrder,
UiScrollable scroll)
{
return new UiItemList(scroll: scroll)
{
Left = left,
Top = top,
Width = width,
Height = height,
ZOrder = zOrder,
Columns = 1,
CellWidth = width,
CellHeight = 20f,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
}
}
/// <summary>
/// Retail <c>AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0</c> resolves
/// creature enum 0x10000005 through portal EnumMapper 0x2200000E and replaces
/// underscores with spaces.
/// </summary>
public sealed class CreatureDisplayNameResolver
{
public const uint MapperDid = 0x2200000Eu;
private readonly IReadOnlyDictionary<uint, string> _names;
public CreatureDisplayNameResolver(
IReadOnlyDictionary<uint, string> names)
{
_names = names ?? throw new ArgumentNullException(nameof(names));
}
public static CreatureDisplayNameResolver Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
var names = new Dictionary<uint, string>();
var visited = new HashSet<uint>();
uint did = MapperDid;
while (did != 0u && visited.Add(did))
{
EnumMapper? mapper = dats.Get<EnumMapper>(did);
if (mapper is null)
break;
foreach ((uint id, PStringBase<byte> text) in mapper.IdToStringMap)
names.TryAdd(id, text.Value.Replace('_', ' '));
did = mapper.BaseEnumMap;
}
return new CreatureDisplayNameResolver(names);
}
public string Resolve(int creatureType)
=> creatureType > 0
&& _names.TryGetValue((uint)creatureType, out string? name)
? name
: string.Empty;
}