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