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

@ -382,6 +382,9 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
InvokeClient(b => b.ShowConfirmation(text, callback)),
Suicide: () => InvokeClient(static b => b.Suicide()),
ClearChat: all => InvokeClient(b => b.ClearChat(all)),
SetChatLogFile: name => ReadClient(
b => b.SetChatLogFile(name),
default(AcDream.Core.Chat.ChatLogResult)),
SaveUi: name => InvokeClient(b => b.SaveUi(name)),
LoadUi: name => InvokeClient(b => b.LoadUi(name)),
SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()),

View file

@ -115,6 +115,16 @@ internal sealed class LiveSessionRuntimeFactory
private readonly TimeSpan _loginCommandDelay;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
/// for why this is not the install directory retail names.
/// </summary>
private readonly string _chatLogDirectory;
private ChatSessionLog? _chatSessionLog;
private ChatTranscriptLogWriter? _chatLogWriter;
public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player,
LiveSessionDomainRuntime domain,
@ -127,7 +137,8 @@ internal sealed class LiveSessionRuntimeFactory
string sessionId = "app",
IReadOnlyList<string>? loginCommands = null,
int loginCommandDelayMs = 500,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
string? chatLogDirectory = null)
{
_player = player ?? throw new ArgumentNullException(nameof(player));
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
@ -146,6 +157,8 @@ internal sealed class LiveSessionRuntimeFactory
throw new ArgumentOutOfRangeException(
nameof(loginCommandDelayMs));
}
_chatLogDirectory = chatLogDirectory
?? AcDream.Platform.ApplicationPathSet.Resolve().LogsDirectory;
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
_timeProvider = timeProvider ?? TimeProvider.System;
@ -252,6 +265,43 @@ internal sealed class LiveSessionRuntimeFactory
connectOptions);
}
/// <summary>
/// Retail's <c>@log</c> file lifecycle
/// (<c>ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0</c> /
/// <c>CloseLogFile @0x0057ACC0</c>). An empty name closes.
/// </summary>
/// <remarks>
/// The writer attaches to the transcript on OPEN rather than at startup,
/// which is what retail's own help promises: "All the information that
/// appears in your chat window AFTER you type this command will be copied".
/// It detaches on close, so a closed log costs nothing per line.
/// <para>
/// The line written is the composed display line, because that is what
/// retail logs — <c>fprintf @0x00563E5B</c> sits inside
/// <c>AddTextToScroll</c>, downstream of composition and upstream of glyph
/// layout. <see cref="ChatLog.Append"/> is acdream's equivalent single
/// fan-in, and it already owns the timestamp decision the log shares.
/// </para>
/// </remarks>
private ChatLogResult SetChatLogFile(string name)
{
ChatSessionLog log = _chatSessionLog ??= new ChatSessionLog(_chatLogDirectory);
ChatTranscriptLogWriter writer = _chatLogWriter ??= new ChatTranscriptLogWriter(log);
string? closedName = log.CurrentName;
writer.Detach();
bool closed = log.Close();
if (string.IsNullOrWhiteSpace(name))
return new ChatLogResult(Opened: false, closed, string.Empty, closedName);
bool opened = log.Open(name, out string resolved);
if (opened)
writer.Attach(_domain.Communication.Chat);
return new ChatLogResult(opened, closed, resolved, closedName);
}
private LiveSessionResetBindings CreateResetBindings(
IRuntimeGenerationResetHost resetHost) => new()
{
@ -582,6 +632,7 @@ internal sealed class LiveSessionRuntimeFactory
_ui.RetailUi?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => _domain.Communication.Chat.Clear(),
SetChatLogFile: SetChatLogFile,
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),

View file

@ -0,0 +1,73 @@
using System;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI;
/// <summary>
/// Copies the chat transcript into retail's <c>@log</c> file.
/// </summary>
/// <remarks>
/// <para>
/// Retail's log write sits INSIDE <c>ClientSystem::AddTextToScroll</c>
/// (<c>fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B</c>) — downstream of
/// composition and upstream of glyph layout. So the log records the finished
/// display line, timestamp included, and does not re-derive one.
/// <see cref="ChatLog.Append"/> is acdream's equivalent single fan-in.
/// </para>
/// <para>
/// Attaching happens on OPEN rather than at startup, which is what retail's
/// own help promises: "All the information that appears in your chat window
/// AFTER you type this command will be copied into a text file."
/// </para>
/// </remarks>
public sealed class ChatTranscriptLogWriter
{
private readonly ChatSessionLog _log;
private ChatLog? _source;
public ChatTranscriptLogWriter(ChatSessionLog log)
=> _log = log ?? throw new ArgumentNullException(nameof(log));
/// <summary>
/// Starts copying <paramref name="source"/>, detaching from whatever was
/// attached before. Re-attaching to the same transcript does not double
/// up.
/// </summary>
public void Attach(ChatLog source)
{
ArgumentNullException.ThrowIfNull(source);
Detach();
_source = source;
source.EntryAppended += Write;
}
/// <summary>
/// Stops copying. Detaches from the instance actually attached to, not
/// from whatever is current — a transcript replaced mid-log must not leave
/// a handler behind on the old one.
/// </summary>
public void Detach()
{
if (_source is null)
return;
_source.EntryAppended -= Write;
_source = null;
}
private void Write(ChatEntry entry)
{
ChatLog? source = _source;
if (source is null)
return;
// The SAME gate the window uses, so a log never disagrees with the
// transcript it is a copy of.
bool stamped = source.DisplayTimestampsSource?.Invoke() == true;
_log.Write(
stamped ? ChatLog.FormatTimestampPrefix(entry.Received) : null,
ChatVM.FormatEntry(entry));
}
}

View file

@ -1,4 +1,5 @@
using System.Globalization;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
@ -34,6 +35,7 @@ public sealed class ClientCommandController
Action<string, Action<bool>> ShowConfirmation,
Action Suicide,
Action<bool> ClearChat,
Func<string, ChatLogResult> SetChatLogFile,
Action<string> SaveUi,
Action<string> LoadUi,
Action SaveAutoUi,
@ -189,6 +191,9 @@ public sealed class ClientCommandController
_bindings.ClearChat(FirstArgument(command.Arguments)
.Equals("all", StringComparison.OrdinalIgnoreCase));
break;
case ClientCommandId.ChatLogFile:
ExecuteChatLogFile(command.Arguments);
break;
case ClientCommandId.SaveUi:
ExecuteUiProfile(command.Arguments, save: true);
break;
@ -407,6 +412,38 @@ public sealed class ClientCommandController
return false;
}
/// <summary>
/// Retail's <c>@log</c> (<c>ClientCommunicationSystem::DoSetOutput
/// @0x0057E4F0</c>). One verb does both jobs: a filename opens a log, no
/// argument closes the open one. All four replies are retail's own
/// strings, byte-decoded from the paired binary because Binary Ninja
/// truncates its previews at ~33 characters.
/// </summary>
private void ExecuteChatLogFile(string arguments)
{
// Retail JoinArgs the remainder, so the name may contain spaces.
string name = arguments.Trim();
ChatLogResult result = _bindings.SetChatLogFile(name);
// CloseLogFile announces itself wherever it is called from, which
// includes the open path — starting a second log tells you the first
// one ended.
if (result.Closed)
_bindings.ShowSystemMessage($"Chat log {result.ClosedName} closed.");
if (name.Length == 0)
{
_bindings.ShowSystemMessage(result.Closed
? "Chat output now directed only to the screen."
: "Please specify a file to append chat messages to.");
return;
}
_bindings.ShowSystemMessage(result.Opened
? $"Copying chat to {result.Name}. Run command again with no arguments to turn off logging."
: $"Failed to redirect to file {result.Name}!");
}
private void ExecuteAway(string arguments)
{
string first = FirstArgument(arguments);