diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bedfc289..720737ee 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -494,6 +494,8 @@ equivalence argument (promote to AD/AP) or a fix. | CT-2 | No client-side chat word filtering. Retail runs every transcript line through a taboo table when the `FilterLanguage` option is on and SUBSTITUTES matches; acdream performs no substitution at all. The option itself is kept and still stores/ships its bit to the server exactly as retail does | `src/AcDream.Core.Net/GameEventWiring.cs` (no filter in the AddText path); option at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | DELIBERATE PRODUCT DECISION by the user, 2026-08-21: "I do not want any censoring." Not an oversight and not a porting gap | A player who enables FilterLanguage expecting retail's behaviour sees unfiltered text. No state, wire or server-visible effect — the option bit is still sent, so anything the SERVER gates on it behaves normally | `PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW @0x00682A30` inside `ClientSystem::AddTextToScroll @0x00563C50`; matching at `TabooTable::CreateCheckString @0x00681570` / `StringMatchesFilter @0x00681600` | | CT-3 | A media `Pause` step holds for its `MinDuration`; retail authors a min AND a max and acdream ignores the max. Every sequence measured so far sets them equal, so nothing shipped is affected | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`, the `Pause` case) | Whether the range means a random hold, a ramp, or a min-with-a-frame-budget ceiling is NOT determinable from the decomp, and picking one would be a guess dressed as a port. Using the min is the one reading that is right in every interpretation for the equal-valued case we can actually observe | A sequence authoring min != max would animate faster than retail. None does in the elements dumped so far; if one is found, the reading has to be measured before it is implemented | `MediaDescPause` in the LayoutDesc dat; playback at `UIElement::AnimateMedia` | | CT-4 | A media `Jump`/`State` step with a probability below 1 FALLS THROUGH rather than branching; retail rolls for it | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`) | The roll's distribution and its re-roll cadence (per visit? per state entry?) are not in the decomp. Falling through is the conservative direction: a sequence that ends early stops animating, where treating it as certain would animate forever and could pin a state that never hands off | A probabilistic sequence plays its deterministic tail instead of its branch. The chat indicator authors p=1 throughout, so it is exact there | `MediaDescJump{Probability}` / `MediaDescState{Probability}` in the LayoutDesc dat | +| CT-5 | A bare `@log` filename lands in the client's own log directory (`ApplicationPathSet.LogsDirectory`), not the install directory retail names ("a log file named Aclog.txt in your Asheron's Call directory"). Rooted paths are honoured verbatim, as retail's `fopen` would | `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`_chatLogDirectory`); `src/AcDream.Core/Chat/ChatSessionLog.cs` | acdream's launcher replaces the install directory atomically on update, so a log written there is wiped by the next update or blocks it outright. Retail had no updater with that property. The client's own data directory is the equivalent that survives | A player following retail-era instructions looks for the file next to the executable and does not find it. The `/log` reply names the file, not the directory, so the path is discoverable only from this row and the code | `ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0`; help text at `DoSetOutputHelp @0x0057A950` | +| CT-6 | The `@log` file records the composed line WITHOUT retail's inline text-tag markup. Retail's `fprintf` runs before glyph parsing, so its logs contain literal `` markers around tagged names | `src/AcDream.App/UI/ChatTranscriptLogWriter.cs` | acdream never puts markup in the line: `ChatVM` carries tags as SPANS beside the text (CT-A2/A3), so there is no markup at that seam to preserve. Reconstructing it purely to write it to a file would be inventing a string the client does not otherwise produce | A log diffed against a retail-era log differs on tagged lines — acdream's are the clean ones. No in-client effect | `ClientSystem::AddTextToScroll` write at `@0x00563E5B`, upstream of `UIElement_Text::InqGlyphs @0x00468EA0` | --- diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md index b8cde931..d81c7871 100644 --- a/docs/plans/2026-08-21-chat-text-tag-campaign.md +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -1,14 +1,17 @@ # Campaign CT — complete chat parity (system + GUI) -**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. Two -items deliberately not shipped: CT-B3 (word filtering — dropped by user -direction, register row CT-2) and CT-B4 (session chat log — research-blocked). +**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. +CT-B4 landed 2026-08-21 after the research block turned out to rest on a wrong +premise (see the slice). One item deliberately not shipped: CT-B3 (word +filtering — dropped by user direction, register row CT-2). -**Carried forward:** multi-frame state media. Retail's unseen-text indicator -blinks because its `Normal` state authors SIX image frames; our importer keeps -one file per state, so nothing authored can animate. The indicator is -state-driven and correct today and gains the blink for free once that -capability lands. It is the first known customer, not the only one. +**Carried forward:** ~~multi-frame state media~~ **DONE 2026-08-21.** The +importer now keeps the whole authored sequence and `UiMediaSequence` plays it. +Measuring the real data (`LayoutDump --media 0x1000048C`) corrected the +behaviour as well as enabling it: the indicator blinks three times over three +seconds and then hands off to `Ghosted`, hiding itself. Retail's is a transient +attention-flash, not a badge that stays lit until you scroll down. Register +rows CT-3, CT-4. **Goal, set by the user 2026-08-21: complete retail parity for the chat system AND the chat GUI.** Not "fix the green name" — that was the symptom @@ -127,9 +130,29 @@ Nothing is user-visible until A4. `TabooTable::CreateCheckString @0x00681570` normalises a candidate before `StringMatchesFilter @0x00681600` compares it, which is how retail catches obfuscated spellings. -- **CT-B4** The plain-text session chat log (`ClientSystem::s_pLogFile`). We - write none. **Research-blocked**: path and rotation are UNKNOWN and are not - in the decomp — needs a live check or a dat/filesystem probe. +- **CT-B4** ~~The plain-text session chat log~~ **DONE — and the premise was + wrong.** There is no automatic session log to have a path for. Retail's + `@log` is a COMMAND: `ClientCommunicationSystem::DoSetOutput @0x0057E4F0` + takes a filename, `StartCopyOutputToFile @0x0057C8A0` does the + `fopen(name, "a+")`, and running it again with no argument closes it. So + "path and rotation UNKNOWN" was asking a question the design does not have: + the player names the file, and there is no rotation because it appends + forever. + + The path question that DOES exist — where a bare name lands — is answered by + retail's own help text, which the CH4 help table already carried verbatim + without anyone reading it: "a log file named Aclog.txt **in your Asheron's + Call directory**". acdream cannot use the install directory (the launcher + replaces it atomically on update), so a bare name lands in the client's own + log directory. Rooted paths are honoured verbatim. Register row CT-5. + + Landed with it: the verb registered in the catalog (it had a help entry + since CH4 but no catalog entry, so `/log` printed help and did nothing), + retail's `.txt`-for-extensionless rule, all five reply strings byte-decoded + from the paired binary, and the writer attached at OPEN so only text after + the command is copied. The line logged is the composed display line with the + shared timestamp, because retail's `fprintf` sits inside `AddTextToScroll` + — downstream of composition, upstream of glyph layout. ### Group C — chat GUI @@ -166,7 +189,6 @@ Nothing is user-visible until A4. Blocks nothing in Group A, but decides whether other tag shapes exist. - Whether retail's transcript supports text selection distinctly from the entry field (blocks CT-C4's scope). -- The chat log file's path and rotation (blocks CT-B4). - Whether a chat-specific sound cue exists — a grep came back empty, which is weak evidence, not proof of absence. diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 2d781197..9b7aa83a 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -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()), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index f5af448e..6460bc14 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -115,6 +115,16 @@ internal sealed class LiveSessionRuntimeFactory private readonly TimeSpan _loginCommandDelay; private readonly TimeProvider _timeProvider; + /// + /// Where a bare @log filename lands. See + /// for why this is not the install directory retail names. + /// + 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? 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); } + /// + /// Retail's @log file lifecycle + /// (ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0 / + /// CloseLogFile @0x0057ACC0). An empty name closes. + /// + /// + /// 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. + /// + /// The line written is the composed display line, because that is what + /// retail logs — fprintf @0x00563E5B sits inside + /// AddTextToScroll, downstream of composition and upstream of glyph + /// layout. is acdream's equivalent single + /// fan-in, and it already owns the timestamp decision the log shares. + /// + /// + 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(), diff --git a/src/AcDream.App/UI/ChatTranscriptLogWriter.cs b/src/AcDream.App/UI/ChatTranscriptLogWriter.cs new file mode 100644 index 00000000..438fe094 --- /dev/null +++ b/src/AcDream.App/UI/ChatTranscriptLogWriter.cs @@ -0,0 +1,73 @@ +using System; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI; + +/// +/// Copies the chat transcript into retail's @log file. +/// +/// +/// +/// Retail's log write sits INSIDE ClientSystem::AddTextToScroll +/// (fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B) — downstream of +/// composition and upstream of glyph layout. So the log records the finished +/// display line, timestamp included, and does not re-derive one. +/// is acdream's equivalent single fan-in. +/// +/// +/// 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." +/// +/// +public sealed class ChatTranscriptLogWriter +{ + private readonly ChatSessionLog _log; + private ChatLog? _source; + + public ChatTranscriptLogWriter(ChatSessionLog log) + => _log = log ?? throw new ArgumentNullException(nameof(log)); + + /// + /// Starts copying , detaching from whatever was + /// attached before. Re-attaching to the same transcript does not double + /// up. + /// + public void Attach(ChatLog source) + { + ArgumentNullException.ThrowIfNull(source); + Detach(); + _source = source; + source.EntryAppended += Write; + } + + /// + /// 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. + /// + 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)); + } +} diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs index a4cf2145..0b34596e 100644 --- a/src/AcDream.App/UI/ClientCommandController.cs +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -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> ShowConfirmation, Action Suicide, Action ClearChat, + Func SetChatLogFile, Action SaveUi, Action 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; } + /// + /// Retail's @log (ClientCommunicationSystem::DoSetOutput + /// @0x0057E4F0). 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. + /// + 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); diff --git a/src/AcDream.Core/Chat/ChatSessionLog.cs b/src/AcDream.Core/Chat/ChatSessionLog.cs new file mode 100644 index 00000000..94514621 --- /dev/null +++ b/src/AcDream.Core/Chat/ChatSessionLog.cs @@ -0,0 +1,184 @@ +using System; +using System.IO; +using System.Text; + +namespace AcDream.Core.Chat; + +/// +/// Retail's @log chat-to-file capture. +/// +/// +/// +/// This is NOT an automatic session transcript. Retail opens a log only when +/// the player asks for one by name — ClientCommunicationSystem::DoSetOutput +/// @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0 +/// does the fopen(name, "a+"), and running the command again with no +/// argument closes it. The file is APPENDED to, never rotated and never +/// truncated, which is what retail's own help promises: "If this file already +/// exists, it will add the additional text to the end of it." +/// +/// +/// Every line goes out as timestamp + text + "\n" +/// (fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B, inside +/// ClientSystem::AddTextToScroll), with the timestamp present only when +/// the DisplayTimeStamps option is on — the log and the chat window +/// share the one stamp rather than deciding separately. +/// +/// +/// File handling only. What a line SAYS is composed upstream, because retail +/// logs the finished display line rather than re-deriving one. +/// +/// +public sealed class ChatSessionLog : IDisposable +{ + private readonly string _baseDirectory; + private StreamWriter? _writer; + + /// + /// Where a bare filename lands. Retail says "your Asheron's Call + /// directory" — its install directory — which acdream cannot use: the + /// launcher replaces the install atomically on update, so a file written + /// there is wiped or blocks the update. The client's own log directory is + /// the equivalent that survives. Rooted paths are still honoured verbatim, + /// as retail's fopen would. + /// + public ChatSessionLog(string baseDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + _baseDirectory = baseDirectory; + } + + /// The name the player asked for, or null when nothing is open. + public string? CurrentName { get; private set; } + + public bool IsOpen => _writer is not null; + + /// + /// Retail appends .txt to an extensionless name + /// (PSUtils::get_extension against the empty string, then + /// += ".txt"). A name that already carries ANY extension is left + /// alone — "chat.old" stays "chat.old" rather than becoming "chat.old.txt". + /// + public static string EnsureExtension(string name) + => Path.GetExtension(name).Length == 0 ? name + ".txt" : name; + + /// + /// Opens for append, closing any log already open + /// first — retail's StartCopyOutputToFile calls CloseLogFile + /// before it does anything else. + /// + /// + /// Whether the file opened. Retail reports failure to the player rather + /// than treating it as fatal, so an unwritable path is an ordinary answer + /// here and not an exception. + /// + public bool Open(string name, out string resolvedName) + { + resolvedName = string.Empty; + Close(); + + if (string.IsNullOrWhiteSpace(name)) + return false; + + resolvedName = EnsureExtension(name.Trim()); + + try + { + string path = Path.IsPathRooted(resolvedName) + ? resolvedName + : Path.Combine(_baseDirectory, resolvedName); + + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + _writer = new StreamWriter( + new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + // Flushed per line: a chat log's whole point is being readable + // while the client is still running, and a crash must not eat + // the tail that explains it. + AutoFlush = true, + }; + CurrentName = resolvedName; + return true; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + _writer = null; + CurrentName = null; + return false; + } + } + + /// Closes the open log, if any. + /// + /// Whether one WAS open. Retail's CloseLogFile returns this and its + /// caller uses it to choose between "closed" and "please specify a file". + /// + public bool Close() + { + if (_writer is null) + return false; + + try + { + _writer.Dispose(); + } + catch (IOException) + { + // The line is already gone; failing to flush a closing file is not + // something the player can act on. + } + + _writer = null; + CurrentName = null; + return true; + } + + /// + /// Writes one transcript line. A no-op when no log is open, so the caller + /// can hand every line over unconditionally the way retail does. + /// + public void Write(string? timestampPrefix, string? text) + { + StreamWriter? writer = _writer; + if (writer is null) + return; + + try + { + writer.Write(timestampPrefix); + writer.Write(text); + writer.Write('\n'); + } + catch (IOException) + { + // A vanished drive or a full disk stops the log; it must not stop + // chat. Retail ignores fprintf's return except to warn about very + // long lines. + } + } + + public void Dispose() => Close(); +} + +/// +/// What one @log invocation did, so the caller can print retail's +/// replies without reaching into the file handle. +/// +/// A new log was opened. +/// +/// A log that WAS open got closed. True both when closing is the whole point +/// and when opening a second log displaced the first — retail's +/// StartCopyOutputToFile closes before it opens, and announces it. +/// +/// The resolved name of the new log, extension included. +/// The name of the log that was closed, if any. +public readonly record struct ChatLogResult( + bool Opened, + bool Closed, + string Name, + string? ClosedName); diff --git a/src/AcDream.Runtime/Chat/ClientCommandId.cs b/src/AcDream.Runtime/Chat/ClientCommandId.cs index 42aee416..e6ca5f7b 100644 --- a/src/AcDream.Runtime/Chat/ClientCommandId.cs +++ b/src/AcDream.Runtime/Chat/ClientCommandId.cs @@ -24,6 +24,12 @@ public enum ClientCommandId ShowLastCorpseLocation, Die, ClearChat, + + /// + /// Retail's @log: start or stop copying chat to a file. + /// ClientCommunicationSystem::DoSetOutput @0x0057E4F0. + /// + ChatLogFile, SaveUi, LoadUi, SaveAutoUi, diff --git a/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs index f819526d..ed534a74 100644 --- a/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs @@ -145,6 +145,15 @@ public static class RetailClientCommandCatalog "/clear [all]", "/clear [all] - Clears the current chat window, or every chat window."); + /// + /// Retail takes the whole remainder as the filename — DoSetOutput + /// calls JoinArgs first, so a name with spaces in it works. + /// + private static readonly Definition ChatLogFile = AnyArguments( + ClientCommandId.ChatLogFile, + "/log [filename]", + "/log [filename] - Echoes chat text to a logfile, or stops if already logging."); + private static readonly Definition SaveUi = AnyArguments( ClientCommandId.SaveUi, "/saveui [filename]", @@ -497,6 +506,7 @@ public static class RetailClientCommandCatalog ["cor"] = Corpse, ["die"] = Die, ["clear"] = Clear, + ["log"] = ChatLogFile, ["saveui"] = SaveUi, ["loadui"] = LoadUi, ["saveautoui"] = SaveAutoUi, diff --git a/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs index 4e07b679..4e7054d3 100644 --- a/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs @@ -949,6 +949,12 @@ public static class RetailCommandHelpTable private static readonly FrozenDictionary CatalogVerbDetailByVerb = new Dictionary(StringComparer.OrdinalIgnoreCase) { + // CT-B4 (2026-08-21): "log" became a catalog verb, so its retail + // help has to be reachable through the CATALOG path too — the + // catalog's own one-line summary is acdream-authored, and showing + // that in place of retail's text is exactly what this table exists + // to prevent. + ["log"] = Log, ["lifestone"] = LifestoneDetail, ["lif"] = LifestoneDetail, ["ls"] = LifestoneDetail, diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 80a656e2..82b2db72 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -837,6 +837,7 @@ public sealed class LiveSessionCommandRouterTests ShowConfirmation: (_, _) => { }, Suicide: () => { }, ClearChat: _ => { }, + SetChatLogFile: _ => default, SaveUi: _ => { }, LoadUi: _ => { }, SaveAutoUi: () => { }, diff --git a/tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs b/tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs new file mode 100644 index 00000000..ffe5258b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using AcDream.App.UI; +using AcDream.Core.Chat; + +namespace AcDream.App.Tests.UI; + +/// +/// CT-B4: what actually reaches the @log file. +/// +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()); + } +} diff --git a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs index 92febc2a..5d5a85a7 100644 --- a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs @@ -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(() => 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(); + var calls = new List(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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? calls = null, List? 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? confirmationResponses = null) + Queue? confirmationResponses = null, + Func? 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"), diff --git a/tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs b/tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs new file mode 100644 index 00000000..ae343d0d --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Retail's @log file behaviour: append, never rotate, never truncate. +/// +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)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs index 62b8ddf1..bdea46ed 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs @@ -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 - 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); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs index 3ca66f74..df4142b3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs @@ -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]