Path.GetInvalidFileNameChars() is platform-dependent: on Linux it is only '/' and NUL, so a backslash in a server or character name survived sanitisation there and JournalFileTests.TheFileNameFollowsRetailsPattern failed on the Linux runner while passing on Windows. Sanitise against a fixed set (Windows' printable invalid chars plus all control chars) so one name maps to one file name on every platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
231 lines
8.9 KiB
C#
231 lines
8.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Text;
|
|
|
|
namespace AcDream.Core.Journal;
|
|
|
|
/// <summary>The outcome of reading a journal file.</summary>
|
|
/// <param name="Pages">The pages read, oldest first.</param>
|
|
/// <param name="Error">
|
|
/// Retail's own message when the file is unusable, or null on success. An
|
|
/// ABSENT file is success with no pages, not an error — a character who has
|
|
/// never written a page has no file.
|
|
/// </param>
|
|
public readonly record struct JournalReadResult(
|
|
IReadOnlyList<JournalPage> Pages,
|
|
string? Error);
|
|
|
|
/// <summary>
|
|
/// Retail's journal file — <c>gmJournalUI::LoadPages @0x00496AC0</c> and
|
|
/// <c>SavePages @0x00497270</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A plain tagged text file, one tag per line, <c><NEWP></c> opening each
|
|
/// page. Retail writes it with <c>fopen</c> mode <c>w+</c> — the whole file is
|
|
/// rewritten, never appended.
|
|
/// </para>
|
|
/// <para>
|
|
/// A file that does not OPEN with a page marker is refused outright, with its
|
|
/// own message. That is retail being strict about a file the player could have
|
|
/// hand-edited, and it is ported rather than softened: silently accepting a
|
|
/// malformed file would scatter the first page's text into no page at all.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class JournalFile
|
|
{
|
|
private const string NewPage = "<NEWP>";
|
|
private const string PageNumber = "<PNUM>";
|
|
private const string Label = "<LABE>";
|
|
private const string Title = "<TITL>";
|
|
private const string Notes = "<NOTE>";
|
|
private const string Days = "<DAYS>";
|
|
private const string Hours = "<HOUR>";
|
|
private const string Minutes = "<MINU>";
|
|
private const string LocationX = "<LOCX>";
|
|
private const string LocationY = "<LOCY>";
|
|
private const string RunningTime = "<TIME>";
|
|
|
|
/// <summary>Retail's own load failure, byte-decoded from the paired binary.</summary>
|
|
public const string MalformedFileMessage =
|
|
"Problem loading journal: Your journal file does not create a new page!";
|
|
|
|
/// <summary>
|
|
/// The per-character file name — retail's <c>"%s%s-%s-%s.txt"</c> with the
|
|
/// literal prefix <c>"Journal"</c> both call sites pass.
|
|
/// </summary>
|
|
public static string FileNameFor(string serverName, string characterName)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
|
return $"Journal-{Sanitize(serverName)}-{Sanitize(characterName)}.txt";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Strips anything a file name cannot carry. Retail had no need for this —
|
|
/// its server and character names never contained a path separator — but a
|
|
/// name is outside data here and must not be able to redirect a write.
|
|
///
|
|
/// <para>The set is FIXED, not <c>Path.GetInvalidFileNameChars()</c>: that
|
|
/// API is platform-dependent (on Linux it is only '/' and NUL), so a
|
|
/// backslash survived sanitisation there and the same character produced
|
|
/// two different file names on the two CI platforms (linux-portable,
|
|
/// 2026-08-23). Windows' set is the strictest of the supported platforms;
|
|
/// applying it everywhere keeps one name mapping to one file name.</para>
|
|
/// </summary>
|
|
private static readonly char[] InvalidFileNameChars =
|
|
['"', '<', '>', '|', ':', '*', '?', '\\', '/'];
|
|
|
|
private static string Sanitize(string value)
|
|
{
|
|
var text = new StringBuilder(value.Length);
|
|
foreach (char c in value)
|
|
{
|
|
bool invalid = c < ' '
|
|
|| Array.IndexOf(InvalidFileNameChars, c) >= 0;
|
|
text.Append(invalid ? '_' : c);
|
|
}
|
|
return text.ToString();
|
|
}
|
|
|
|
public static JournalReadResult Read(string text)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(text);
|
|
|
|
var pages = new List<JournalPage>();
|
|
JournalPage? current = null;
|
|
bool sawAnyContent = false;
|
|
|
|
foreach (string rawLine in text.Split('\n'))
|
|
{
|
|
string line = rawLine.TrimEnd('\r');
|
|
if (line.Length == 0)
|
|
continue;
|
|
|
|
if (!line.StartsWith('<'))
|
|
{
|
|
// Retail's reader is tag-driven; a line with no tag belongs to
|
|
// nothing and is skipped rather than guessed at.
|
|
sawAnyContent = true;
|
|
continue;
|
|
}
|
|
|
|
string tag = line.Length >= 6 ? line[..6] : line;
|
|
string value = line.Length > 7 ? line[7..] : string.Empty;
|
|
|
|
if (tag == NewPage)
|
|
{
|
|
if (current is not null)
|
|
pages.Add(current);
|
|
current = JournalPage.Empty;
|
|
sawAnyContent = true;
|
|
continue;
|
|
}
|
|
|
|
if (current is null)
|
|
{
|
|
// Content before the first page marker: the file does not
|
|
// create a new page.
|
|
return new JournalReadResult([], MalformedFileMessage);
|
|
}
|
|
|
|
sawAnyContent = true;
|
|
current = tag switch
|
|
{
|
|
Label => current with { Label = value },
|
|
Title => current with { Title = value },
|
|
Notes => current with { Notes = value },
|
|
Days => current with { TimerDays = ParseInt(value) },
|
|
Hours => current with { TimerHours = ParseInt(value) },
|
|
Minutes => current with { TimerMinutes = ParseInt(value) },
|
|
LocationX => current with
|
|
{
|
|
LocationX = ParseFloat(value),
|
|
HasLocation = true,
|
|
},
|
|
LocationY => current with
|
|
{
|
|
LocationY = ParseFloat(value),
|
|
HasLocation = true,
|
|
},
|
|
RunningTime => current with { RunningTimerSeconds = ParseFloat(value) },
|
|
// <PNUM> is written but carries no information the reader
|
|
// needs: page order IS file order. Anything unrecognised is
|
|
// skipped for the same reason a newer client's tag should not
|
|
// make an older one refuse the file.
|
|
_ => current,
|
|
};
|
|
}
|
|
|
|
if (current is not null)
|
|
pages.Add(current);
|
|
|
|
// An empty file is a character who has never written a page.
|
|
if (pages.Count == 0 && sawAnyContent)
|
|
return new JournalReadResult([], MalformedFileMessage);
|
|
|
|
for (int i = 0; i < pages.Count; i++)
|
|
pages[i] = pages[i].Clipped();
|
|
|
|
return new JournalReadResult(pages, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the pages in retail's tag order. Every page is written whole,
|
|
/// including empty fields, because that is what retail's own writer does
|
|
/// and a reader keyed on tag presence must not have to infer absence.
|
|
/// </summary>
|
|
public static string Write(IReadOnlyList<JournalPage> pages)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pages);
|
|
|
|
var text = new StringBuilder();
|
|
for (int i = 0; i < pages.Count; i++)
|
|
{
|
|
JournalPage page = pages[i];
|
|
text.Append(NewPage).Append('\n');
|
|
Append(text, PageNumber, (i + 1).ToString(CultureInfo.InvariantCulture));
|
|
Append(text, Label, page.Label);
|
|
Append(text, Title, page.Title);
|
|
Append(text, Notes, page.Notes);
|
|
Append(text, Days, page.TimerDays.ToString(CultureInfo.InvariantCulture));
|
|
Append(text, Hours, page.TimerHours.ToString(CultureInfo.InvariantCulture));
|
|
Append(text, Minutes, page.TimerMinutes.ToString(CultureInfo.InvariantCulture));
|
|
if (page.HasLocation)
|
|
{
|
|
Append(text, LocationX, Format(page.LocationX));
|
|
Append(text, LocationY, Format(page.LocationY));
|
|
}
|
|
|
|
Append(text, RunningTime, Format(page.RunningTimerSeconds));
|
|
}
|
|
|
|
return text.ToString();
|
|
|
|
static void Append(StringBuilder text, string tag, string value)
|
|
{
|
|
// A newline inside a value would read back as a separate line and
|
|
// silently truncate the field. Retail's single-line edit boxes
|
|
// cannot produce one; the multi-line notes box can, so they are
|
|
// folded to spaces rather than corrupting the file.
|
|
text.Append(tag).Append(' ')
|
|
.Append(value.Replace('\n', ' ').Replace('\r', ' '))
|
|
.Append('\n');
|
|
}
|
|
}
|
|
|
|
private static string Format(double value) =>
|
|
value.ToString("0.######", CultureInfo.InvariantCulture);
|
|
|
|
private static int ParseInt(string value) =>
|
|
int.TryParse(value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)
|
|
? result
|
|
: 0;
|
|
|
|
private static float ParseFloat(string value) =>
|
|
float.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float result)
|
|
? result
|
|
: 0f;
|
|
}
|