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);