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:
parent
89db9a794c
commit
0e0a77c9b1
16 changed files with 831 additions and 18 deletions
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
73
src/AcDream.App/UI/ChatTranscriptLogWriter.cs
Normal file
73
src/AcDream.App/UI/ChatTranscriptLogWriter.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
184
src/AcDream.Core/Chat/ChatSessionLog.cs
Normal file
184
src/AcDream.Core/Chat/ChatSessionLog.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>@log</c> chat-to-file capture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is NOT an automatic session transcript. Retail opens a log only when
|
||||
/// the player asks for one by name — <c>ClientCommunicationSystem::DoSetOutput
|
||||
/// @0x0057E4F0</c> takes a filename, <c>StartCopyOutputToFile @0x0057C8A0</c>
|
||||
/// does the <c>fopen(name, "a+")</c>, 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."
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every line goes out as <c>timestamp + text + "\n"</c>
|
||||
/// (<c>fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B</c>, inside
|
||||
/// <c>ClientSystem::AddTextToScroll</c>), with the timestamp present only when
|
||||
/// the <c>DisplayTimeStamps</c> option is on — the log and the chat window
|
||||
/// share the one stamp rather than deciding separately.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// File handling only. What a line SAYS is composed upstream, because retail
|
||||
/// logs the finished display line rather than re-deriving one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ChatSessionLog : IDisposable
|
||||
{
|
||||
private readonly string _baseDirectory;
|
||||
private StreamWriter? _writer;
|
||||
|
||||
/// <param name="baseDirectory">
|
||||
/// 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 <c>fopen</c> would.
|
||||
/// </param>
|
||||
public ChatSessionLog(string baseDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
|
||||
_baseDirectory = baseDirectory;
|
||||
}
|
||||
|
||||
/// <summary>The name the player asked for, or null when nothing is open.</summary>
|
||||
public string? CurrentName { get; private set; }
|
||||
|
||||
public bool IsOpen => _writer is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Retail appends <c>.txt</c> to an extensionless name
|
||||
/// (<c>PSUtils::get_extension</c> against the empty string, then
|
||||
/// <c>+= ".txt"</c>). A name that already carries ANY extension is left
|
||||
/// alone — "chat.old" stays "chat.old" rather than becoming "chat.old.txt".
|
||||
/// </summary>
|
||||
public static string EnsureExtension(string name)
|
||||
=> Path.GetExtension(name).Length == 0 ? name + ".txt" : name;
|
||||
|
||||
/// <summary>
|
||||
/// Opens <paramref name="name"/> for append, closing any log already open
|
||||
/// first — retail's <c>StartCopyOutputToFile</c> calls <c>CloseLogFile</c>
|
||||
/// before it does anything else.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 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.
|
||||
/// </returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes the open log, if any.</summary>
|
||||
/// <returns>
|
||||
/// Whether one WAS open. Retail's <c>CloseLogFile</c> returns this and its
|
||||
/// caller uses it to choose between "closed" and "please specify a file".
|
||||
/// </returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one <c>@log</c> invocation did, so the caller can print retail's
|
||||
/// replies without reaching into the file handle.
|
||||
/// </summary>
|
||||
/// <param name="Opened">A new log was opened.</param>
|
||||
/// <param name="Closed">
|
||||
/// 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
|
||||
/// <c>StartCopyOutputToFile</c> closes before it opens, and announces it.
|
||||
/// </param>
|
||||
/// <param name="Name">The resolved name of the new log, extension included.</param>
|
||||
/// <param name="ClosedName">The name of the log that was closed, if any.</param>
|
||||
public readonly record struct ChatLogResult(
|
||||
bool Opened,
|
||||
bool Closed,
|
||||
string Name,
|
||||
string? ClosedName);
|
||||
|
|
@ -24,6 +24,12 @@ public enum ClientCommandId
|
|||
ShowLastCorpseLocation,
|
||||
Die,
|
||||
ClearChat,
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>@log</c>: start or stop copying chat to a file.
|
||||
/// <c>ClientCommunicationSystem::DoSetOutput @0x0057E4F0</c>.
|
||||
/// </summary>
|
||||
ChatLogFile,
|
||||
SaveUi,
|
||||
LoadUi,
|
||||
SaveAutoUi,
|
||||
|
|
|
|||
|
|
@ -145,6 +145,15 @@ public static class RetailClientCommandCatalog
|
|||
"/clear [all]",
|
||||
"/clear [all] - Clears the current chat window, or every chat window.");
|
||||
|
||||
/// <summary>
|
||||
/// Retail takes the whole remainder as the filename — <c>DoSetOutput</c>
|
||||
/// calls <c>JoinArgs</c> first, so a name with spaces in it works.
|
||||
/// </summary>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -949,6 +949,12 @@ public static class RetailCommandHelpTable
|
|||
private static readonly FrozenDictionary<string, string> CatalogVerbDetailByVerb =
|
||||
new Dictionary<string, string>(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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue