feat(chat): CT-B1 — bound the transcript by retail's character budget

Campaign CT slice B1.

CORRECTION TO THE PLAN: this slice was written as "the transcript grows for the
life of the session — a slow leak". That was wrong, and the plan said it
because I read the retail-side finding and inferred our side without checking.
ChatLog has always been bounded (ConcurrentQueue, maxEntries default 500, with
a dequeue loop in Append). There was no leak.

The real gap is the UNIT. Retail bounds the rendered transcript by CHARACTERS —
0x2710, beheaded toward 0x1D4C at a newline boundary — while we bounded the
model by messages. Two different things: a window of 500 messages is far more
scrollback than 10,000 characters, and the message cap is a safety limit on the
log rather than a display rule.

So the budget is applied where retail applies it: on the rendered window, not
the model. ChatLog's entry cap stays as the model-level bound.

Two deliberate simplifications, both registered as CT-1 rather than left
implicit:

  - ONE threshold, not retail's two. The hysteresis exists to stop retail
    re-trimming an accumulating buffer on every append; we rebuild the visible
    list each time, so there is nothing to damp, and a second threshold would
    only make the oldest visible line jump around as messages arrive.
  - Whole-line cutting rather than a newline search near an offset — our unit
    already IS the line, which is what retail's newline preference is for.

Filtered-out lines deliberately do not consume budget: a line this window
filters out is not in retail's buffer at all, so counting it would mean turning
a filter OFF silently shortened the visible history.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 08:05:26 +02:00
parent 78bf62c80e
commit 0f1660d6ea
3 changed files with 126 additions and 1 deletions

View file

@ -65,6 +65,58 @@ internal static class ChatTranscriptRenderer
/// unchanged" rule — see <see cref="RetailChatColorTable"/>'s own doc).
/// Callers pass their transcript's <see cref="UiText.DefaultColor"/>.
/// </param>
/// <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. Cutting at whole lines is
/// automatic for the same reason: our unit already is the line, which is
/// what retail's newline preference is trying to achieve.
/// </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)
{
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.
used += detailed[i].Text.Length + 1;
if (used > budget)
return i + 1;
}
return 0;
}
/// <summary>
/// The runs covering one wrapped fragment, or <see langword="null"/> when
/// the fragment is a single colour.
@ -176,8 +228,10 @@ internal static class ChatTranscriptRenderer
// (defaultColor), matching retail's DoFontReset — not the color table's
// unrelated index-0x00 slot.
Vector4 currentColor = defaultColor;
foreach (FormattedLine d in detailed)
int firstLine = FirstLineWithinBudget(detailed, accept);
for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++)
{
FormattedLine d = detailed[lineIndex];
if (accept is not null && !accept(d.LogTextType))
continue;
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))