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