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

@ -490,6 +490,7 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
| CT-1 | Transcript truncation uses ONE character threshold (10,000) where retail uses two — it beheads to ~7,500 (`0x1D4C`) on passing 10,000 (`0x2710`), so its buffer oscillates between the two. acdream also cuts at whole LINES rather than searching for a newline near a byte offset | `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`MaxTranscriptCharacters`, `FirstLineWithinBudget`) | Retail's hysteresis exists to avoid re-trimming an ACCUMULATING buffer on every append; we rebuild the visible list from the log 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 is what retail's newline preference is trying to achieve — our unit already is the line | acdream shows up to ~2,500 characters more scrollback than retail at the moment retail has just trimmed. Visible only as a slightly longer history; no state, wire or memory effect (ChatLog's own entry cap still bounds the model) | `ChatInterface::TruncateChatLog @0x004F4290`; threshold read at `RecvNotice_DisplayFinalStringInfo @0x004F4640` |
---

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))

View file

@ -158,4 +158,74 @@ public sealed class ChatTranscriptRunsTests
IReadOnlyList<UiText.TextRun> line = Assert.Single(runs)!;
Assert.All(line, run => Assert.Equal(line[0].Color, run.Color));
}
// ── CT-B1: retail's transcript character budget ─────────────────────
private static FormattedLine Plain(string text, uint logTextType = 0x02u)
=> new(text, ChatKind.LocalSpeech, null, logTextType);
[Fact]
public void AShortHistoryIsKeptWhole()
{
var detailed = new List<FormattedLine> { Plain("one"), Plain("two") };
Assert.Equal(0, ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null));
}
[Fact]
public void TheOldestLinesDropOnceTheBudgetIsExceeded()
{
// Four lines of 10 characters (+1 newline each = 11) against a budget
// of 25 keeps the newest two and drops the older two.
var detailed = new List<FormattedLine>
{
Plain(new string('a', 10)),
Plain(new string('b', 10)),
Plain(new string('c', 10)),
Plain(new string('d', 10)),
};
Assert.Equal(
2,
ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null, budget: 25));
}
[Fact]
public void FilteredOutLinesDoNotConsumeBudget()
{
// A line this window filters out is not in its buffer at all, so it
// must not push older lines off the top — otherwise turning a filter
// OFF would silently shorten the visible history.
var detailed = new List<FormattedLine>
{
Plain(new string('a', 10), logTextType: 0x02u),
Plain(new string('x', 100), logTextType: 0x06u), // filtered
Plain(new string('b', 10), logTextType: 0x02u),
};
Assert.Equal(
0,
ChatTranscriptRenderer.FirstLineWithinBudget(
detailed, accept: type => type == 0x02u, budget: 25));
}
[Fact]
public void BuildLines_RendersOnlyTheLinesInsideTheBudget()
{
var detailed = new List<FormattedLine>();
for (int i = 0; i < 40; i++)
detailed.Add(Plain(new string((char)('a' + (i % 26)), 500)));
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
detailed, maxW: 100000f, Measure, accept: null, defaultColor: LineColor);
// 40 * 501 = 20,040 characters against retail's 10,000 budget, so
// roughly half survive — and crucially the NEWEST half.
Assert.True(lines.Count < detailed.Count, "the oldest lines should have dropped");
Assert.Equal(detailed[^1].Text, lines[^1].Text);
}
[Fact]
public void TheBudgetIsRetailsOwnNumber()
=> Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters);
}