diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 83fc2af5..5586a14f 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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` |
---
diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
index 2b4a1a5d..fa430f7c 100644
--- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
+++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
@@ -65,6 +65,58 @@ internal static class ChatTranscriptRenderer
/// unchanged" rule — see 's own doc).
/// Callers pass their transcript's .
///
+ ///
+ /// Retail's transcript character budget:
+ /// ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640
+ /// truncates once the chat log passes 0x2710 characters.
+ ///
+ ///
+ ///
+ /// Retail keeps ONE accumulating glyph buffer per window and beheads it
+ /// back toward 0x1D4C (~7,500) when it passes this, preferring to
+ /// cut at a newline (ChatInterface::TruncateChatLog @0x004F4290).
+ /// Its transcript therefore oscillates between roughly 7,500 and 10,000
+ /// characters.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public const int MaxTranscriptCharacters = 0x2710;
+
+ ///
+ /// The first index of that fits in retail's
+ /// character budget, counting back from the newest line.
+ ///
+ ///
+ /// 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.
+ ///
+ internal static int FirstLineWithinBudget(
+ IReadOnlyList detailed,
+ Func? 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;
+ }
+
///
/// The runs covering one wrapped fragment, or 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))
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
index 15fe1d4b..664c3a6c 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
@@ -158,4 +158,74 @@ public sealed class ChatTranscriptRunsTests
IReadOnlyList 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 { 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
+ {
+ 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
+ {
+ 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();
+ for (int i = 0; i < 40; i++)
+ detailed.Add(Plain(new string((char)('a' + (i % 26)), 500)));
+
+ List 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);
}