feat(chat): CT-B4 — @log, and the research block that was a wrong question

CT-B4 was filed as "the plain-text session chat log, path and rotation
UNKNOWN, needs a live check." Both unknowns dissolve once you read the
handler: there is no automatic session log. Retail's @log is a COMMAND.
DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0
does the fopen(name, "a+"), and running it again with no argument closes it.
Nothing rotates because it appends forever, and nothing has a fixed path
because the player names the file.

The path question that DOES exist — where a bare name lands — was answered
all along by retail's own help text, which CH4 extracted verbatim into our
help table a fortnight ago and nobody read: "a log file named Aclog.txt in
your Asheron's Call directory." A blocked question sat on top of a committed
answer.

We cannot use the install directory: the launcher replaces it atomically on
update, so a log written there is wiped by the next update or blocks it. The
client's own log directory is the equivalent that survives. Rooted paths are
honoured verbatim, as retail's fopen would. Register CT-5.

The verb was registered in the help table but NOT in the command catalog, so
/log printed help and did nothing — and the CH4 conformance registry recorded
it as a "server passthrough" precisely because that shape is indistinguishable
from an unimplemented client command. It never went on the wire at all. Both
are corrected, with the totals moved in the same commit rather than left to
drift.

Moving it into the catalog also moves which help table answers for it, so
retail's real text moved to the catalog-verb table in the same change. Without
that, /help log would have silently started printing acdream's own invented
one-line summary — caught by the coverage test, and now pinned by a test that
names the text.

All five replies are byte-decoded from the PDB-paired binary rather than read
off Binary Ninja's previews, which truncate at ~33 characters and would have
lost the second half of every one of them (including the two spaces retail
puts after "Copying chat to %s.").

The writer attaches on OPEN, not at startup — retail's help is explicit that
only what appears after the command is copied — and detaches from the
transcript it actually attached to, so a session teardown cannot leave a live
handler writing into a file the player believes is closed. What gets written
is the composed display line with the shared timestamp, because retail's
fprintf sits inside AddTextToScroll: downstream of composition, upstream of
glyph layout. Logging the raw entry text would have produced a file of bare
fragments with no speakers.

acdream's logs carry no inline tag markup where retail's do, since tags live
beside the text as spans here rather than inside it. Registered as CT-6 rather
than reconstructed purely to write it to a file.

Register: CT-5, CT-6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 11:28:20 +02:00
parent 89db9a794c
commit 0e0a77c9b1
16 changed files with 831 additions and 18 deletions

View file

@ -837,6 +837,7 @@ public sealed class LiveSessionCommandRouterTests
ShowConfirmation: (_, _) => { },
Suicide: () => { },
ClearChat: _ => { },
SetChatLogFile: _ => default,
SaveUi: _ => { },
LoadUi: _ => { },
SaveAutoUi: () => { },

View file

@ -0,0 +1,121 @@
using System;
using System.IO;
using AcDream.App.UI;
using AcDream.Core.Chat;
namespace AcDream.App.Tests.UI;
/// <summary>
/// CT-B4: what actually reaches the <c>@log</c> file.
/// </summary>
public sealed class ChatTranscriptLogWriterTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "acdream-logwriter-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch (IOException) { /* nothing left to say */ }
}
private string Read() => File.ReadAllText(Path.Combine(_directory, "session.txt"));
[Fact]
public void TheComposedDisplayLineIsLoggedRatherThanTheRawMessage()
{
// The entry's own Text is just 'hello' — the speaker and the quotes
// are composition. Retail's log write sits downstream of that
// (fprintf inside AddTextToScroll), so logging entry.Text would give
// a file full of bare fragments with no idea who said them.
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "hello", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"hello\"\n", Read());
}
[Fact]
public void TheTimestampFollowsTheSameOptionTheWindowUses()
{
// Retail computes the stamp ONCE and hands the same string to the
// window and to the file, so the two can never disagree.
using var log = new ChatSessionLog(_directory);
bool stamps = false;
var transcript = new ChatLog { DisplayTimestampsSource = () => stamps };
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "before", 0x02u, false, 0u);
stamps = true;
transcript.OnLocalSpeech("Dww", "after", 0x02u, false, 0u);
log.Close();
string[] lines = Read().Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal("Dww says, \"before\"", lines[0]);
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Dww says, ""after""$", lines[1]);
}
[Fact]
public void NothingIsLoggedBeforeAttachOrAfterDetach()
{
// Retail's help is explicit that logging starts when you type the
// command: only what appears AFTER it is copied.
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
transcript.OnLocalSpeech("Dww", "before attach", 0x02u, false, 0u);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "during", 0x02u, false, 0u);
writer.Detach();
transcript.OnLocalSpeech("Dww", "after detach", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"during\"\n", Read());
}
[Fact]
public void AttachingTwiceDoesNotWriteEveryLineTwice()
{
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "once", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"once\"\n", Read());
}
[Fact]
public void DetachReleasesTheTranscriptItActuallyAttachedTo()
{
// A session teardown replaces the transcript. Detaching from the
// CURRENT one would leave a live handler on the old one, which then
// keeps writing into a file the player believes is closed.
using var log = new ChatSessionLog(_directory);
var first = new ChatLog();
var second = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(first);
writer.Attach(second); // switches transcripts
first.OnLocalSpeech("Dww", "stale", 0x02u, false, 0u);
second.OnLocalSpeech("Dww", "live", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"live\"\n", Read());
}
}

View file

@ -1,4 +1,5 @@
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
@ -432,6 +433,125 @@ public sealed class ClientCommandControllerTests
Assert.Throws<ArgumentOutOfRangeException>(() => controller.Execute(command));
}
// ── CT-B4: retail's @log ────────────────────────────────────────────
[Fact]
public void Log_WithAName_ReportsWhereChatIsGoingAndHowToStop()
{
// Retail's reply, byte-decoded from the paired binary — Binary Ninja
// truncates it at "Copying chat to %s. Run command…". Note the TWO
// spaces after the period; they are retail's.
var messages = new List<string>();
var calls = new List<string>();
ClientCommandController ctrl = NewController(
calls, messages: messages,
chatLog: name => new ChatLogResult(true, false, name, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "aclog.txt"));
Assert.Contains("log:aclog.txt", calls);
Assert.Equal(
"Copying chat to aclog.txt. Run command again with no arguments "
+ "to turn off logging.",
Assert.Single(messages));
}
[Fact]
public void Log_WhenTheFileCannotBeOpened_SaysSoRatherThanClaimingSuccess()
{
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: name => new ChatLogResult(false, false, name, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "C:/nope/x.txt"));
Assert.Equal(
"Failed to redirect to file C:/nope/x.txt!",
Assert.Single(messages));
}
[Fact]
public void Log_WithNoArgument_ClosesTheOpenLogAndSaysBothLines()
{
// CloseLogFile announces the file, then DoSetOutput announces the
// redirect. Two lines, in that order.
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: _ => new ChatLogResult(false, true, string.Empty, "aclog.txt"));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, ""));
Assert.Equal(
["Chat log aclog.txt closed.", "Chat output now directed only to the screen."],
messages);
}
[Fact]
public void Log_WithNoArgumentAndNothingOpen_AsksForAFileName()
{
// The same verb with the same arguments says something DIFFERENT
// depending on whether a log was running — retail branches on
// CloseLogFile's return value, not on the arguments.
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: _ => new ChatLogResult(false, false, string.Empty, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, ""));
Assert.Equal(
"Please specify a file to append chat messages to.",
Assert.Single(messages));
}
[Fact]
public void Log_StartingASecondLogAnnouncesThatTheFirstEnded()
{
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: name => new ChatLogResult(true, true, name, "old.txt"));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "new.txt"));
Assert.Equal("Chat log old.txt closed.", messages[0]);
Assert.StartsWith("Copying chat to new.txt.", messages[1]);
}
[Fact]
public void Log_TakesTheWholeRemainderSoASpacedNameSurvives()
{
// Retail JoinArgs the arguments before using them as a filename.
var calls = new List<string>();
ClientCommandController ctrl = NewController(
calls,
chatLog: name => new ChatLogResult(true, false, name, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "my chat log.txt"));
Assert.Contains("log:my chat log.txt", calls);
}
[Fact]
public void Log_ResolvesFromTheCatalogWithItsWholeRemainderAsTheArgument()
{
// The verb had a help entry since CH4 but no catalog entry, so /log
// printed help and did nothing. This pins the registration.
Assert.True(RetailClientCommandCatalog.TryMatch(
"/log my chat log.txt", out RetailClientCommandCatalog.Match match));
Assert.Equal(ClientCommandId.ChatLogFile, match.Command);
Assert.Equal("my chat log.txt", match.Arguments);
}
private static ClientCommandController NewController(
List<string>? calls = null,
List<uint>? errors = null,
@ -448,7 +568,8 @@ public sealed class ClientCommandControllerTests
// ShowConfirmation calls (e.g. house-abandon's two-stage prompt).
// Defaults to "always accept" so every pre-existing single-stage
// test (Die, etc.) keeps its original behavior unchanged.
Queue<bool>? confirmationResponses = null)
Queue<bool>? confirmationResponses = null,
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null)
{
calls ??= [];
errors ??= [];
@ -483,6 +604,16 @@ public sealed class ClientCommandControllerTests
},
() => calls.Add("suicide"),
all => calls.Add("clear:" + all),
name =>
{
calls.Add("log:" + name);
return chatLog?.Invoke(name)
?? new AcDream.Core.Chat.ChatLogResult(
Opened: name.Length > 0,
Closed: name.Length == 0,
Name: name,
ClosedName: "old.txt");
},
name => calls.Add("saveui:" + name),
name => calls.Add("loadui:" + name),
() => calls.Add("saveautoui"),

View file

@ -0,0 +1,142 @@
using System;
using System.IO;
using AcDream.Core.Chat;
namespace AcDream.Core.Tests.Chat;
/// <summary>
/// Retail's <c>@log</c> file behaviour: append, never rotate, never truncate.
/// </summary>
public sealed class ChatSessionLogTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "acdream-chatlog-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch (IOException) { /* the test already told us what it needed to */ }
}
[Theory]
[InlineData("aclog", "aclog.txt")]
[InlineData("aclog.txt", "aclog.txt")]
[InlineData("aclog.log", "aclog.log")]
// A name that already carries ANY extension is left alone; retail tests
// the extension for emptiness, not for ".txt".
[InlineData("chat.old", "chat.old")]
public void AnExtensionlessNameGainsDotTxt(string given, string expected)
=> Assert.Equal(expected, ChatSessionLog.EnsureExtension(given));
[Fact]
public void LinesLandInTheFileWithTheirTimestampAndANewline()
{
using var log = new ChatSessionLog(_directory);
Assert.True(log.Open("session", out string resolved));
Assert.Equal("session.txt", resolved);
log.Write("13:05:09 ", "Dww tells you, \"hello\"");
log.Write(null, "Welcome to Dereth.");
log.Close();
Assert.Equal(
"13:05:09 Dww tells you, \"hello\"\nWelcome to Dereth.\n",
File.ReadAllText(Path.Combine(_directory, "session.txt")));
}
[Fact]
public void ReopeningTheSameNameAppendsRatherThanTruncating()
{
// Retail's own help promises this: "If this file already exists, it
// will add the additional text to the end of it." Truncating would
// destroy the previous session's log the moment you start a new one.
using var log = new ChatSessionLog(_directory);
log.Open("session", out _);
log.Write(null, "first");
log.Close();
log.Open("session", out _);
log.Write(null, "second");
log.Close();
Assert.Equal(
"first\nsecond\n",
File.ReadAllText(Path.Combine(_directory, "session.txt")));
}
[Fact]
public void OpeningASecondLogClosesTheFirst()
{
// StartCopyOutputToFile calls CloseLogFile before it does anything
// else, so two files can never be open at once.
using var log = new ChatSessionLog(_directory);
log.Open("one", out _);
log.Write(null, "to one");
Assert.True(log.Open("two", out _));
log.Write(null, "to two");
log.Close();
Assert.Equal("to one\n", File.ReadAllText(Path.Combine(_directory, "one.txt")));
Assert.Equal("to two\n", File.ReadAllText(Path.Combine(_directory, "two.txt")));
}
[Fact]
public void CloseReportsWhetherOneWasOpen()
{
// The caller picks between two different retail replies on this, so
// it is load-bearing rather than informational.
using var log = new ChatSessionLog(_directory);
Assert.False(log.Close());
log.Open("session", out _);
Assert.True(log.Close());
Assert.False(log.Close());
}
[Fact]
public void WritingWithNoLogOpenIsANoOp()
{
// The caller hands over every transcript line unconditionally, the way
// retail does, so the closed case has to be silent rather than throw.
using var log = new ChatSessionLog(_directory);
log.Write("13:05:09 ", "nobody is listening");
Assert.False(log.IsOpen);
Assert.Null(log.CurrentName);
Assert.False(Directory.Exists(_directory));
}
[Fact]
public void AnUnopenableNameReportsFailureInsteadOfThrowing()
{
// Retail tells the player "Failed to redirect to file %s!" rather than
// dying, so a bad name is an ordinary answer here.
using var log = new ChatSessionLog(_directory);
// A directory cannot be opened as a file.
Directory.CreateDirectory(Path.Combine(_directory, "taken.txt"));
Assert.False(log.Open("taken.txt", out string resolved));
Assert.Equal("taken.txt", resolved);
Assert.False(log.IsOpen);
}
[Fact]
public void ARootedNameIsHonouredVerbatim()
{
// Retail's fopen takes the string as given; a player who types a full
// path means it.
using var log = new ChatSessionLog(_directory);
string rooted = Path.Combine(_directory, "nested", "elsewhere.txt");
Assert.True(log.Open(rooted, out string resolved));
Assert.Equal(rooted, resolved);
log.Write(null, "here");
log.Close();
Assert.Equal("here\n", File.ReadAllText(rooted));
}
}

View file

@ -415,6 +415,19 @@ public sealed class RetailCommandHelpTableTests
entries[0].LogTextType);
}
[Fact]
public void HelpForLog_StillReturnsRetailsOwnTextNowThatItIsACatalogVerb()
{
// Registering a verb in the catalog CHANGES which help table answers
// for it. "log" had retail's real help under the passthrough table for
// the whole of CH4; adding it to the catalog without moving that text
// would have quietly replaced it with acdream's own one-line summary.
Assert.True(
RetailCommandHelpTable.TryGetCatalogVerbDetailText("log", out string detail));
Assert.Equal(RetailCommandHelpTable.Log, detail);
Assert.StartsWith("@log <name> - Echoes chat text to a logfile.", detail);
}
[Fact]
public void CatalogLeafVerbCoverage_ExtractedVsConfirmedNullVsUnverified_MatchesConsolidatedReviewCount()
{
@ -475,7 +488,9 @@ public sealed class RetailCommandHelpTableTests
// unverified to extracted (its real live-construction is now
// ported -- see RetailCommandHelpTable.MessageTypesDetail) --
// 43/4/0, zero remaining unverified leaf verbs.
Assert.Equal(43, extractedCount);
// CT-B4 (2026-08-21): "log" joined the catalog, bringing its already-
// extracted retail Detail text with it -- 44/4/0.
Assert.Equal(44, extractedCount);
Assert.Equal(4, confirmedNullCount);
Assert.Equal(0, unverifiedCount);
}

View file

@ -73,7 +73,12 @@ public sealed class RetailCommandRegistryConformanceTests
new(Status.Implemented, "on"),
new(Status.Implemented, "off"),
new(Status.Implemented, "title"),
new(Status.ServerPassthrough, "log"), // TS-69
// CT-B4 (2026-08-21): "log" was never a server passthrough. Retail
// handles it entirely client-side — DoSetOutput @0x0057E4F0 opens a
// file, and nothing goes on the wire. It was classified here as a
// passthrough because it had a help entry and no catalog entry, which
// is the shape an unimplemented client command has too.
new(Status.Implemented, "log"),
new(Status.Implemented, "clear"),
new(Status.Implemented, "filter"),
new(Status.Implemented, "unfilter"),
@ -211,8 +216,12 @@ public sealed class RetailCommandRegistryConformanceTests
public void Registry_StatusCountsMatchTheAuditedTotals()
{
Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length));
Assert.Equal(5, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
Assert.Equal(138, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
// CT-B4 moved "log" from ServerPassthrough to Implemented, so these
// two totals shift by one against the CH4 audit. The 152 verb total
// is unchanged, which is what NoDuplicateVerbsAcrossEntries and the
// section counts protect.
Assert.Equal(4, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
Assert.Equal(139, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
}
[Fact]