Applies the Opus review findings on CH1 (172c6f9a), the exact retail chat
color table. Two blockers plus should-fixes/nits, one commit:
BLOCKER 1 — LegacyChannelChatType.Resolve's channel-bit table was wrong.
Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom (a branchless
select between Channel 0x08 and Channel_Send 0x09) as the trivial pseudo-C
`esi - esi` (always 0), hiding the real values. Corrected by decoding the
raw bytes at the PDB-paired binary: HEAR sbb site VA 0x00570F0A (mask -6 ->
0x08), SEND sbb site VA 0x00570D4F (mask -5 -> 0x09). The generic
admin/audit/sentinel catch-all is Channel/Channel_Send, NOT Abuse (0x0E) —
Abuse is retail's ONLY 0x0E producer (bit 0x0001). The unnamed
FellowBroadcast bit (0x4000000) is hear=Channel(0x08)/send=Fellowship(0x13),
not a flat 0x13. ACE's PDB-sourced Channel enum corroborates. Introduces
`RetailLogTextType`, the 34-value named enum for the wire LogTextType space
(values only, no color — Core stays presentation-free).
BLOCKER 2 — three ChatLog.OnSystemMessage sinks (ChatVM.ShowSystemMessage,
LiveSessionRuntimeFactory's ShowSystemMessage delegate,
HeadlessGameplayOperations.DisplayMessage) were typing ALL
ClientCommandController output 0x1A (bright red), including informational
command output (@version, /loc, friends list, usage lines). Retail types
the great majority of that output 0x00 Default (green) and reserves 0x1A
for genuine refusals/errors. Reverted to 0x00 with a comment noting the
refusal-vs-info split lands with CH2's SpewBox producer rewiring. The five
App composition sites that pass 0x1A for actual refusal text
(InteractionRetainedUiComposition, SessionPlayerComposition) were already
correct and are untouched (aside from converting the literal to the new
enum).
Also: AP-176 divergence-register row for OnWeenieError/OnCombatLine's
single-type approximation of retail's per-code/per-message dispatch; a
carry-forward test for the out-of-range LogTextType color fallback in
ChatWindowController; decomp-confirmed anchors replacing ACE-inferred
citations in CombatChatTranslator and ChatLog.OnPlayerKilled; required
(non-optional) logTextType parameters on OnLocalSpeech/OnTellReceived/
OnCombatLine/OnSelfSent since no production caller relied on a default;
LegacyChannelChatType.Resolve's parameter renamed channelBit -> channelId
with a doc note on multi-bit ids; corrections to the color-table research
doc's §3.3 wire tables; and issue #359 for the pre-existing (not
CH1-introduced) 0x019E PlayerKilled participant-suppression gap retail has
and acdream lacks.
dotnet build clean; full Release suite 11,835 passed / 4 skipped / 0 failed
(11,839 total), up from the CH1 baseline of 11,833/4/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.5 KiB
C#
129 lines
5.5 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Phase J Tier 3: <see cref="ChatPanel.Render"/> must reserve footer
|
|
/// space for the separator + input field so the input stays anchored
|
|
/// at the bottom across window resizes (the user reported the input
|
|
/// disappearing when the window shrank). The pattern is the standard
|
|
/// ImGui chat-window layout: a scrollable child filling
|
|
/// <c>(0, -footerHeight)</c>, then the separator + input below it.
|
|
/// </summary>
|
|
public sealed class ChatPanelLayoutTests
|
|
{
|
|
private sealed class NoBus : ICommandBus
|
|
{
|
|
public void Publish<T>(T command) where T : notnull { /* no-op */ }
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_OrderIs_Begin_BeginChild_EndChild_Separator_InputTextSubmit_End()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("seed", chatType: 0);
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer();
|
|
|
|
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
|
|
|
|
var methods = renderer.Calls.Select(c => c.Method).ToList();
|
|
int beginIdx = methods.IndexOf("Begin");
|
|
int beginChildIdx = methods.IndexOf("BeginChild");
|
|
int endChildIdx = methods.IndexOf("EndChild");
|
|
// L.0 follow-up: Copy-mode toggle adds a Separator above the
|
|
// chat tail, so multiple Separators now exist. The footer
|
|
// separator (the one we care about for input layout) is the
|
|
// LAST one — between EndChild and the input field.
|
|
int separatorIdx = methods.LastIndexOf("Separator");
|
|
int inputSubmitIdx = methods.IndexOf("InputTextSubmit");
|
|
int endIdx = methods.IndexOf("End");
|
|
|
|
// All present
|
|
Assert.True(beginIdx >= 0, "Begin missing");
|
|
Assert.True(beginChildIdx >= 0, "BeginChild missing");
|
|
Assert.True(endChildIdx >= 0, "EndChild missing");
|
|
Assert.True(separatorIdx >= 0, "Separator missing");
|
|
Assert.True(inputSubmitIdx >= 0, "InputTextSubmit missing");
|
|
Assert.True(endIdx >= 0, "End missing");
|
|
|
|
// Order: Begin < BeginChild < EndChild < Separator < InputTextSubmit < End
|
|
Assert.True(beginIdx < beginChildIdx);
|
|
Assert.True(beginChildIdx < endChildIdx);
|
|
Assert.True(endChildIdx < separatorIdx);
|
|
Assert.True(separatorIdx < inputSubmitIdx);
|
|
Assert.True(inputSubmitIdx < endIdx);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_BeginChild_ReservesNegativeFooterFromFrameHeight()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer { FrameHeightWithSpacingValue = 24f };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
|
|
|
|
// L.0 follow-up: the chat panel now wraps its body in an outer
|
|
// ##chatbody BeginChild (so empty-space clicks can't drag the
|
|
// parent window). The inner ##chattail BeginChild is the one
|
|
// that reserves the footer; that's what this test asserts.
|
|
var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild"
|
|
&& (string)c.Args[0]! == "##chattail");
|
|
var size = (System.Numerics.Vector2)chattailCall.Args[1]!;
|
|
// Width 0 = fill available; height < 0 = "fill minus this".
|
|
// Reserved height should equal FrameHeightWithSpacing + a small
|
|
// separator pad (~6f) so the input never visually clips the
|
|
// last chat line.
|
|
Assert.Equal(0f, size.X);
|
|
Assert.True(size.Y < 0, $"expected negative reserve, got {size.Y}");
|
|
Assert.True(size.Y <= -24f, $"expected at least -24f reserve, got {size.Y}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_NewEntries_ScrollsToBottom()
|
|
{
|
|
// First render establishes the baseline (no auto-scroll because
|
|
// _lastRenderedCount == lines.Count == 0). Then a second render
|
|
// after a new entry should fire SetScrollHereY(1.0f).
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer();
|
|
var ctx = new PanelContext(0.016f, new NoBus());
|
|
|
|
panel.Render(ctx, renderer);
|
|
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
|
|
|
|
// Append a new entry, render again — auto-scroll should fire.
|
|
log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
|
|
renderer.Calls.Clear();
|
|
panel.Render(ctx, renderer);
|
|
|
|
var scrollCall = renderer.Calls.Single(c => c.Method == "SetScrollHereY");
|
|
Assert.Equal(1.0f, (float)scrollCall.Args[0]!);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_NoNewEntries_DoesNotForceScroll()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("seed", chatType: 0);
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer();
|
|
var ctx = new PanelContext(0.016f, new NoBus());
|
|
|
|
// First render establishes count baseline (1 entry). The first
|
|
// render auto-scrolls because lines.Count (1) > _lastRenderedCount
|
|
// (0). Subsequent renders without new entries should NOT scroll.
|
|
panel.Render(ctx, renderer);
|
|
renderer.Calls.Clear();
|
|
panel.Render(ctx, renderer);
|
|
|
|
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
|
|
}
|
|
}
|