Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
456 lines
19 KiB
C#
456 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Shared word-wrap + retail color-carry-forward transcript line builder.
|
|
/// Factored out of <see cref="ChatWindowController.GetTranscriptLines"/>
|
|
/// (Campaign CH slice CH6b) so <see cref="FloatingChatWindowController"/>
|
|
/// reuses the exact same algorithm instead of duplicating it — both the main
|
|
/// chat window and the four floating windows are views over the SAME
|
|
/// <see cref="ChatVM"/> transcript (J4.1 pattern: one canonical log, many
|
|
/// filtered presentations).
|
|
///
|
|
/// <para>
|
|
/// Pure function — callers own their own per-controller layout cache
|
|
/// (revision/wrap-width/font keyed), matching the caching each controller
|
|
/// already had before this extraction.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <see cref="WrapText"/> also lives here (CH6a/b REJECT-review NIT 5 — it
|
|
/// used to live on <see cref="ChatWindowController"/>, which made
|
|
/// <see cref="BuildLines"/> call BACK into its own caller's class, a circular
|
|
/// dependency between this "shared" module and one of its two consumers).
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class ChatTranscriptRenderer
|
|
{
|
|
/// <param name="detailed">Tail of the shared chat log, formatted with retail metadata.</param>
|
|
/// <param name="maxW">Wrap width in pixels.</param>
|
|
/// <param name="measure">Glyph-width measurer for the active font.</param>
|
|
/// <param name="accept">
|
|
/// Optional per-line filter — retail's <c>ChatInterface::TypeIsActive</c>
|
|
/// (or the full <c>ShouldDisplay</c> predicate) for THIS window. Null
|
|
/// accepts every line. CH6a/b REJECT-review SHOULD-FIX 2: the main
|
|
/// window now ALWAYS passes a real predicate too (its own retail default
|
|
/// 0xFBFFFFFF filter via <c>ChatWindowState</c>) — null remains supported
|
|
/// for callers with no filter concept at all (there are none in
|
|
/// production today, but the shape stays general). A line that fails the
|
|
/// filter is dropped from this window's view WITHOUT advancing the
|
|
/// carried-forward color, matching retail's <c>m_curFontColor</c> only
|
|
/// advancing for lines actually appended to THIS window's own scroll
|
|
/// (<c>AppendStringInfoWithFont</c> only runs for displayed lines).
|
|
/// </param>
|
|
/// <param name="defaultColor">
|
|
/// The transcript element's own base fill color — retail's
|
|
/// <c>m_curFontColor</c> BEFORE any <c>AppendTextWithFont</c> call ever
|
|
/// tints it, i.e. the value <c>DoFontReset</c> seeds from the element's
|
|
/// authored LayoutDesc property <c>0x1B</c> (style <c>0x10000372</c>:
|
|
/// <c>ARGB(255,204,204,204)</c> for the main chat transcript). Campaign
|
|
/// CH round 4 (<c>docs/research/2026-08-10-retail-ui-text-style.md</c>
|
|
/// §5.2 Fix 5): this used to be hardcoded to
|
|
/// <c>RetailChatColorTable.TryGetColor(0x00u, ...)</c> (colorGreen) —
|
|
/// the color table's OWN default-slot color, not the ELEMENT's authored
|
|
/// default. The two are unrelated: the table governs the per-message
|
|
/// <c>LogTextType</c> tint (untouched by this parameter — every message
|
|
/// with an in-range type still resolves its own table color exactly as
|
|
/// before), while <paramref name="defaultColor"/> is only the carried-
|
|
/// forward seed for a line whose type falls OUTSIDE the table's 34
|
|
/// entries (matching retail's out-of-range "leave <c>m_curFontColor</c>
|
|
/// unchanged" rule — see <see cref="RetailChatColorTable"/>'s own doc).
|
|
/// Callers pass their transcript's <see cref="UiText.DefaultColor"/>.
|
|
/// </param>
|
|
/// <summary>
|
|
/// The colour retail gives the timestamp prefix — chat colour index
|
|
/// <c>0x0C</c>, which
|
|
/// <c>ChatInterface::BuildChatColorLookupTable @0x004F31C0</c> fills with
|
|
/// <c>colorGrey</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Read from the same table every message colour comes from rather than
|
|
/// hard-coded, so it cannot drift from the rest of the palette. This one
|
|
/// IS a table index, unlike the tagged-name colour, which is authored per
|
|
/// element (property <c>0x1D</c>) and deliberately lives elsewhere.
|
|
/// </remarks>
|
|
private static Vector4 TimestampColor =>
|
|
RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey)
|
|
? grey
|
|
: new Vector4(0.5f, 0.5f, 0.5f, 1f);
|
|
|
|
/// <summary>
|
|
/// Retail's transcript character budget:
|
|
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>
|
|
/// truncates once the chat log passes <c>0x2710</c> characters.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Retail keeps ONE accumulating glyph buffer per window and beheads it
|
|
/// back toward <c>0x1D4C</c> (~7,500) when it passes this, preferring to
|
|
/// cut at a newline (<c>ChatInterface::TruncateChatLog @0x004F4290</c>).
|
|
/// Its transcript therefore oscillates between roughly 7,500 and 10,000
|
|
/// characters.
|
|
/// </para>
|
|
/// <para>
|
|
/// We rebuild the visible list from the log each time instead of
|
|
/// accumulating, so the two-threshold hysteresis has nothing to damp — it
|
|
/// exists to stop retail trimming on every single append. A single cap
|
|
/// gives a STABLE window here; oscillating one would make the oldest
|
|
/// visible line jump around as messages arrive. Most entries are already
|
|
/// one line; an oversized server entry with embedded newlines is clipped
|
|
/// at the first complete line inside the retained suffix, matching
|
|
/// retail's newline preference.
|
|
/// </para>
|
|
/// </remarks>
|
|
public const int MaxTranscriptCharacters = 0x2710;
|
|
|
|
/// <summary>
|
|
/// The first index of <paramref name="detailed"/> that fits in retail's
|
|
/// character budget, counting back from the newest line.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Only ACCEPTED lines consume budget — a line this window filters out is
|
|
/// not in its buffer at all, so it cannot push older lines off the top.
|
|
/// </remarks>
|
|
internal static int FirstLineWithinBudget(
|
|
IReadOnlyList<FormattedLine> detailed,
|
|
Func<uint, bool>? accept,
|
|
int budget = MaxTranscriptCharacters)
|
|
=> FindBudgetStart(detailed, accept, budget).LineIndex;
|
|
|
|
private readonly record struct BudgetStart(int LineIndex, int CharacterOffset);
|
|
|
|
private static BudgetStart FindBudgetStart(
|
|
IReadOnlyList<FormattedLine> detailed,
|
|
Func<uint, bool>? accept,
|
|
int budget = MaxTranscriptCharacters)
|
|
{
|
|
long used = 0;
|
|
for (int i = detailed.Count - 1; i >= 0; i--)
|
|
{
|
|
if (accept is not null && !accept(detailed[i].LogTextType))
|
|
continue;
|
|
|
|
// +1 for the newline retail stores between lines.
|
|
long cost = detailed[i].Text.Length + 1L;
|
|
if (used + cost <= budget)
|
|
{
|
|
used += cost;
|
|
continue;
|
|
}
|
|
|
|
int available = (int)Math.Max(0L, budget - used - 1L);
|
|
if (available > 0)
|
|
{
|
|
string text = detailed[i].Text;
|
|
int minimumOffset = Math.Max(0, text.Length - available);
|
|
int offset = FirstCharacterAfterLineBreak(text, minimumOffset);
|
|
if (offset < text.Length)
|
|
return new BudgetStart(i, offset);
|
|
|
|
// A single newest unbroken message must still remain visible;
|
|
// dropping it wholesale is what made large @acecommands
|
|
// replies render as an empty transcript.
|
|
if (used == 0 && text.Length > 0)
|
|
return new BudgetStart(i, minimumOffset);
|
|
}
|
|
return new BudgetStart(i + 1, 0);
|
|
}
|
|
return new BudgetStart(0, 0);
|
|
}
|
|
|
|
private static int FirstCharacterAfterLineBreak(string text, int start)
|
|
{
|
|
for (int i = Math.Clamp(start, 0, text.Length); i < text.Length; i++)
|
|
{
|
|
if (text[i] is not ('\r' or '\n'))
|
|
continue;
|
|
if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n')
|
|
i++;
|
|
return i + 1;
|
|
}
|
|
return text.Length;
|
|
}
|
|
|
|
private static FormattedLine SliceLine(FormattedLine line, int offset)
|
|
{
|
|
if (offset <= 0)
|
|
return line;
|
|
|
|
string text = line.Text[offset..];
|
|
if (line.Spans is not { Count: > 0 } spans)
|
|
return line with { Text = text };
|
|
|
|
var sliced = new List<ChatTextSpan>();
|
|
int at = 0;
|
|
foreach (ChatTextSpan span in spans)
|
|
{
|
|
int end = at + span.Text.Length;
|
|
if (end > offset)
|
|
{
|
|
int from = Math.Max(offset, at) - at;
|
|
sliced.Add(span with { Text = span.Text[from..] });
|
|
}
|
|
at = end;
|
|
}
|
|
return line with { Text = text, Spans = sliced };
|
|
}
|
|
|
|
/// <summary>
|
|
/// The runs covering one wrapped fragment, or <see langword="null"/> when
|
|
/// the fragment is a single colour.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Campaign CT slice A4. Wrapping splits a line into fragments, and a tag
|
|
/// can straddle a break, so a fragment may hold part of a tagged run, all
|
|
/// of it, or none.
|
|
/// </para>
|
|
/// <para>
|
|
/// Returns null unless a tag actually falls inside the window — a
|
|
/// single-colour fragment must take the ordinary flat draw path rather
|
|
/// than a one-run list that means the same thing.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static IReadOnlyList<UiText.TextRun>? RunsForFragment(
|
|
IReadOnlyList<ChatTextSpan> spans,
|
|
int fragmentStart,
|
|
int fragmentLength,
|
|
Vector4 lineColor,
|
|
Vector4 tagColor)
|
|
{
|
|
int fragmentEnd = fragmentStart + fragmentLength;
|
|
var runs = new List<UiText.TextRun>();
|
|
bool sawTag = false;
|
|
int at = 0;
|
|
|
|
foreach (ChatTextSpan span in spans)
|
|
{
|
|
int spanStart = at;
|
|
int spanEnd = at + span.Text.Length;
|
|
at = spanEnd;
|
|
|
|
int from = Math.Max(spanStart, fragmentStart);
|
|
int to = Math.Min(spanEnd, fragmentEnd);
|
|
if (to <= from)
|
|
continue;
|
|
|
|
// Retail appends the timestamp at a FIXED colour index (0x0C)
|
|
// rather than the message's, so it stays grey whatever colour the
|
|
// line is — RecvNotice_DisplayFinalStringInfo @0x004F4640 passes
|
|
// 0xc for that run and the line's own type for the body.
|
|
bool tagged = span.Tag is not null;
|
|
bool stamped = span.Role == ChatSpanRole.Timestamp;
|
|
sawTag |= tagged || stamped;
|
|
|
|
Vector4 color = tagged
|
|
? tagColor
|
|
: stamped
|
|
? TimestampColor
|
|
: lineColor;
|
|
|
|
runs.Add(new UiText.TextRun(
|
|
span.Text.Substring(from - spanStart, to - from),
|
|
color));
|
|
}
|
|
|
|
return sawTag ? runs : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The tagged column ranges inside one wrapped fragment, or
|
|
/// <see langword="null"/> when it holds none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Columns are relative to the FRAGMENT, because that is what a click
|
|
/// resolves to: <c>UiText.HitChar</c> returns a line index into the
|
|
/// wrapped list plus a column within that line.
|
|
/// </remarks>
|
|
internal static IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?
|
|
TaggedRangesForFragment(
|
|
IReadOnlyList<ChatTextSpan> spans,
|
|
int fragmentStart,
|
|
int fragmentLength)
|
|
{
|
|
int fragmentEnd = fragmentStart + fragmentLength;
|
|
List<(int Start, int Length, ChatTextTag Tag)>? ranges = null;
|
|
int at = 0;
|
|
|
|
foreach (ChatTextSpan span in spans)
|
|
{
|
|
int spanStart = at;
|
|
int spanEnd = at + span.Text.Length;
|
|
at = spanEnd;
|
|
|
|
if (span.Tag is not { } tag)
|
|
continue;
|
|
|
|
int from = Math.Max(spanStart, fragmentStart);
|
|
int to = Math.Min(spanEnd, fragmentEnd);
|
|
if (to <= from)
|
|
continue;
|
|
|
|
(ranges ??= new()).Add((from - fragmentStart, to - from, tag));
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
public static List<UiText.Line> BuildLines(
|
|
IReadOnlyList<FormattedLine> detailed,
|
|
float maxW,
|
|
Func<string, float> measure,
|
|
Func<uint, bool>? accept,
|
|
Vector4 defaultColor,
|
|
Vector4? tagColor = null,
|
|
List<IReadOnlyList<UiText.TextRun>?>? runsPerLine = null,
|
|
List<IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?>? tagsPerLine = null)
|
|
{
|
|
var result = new List<UiText.Line>(detailed.Count);
|
|
runsPerLine?.Clear();
|
|
tagsPerLine?.Clear();
|
|
if (detailed.Count == 0)
|
|
return result;
|
|
|
|
// Retail's font-color state (m_curFontColor) persists across every line
|
|
// actually appended to this window — an out-of-range LogTextType leaves it
|
|
// unchanged rather than reverting to a color-table default (color-table doc
|
|
// §3.2). Seed the carry with the ELEMENT's own authored default fill
|
|
// (defaultColor), matching retail's DoFontReset — not the color table's
|
|
// unrelated index-0x00 slot.
|
|
Vector4 currentColor = defaultColor;
|
|
BudgetStart start = FindBudgetStart(detailed, accept);
|
|
for (int lineIndex = start.LineIndex; lineIndex < detailed.Count; lineIndex++)
|
|
{
|
|
FormattedLine d = detailed[lineIndex];
|
|
if (accept is not null && !accept(d.LogTextType))
|
|
continue;
|
|
if (lineIndex == start.LineIndex && start.CharacterOffset > 0)
|
|
d = SliceLine(d, start.CharacterOffset);
|
|
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
|
|
currentColor = resolved;
|
|
// Wrapping can DROP the space it broke on, so a fragment is not
|
|
// simply the next N characters — locate each one in the source
|
|
// line to keep the span offsets honest.
|
|
int searchFrom = 0;
|
|
foreach (string frag in WrapText(d.Text, maxW, measure))
|
|
{
|
|
result.Add(new UiText.Line(frag, currentColor));
|
|
|
|
if (runsPerLine is null && tagsPerLine is null)
|
|
continue;
|
|
|
|
if (d.Spans is not { Count: > 0 } spans || frag.Length == 0)
|
|
{
|
|
runsPerLine?.Add(null);
|
|
tagsPerLine?.Add(null);
|
|
continue;
|
|
}
|
|
|
|
int at = d.Text.IndexOf(frag, searchFrom, StringComparison.Ordinal);
|
|
if (at < 0)
|
|
{
|
|
// Should not happen; a fragment always comes from the line.
|
|
// Fall back to the flat colour rather than mis-colouring.
|
|
runsPerLine?.Add(null);
|
|
tagsPerLine?.Add(null);
|
|
continue;
|
|
}
|
|
searchFrom = at + frag.Length;
|
|
|
|
runsPerLine?.Add(RunsForFragment(
|
|
spans,
|
|
at,
|
|
frag.Length,
|
|
currentColor,
|
|
tagColor ?? currentColor));
|
|
tagsPerLine?.Add(TaggedRangesForFragment(spans, at, frag.Length));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
|
|
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
|
|
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
|
|
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
|
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
|
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
|
|
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
|
|
/// </summary>
|
|
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
|
|
{
|
|
if (string.IsNullOrEmpty(text))
|
|
{
|
|
yield return string.Empty;
|
|
yield break;
|
|
}
|
|
|
|
// Campaign CH user-gate round 1 (item F): server text (e.g. /help's
|
|
// reply) carries embedded '\n's. This function used to hand the
|
|
// WHOLE blob — newlines and all — to the single early-out below,
|
|
// rendering multi-line text as one UiText.Line with literal newline
|
|
// characters in it instead of one rendered line per segment. Split
|
|
// on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then
|
|
// word-wrap each segment independently; the early-out is now scoped
|
|
// to one already-newline-free segment, so it only ever collapses a
|
|
// single-segment text to one line, never a multi-line one.
|
|
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
|
|
foreach (string segment in normalized.Split('\n'))
|
|
{
|
|
foreach (string frag in WrapSingleLine(segment, maxW, measure))
|
|
yield return frag;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Greedy word-wrap for a single, already newline-free line. Split out of
|
|
/// <see cref="WrapText"/> (Campaign CH user-gate round 1, item F) so the
|
|
/// multi-segment split there can call this once per '\n'-delimited
|
|
/// segment without re-deriving the per-line wrap algorithm.
|
|
/// </summary>
|
|
private static IEnumerable<string> WrapSingleLine(string text, float maxW, Func<string, float> measure)
|
|
{
|
|
if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW)
|
|
{
|
|
yield return text;
|
|
yield break;
|
|
}
|
|
|
|
var line = new System.Text.StringBuilder();
|
|
foreach (var word in text.Split(' '))
|
|
{
|
|
string sep = line.Length > 0 ? " " : string.Empty;
|
|
if (measure(line.ToString() + sep + word) <= maxW)
|
|
{
|
|
line.Append(sep).Append(word); // fits on the current line
|
|
continue;
|
|
}
|
|
if (line.Length > 0 && measure(word) <= maxW)
|
|
{
|
|
yield return line.ToString(); // word fits alone → push to a new line
|
|
line.Clear();
|
|
line.Append(word);
|
|
continue;
|
|
}
|
|
// Word too long for any single line: char-wrap it, packing onto the current
|
|
// line's remaining space first (keeps the prefix with the message start).
|
|
if (line.Length > 0) line.Append(' ');
|
|
foreach (char ch in word)
|
|
{
|
|
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
|
|
{
|
|
yield return line.ToString();
|
|
line.Clear();
|
|
}
|
|
line.Append(ch);
|
|
}
|
|
}
|
|
if (line.Length > 0) yield return line.ToString();
|
|
}
|
|
}
|