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

@ -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"),