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:
parent
78bf62c80e
commit
0f1660d6ea
3 changed files with 126 additions and 1 deletions
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue