diff --git a/src/AcDream.Core/Journal/JournalFile.cs b/src/AcDream.Core/Journal/JournalFile.cs
new file mode 100644
index 00000000..6cd08331
--- /dev/null
+++ b/src/AcDream.Core/Journal/JournalFile.cs
@@ -0,0 +1,219 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+namespace AcDream.Core.Journal;
+
+/// The outcome of reading a journal file.
+/// The pages read, oldest first.
+///
+/// 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.
+///
+public readonly record struct JournalReadResult(
+ IReadOnlyList Pages,
+ string? Error);
+
+///
+/// Retail's journal file — gmJournalUI::LoadPages @0x00496AC0 and
+/// SavePages @0x00497270 .
+///
+///
+///
+/// A plain tagged text file, one tag per line, <NEWP> opening each
+/// page. Retail writes it with fopen mode w+ — the whole file is
+/// rewritten, never appended.
+///
+///
+/// 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.
+///
+///
+public static class JournalFile
+{
+ private const string NewPage = "";
+ private const string PageNumber = "";
+ private const string Label = "";
+ private const string Title = "";
+ private const string Notes = "";
+ private const string Days = "";
+ private const string Hours = "";
+ private const string Minutes = "";
+ private const string LocationX = "";
+ private const string LocationY = "";
+ private const string RunningTime = "";
+
+ /// Retail's own load failure, byte-decoded from the paired binary.
+ public const string MalformedFileMessage =
+ "Problem loading journal: Your journal file does not create a new page!";
+
+ ///
+ /// The per-character file name — retail's "%s%s-%s-%s.txt" with the
+ /// literal prefix "Journal" both call sites pass.
+ ///
+ public static string FileNameFor(string serverName, string characterName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
+ return $"Journal-{Sanitize(serverName)}-{Sanitize(characterName)}.txt";
+ }
+
+ ///
+ /// 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.
+ ///
+ private static string Sanitize(string value)
+ {
+ var text = new StringBuilder(value.Length);
+ foreach (char c in value)
+ {
+ text.Append(Array.IndexOf(Path.GetInvalidFileNameChars(), c) >= 0 ? '_' : c);
+ }
+ return text.ToString();
+ }
+
+ public static JournalReadResult Read(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ var pages = new List();
+ 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) },
+ // 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ public static string Write(IReadOnlyList 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;
+}
diff --git a/src/AcDream.Core/Journal/JournalPage.cs b/src/AcDream.Core/Journal/JournalPage.cs
new file mode 100644
index 00000000..30836961
--- /dev/null
+++ b/src/AcDream.Core/Journal/JournalPage.cs
@@ -0,0 +1,77 @@
+namespace AcDream.Core.Journal;
+
+///
+/// One page of retail's per-character journal.
+///
+///
+/// Entirely client-authored: no wire, no server, no dat. The player writes it.
+/// Retail's own PageInfo (64 bytes, g_JournalPages ).
+///
+/// Short name, shown in the Page List. Authored max 16 characters.
+/// Page title. Authored max 32.
+/// Free-form body. Authored max 2048.
+/// Countdown days.
+/// Countdown hours.
+/// Countdown minutes.
+///
+/// Recorded location, in retail's own north-south / east-west units — what the
+/// "Record" button stamps and the page shows as "00.0S, 00.0W".
+///
+/// See .
+///
+/// Whether a location was ever recorded. Distinct from (0, 0), which is a real
+/// place.
+///
+///
+/// The running countdown's remaining seconds, or 0 when the timer is not
+/// running. Retail's <TIME> .
+///
+public sealed record JournalPage(
+ string Label = "",
+ string Title = "",
+ string Notes = "",
+ int TimerDays = 0,
+ int TimerHours = 0,
+ int TimerMinutes = 0,
+ float LocationX = 0f,
+ float LocationY = 0f,
+ bool HasLocation = false,
+ double RunningTimerSeconds = 0d)
+{
+ /// Authored 0x1E on the label edit box.
+ public const int MaxLabelLength = 16;
+
+ /// Authored 0x1E on the title edit box.
+ public const int MaxTitleLength = 32;
+
+ /// Authored 0x1E on the notes edit box.
+ public const int MaxNotesLength = 2048;
+
+ /// An untouched page — what "New" produces.
+ public static readonly JournalPage Empty = new();
+
+ /// Whether the timer fields describe any duration at all.
+ public bool HasTimer =>
+ TimerDays != 0 || TimerHours != 0 || TimerMinutes != 0;
+
+ /// The timer fields as a single duration.
+ public TimeSpan TimerDuration =>
+ new(TimerDays, TimerHours, TimerMinutes, 0);
+
+ /// Whether a countdown is currently running on this page.
+ public bool IsTimerRunning => RunningTimerSeconds > 0d;
+
+ ///
+ /// The page with every field clipped to its authored maximum. Applied at
+ /// the seams that accept outside text — a load from disk, or a paste.
+ ///
+ public JournalPage Clipped() => this with
+ {
+ Label = Clip(Label, MaxLabelLength),
+ Title = Clip(Title, MaxTitleLength),
+ Notes = Clip(Notes, MaxNotesLength),
+ };
+
+ private static string Clip(string value, int max) =>
+ value.Length <= max ? value : value[..max];
+}
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index 475fa3ff..db4b0d54 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -54,6 +54,8 @@ public enum GameRuntimeTeardownStage
EntityObjectsDisposed = 1 << 13,
// Campaign QT (2026-08-21): fourth sibling J-owner, same shape.
ContractsDisposed = 1 << 14,
+ // Campaign QJ (2026-08-21): fifth — the per-character journal.
+ JournalDisposed = 1 << 15,
Complete =
HostLeasesReleased
| EventsDetached
@@ -68,6 +70,7 @@ public enum GameRuntimeTeardownStage
| AllegianceDisposed
| TradeDisposed
| ContractsDisposed
+ | JournalDisposed
| IdentityDisposed
| EntityObjectsDisposed,
}
@@ -113,6 +116,7 @@ internal enum GameRuntimeConstructionPoint
AllegianceCreated,
TradeCreated,
ContractsCreated,
+ JournalCreated,
HouseCreated,
MovementCreated,
ActionsCreated,
@@ -133,6 +137,7 @@ internal sealed class GameRuntimeConstructionContext
public RuntimeAllegianceState? Allegiance { get; set; }
public RuntimeTradeState? Trade { get; set; }
public RuntimeContractState? Contracts { get; set; }
+ public RuntimeJournalState? Journal { get; set; }
public RuntimeHouseState? House { get; set; }
public RuntimeLocalPlayerMovementState? Movement { get; set; }
public RuntimeActionState? Actions { get; set; }
@@ -149,11 +154,11 @@ public sealed class GameRuntime
IRuntimeEventSource,
IDisposable
{
- // Campaign QT (2026-08-21): 15 with the contract owner. This bound and
+ // Campaign QJ (2026-08-21): 16 with the journal owner. This bound and
// GameRuntimeTeardownStage.Complete have to move together — the drain
// loop stops here, so leaving it behind would silently never dispose
// the last owner while the ledger kept demanding its flag.
- private const int TeardownStageCount = 15;
+ private const int TeardownStageCount = 16;
private readonly object _lifetimeGate = new();
private readonly Dictionary _hostLeases = [];
@@ -304,6 +309,15 @@ public sealed class GameRuntime
context,
faultInjection);
+ // Campaign QJ (2026-08-21): the per-character journal. Client-
+ // authored and file-backed — no wire reaches it.
+ context.Journal = new RuntimeJournalState();
+ construction.Own(context.Journal);
+ Fault(
+ GameRuntimeConstructionPoint.JournalCreated,
+ context,
+ faultInjection);
+
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
// deliberately minimal owner (ISSUES #413's own sizing note) —
// no live-object side effects, nothing to dispose, so no
@@ -372,7 +386,8 @@ public sealed class GameRuntime
context.Allegiance,
context.Trade,
context.House,
- context.Contracts);
+ context.Contracts,
+ context.Journal);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
@@ -429,6 +444,7 @@ public sealed class GameRuntime
AllegianceOwner = context.Allegiance;
TradeOwner = context.Trade;
ContractsOwner = context.Contracts;
+ JournalOwner = context.Journal;
HouseOwner = context.House;
MovementOwner = context.Movement;
ActionOwner = context.Actions;
@@ -539,6 +555,7 @@ public sealed class GameRuntime
/// Secure trade (2026-08-14): third sibling J-owner.
public RuntimeTradeState TradeOwner { get; }
public RuntimeContractState ContractsOwner { get; }
+ public RuntimeJournalState JournalOwner { get; }
/// Batch C (2026-08-17): House tab minimal owner — see
/// 's own class doc for the sizing
@@ -596,6 +613,7 @@ public sealed class GameRuntime
public IRuntimeTradeView Trade => TradeOwner.View;
public IRuntimeContractView Contracts => ContractsOwner.View;
+ public IRuntimeJournalView Journal => JournalOwner.View;
public IRuntimeActionView Actions => ActionOwner.View;
public IRuntimeMovementView Movement => MovementOwner.View;
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
@@ -825,27 +843,35 @@ public sealed class GameRuntime
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.ContractsDisposed
+ & ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
10 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.ContractsDisposed
+ & ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
11 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.ContractsDisposed
+ & ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
12 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.ContractsDisposed
+ & ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
13 => GameRuntimeTeardownStage.Complete
+ & ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
14 => GameRuntimeTeardownStage.Complete
+ & ~GameRuntimeTeardownStage.IdentityDisposed
+ & ~GameRuntimeTeardownStage.EntityObjectsDisposed,
+ 15 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
_ => GameRuntimeTeardownStage.Complete,
};
@@ -903,9 +929,12 @@ public sealed class GameRuntime
ContractsOwner.Dispose();
return ContractsOwner.CaptureOwnership().IsConverged;
case 13:
+ JournalOwner.Dispose();
+ return JournalOwner.CaptureOwnership().IsConverged;
+ case 14:
PlayerIdentity.Dispose();
return PlayerIdentity.CaptureOwnership().IsConverged;
- case 14:
+ case 15:
EntityObjects.Dispose();
return EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
@@ -930,8 +959,9 @@ public sealed class GameRuntime
10 => AllegianceOwner.CaptureOwnership().IsConverged,
11 => TradeOwner.CaptureOwnership().IsConverged,
12 => ContractsOwner.CaptureOwnership().IsConverged,
- 13 => PlayerIdentity.CaptureOwnership().IsConverged,
- 14 => EntityObjects.CaptureOwnership().IsConverged
+ 13 => JournalOwner.CaptureOwnership().IsConverged,
+ 14 => PlayerIdentity.CaptureOwnership().IsConverged,
+ 15 => EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
_ => true,
};
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs b/src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs
new file mode 100644
index 00000000..bf27a6a3
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs
@@ -0,0 +1,386 @@
+using System;
+using System.Collections.Generic;
+using AcDream.Core.Journal;
+
+namespace AcDream.Runtime.Gameplay;
+
+public readonly record struct RuntimeJournalOwnershipSnapshot(
+ bool IsDisposed,
+ int PageCount)
+{
+ public bool IsConverged => IsDisposed && PageCount == 0;
+}
+
+/// Whole-journal state at one revision.
+/// 1-based, or 0 when the journal is empty.
+public readonly record struct RuntimeJournalSnapshot(
+ long Revision,
+ int PageCount,
+ int CurrentPage,
+ bool IsDirty);
+
+/// Borrowed read surface over .
+public interface IRuntimeJournalView
+{
+ RuntimeJournalSnapshot Snapshot { get; }
+
+ /// Every page, in file order.
+ IReadOnlyList Pages { get; }
+
+ /// The page names,
+ /// or when the journal is empty.
+ JournalPage Current { get; }
+
+ ///
+ /// Remaining seconds on the current page's countdown at
+ /// , or 0 when nothing is running.
+ ///
+ double RemainingTimerSeconds(DateTime now);
+}
+
+///
+/// Canonical owner for retail's per-character journal — Campaign QJ slice QJ2.
+///
+///
+///
+/// Purely client-authored: no wire touches this. The player writes the pages,
+/// and they persist to a per-character file
+/// ( ). Retail loads on entering the world and saves
+/// whenever the journal page is hidden
+/// (gmJournalUI::OnVisibilityChanged @0x004978F0 ) rather than only at
+/// exit, so a crash costs at most the page you are looking at.
+///
+///
+/// Pages are 1-BASED throughout, matching retail's m_CurrentPage and the
+/// "~ 1 ~" the panel shows. 0 means "no pages".
+///
+///
+public sealed class RuntimeJournalState : IDisposable
+{
+ private readonly object _gate = new();
+ private readonly List _pages = [];
+ private int _currentPage;
+ private long _revision;
+ private bool _dirty;
+ private bool _disposed;
+
+ /// When the running countdown was started, and what it had left then.
+ private DateTime? _timerStartedAt;
+ private double _timerSecondsAtStart;
+
+ public RuntimeJournalState() => View = new JournalView(this);
+
+ public IRuntimeJournalView View { get; }
+
+ ///
+ /// Whether anything has changed since the last —
+ /// what the host checks before writing the file.
+ ///
+ public bool IsDirty
+ {
+ get { lock (_gate) return _dirty; }
+ }
+
+ /// Replaces the whole journal, as a file load does.
+ public void Load(IReadOnlyList pages)
+ {
+ ArgumentNullException.ThrowIfNull(pages);
+ lock (_gate)
+ {
+ if (_disposed) return;
+
+ _pages.Clear();
+ _pages.AddRange(pages);
+ _currentPage = _pages.Count == 0 ? 0 : 1;
+ _timerStartedAt = null;
+ _timerSecondsAtStart = 0d;
+
+ // A load is not an edit: the file already says this.
+ _dirty = false;
+ Bump();
+ }
+ }
+
+ /// A snapshot for writing to disk, with the live countdown folded in.
+ public IReadOnlyList CaptureForSave(DateTime now)
+ {
+ lock (_gate)
+ {
+ var saved = new JournalPage[_pages.Count];
+ for (int i = 0; i < _pages.Count; i++)
+ {
+ saved[i] = i + 1 == _currentPage
+ ? _pages[i] with { RunningTimerSeconds = RemainingLocked(now) }
+ : _pages[i];
+ }
+
+ return saved;
+ }
+ }
+
+ public void MarkSaved()
+ {
+ lock (_gate) _dirty = false;
+ }
+
+ ///
+ /// Retail's "New" — appends a blank page and moves to it
+ /// (gmJournalUI::NewPage @0x00496880 ).
+ ///
+ public void NewPage()
+ {
+ lock (_gate)
+ {
+ if (_disposed) return;
+ _pages.Add(JournalPage.Empty);
+ _currentPage = _pages.Count;
+ ClearTimerLocked();
+ _dirty = true;
+ Bump();
+ }
+ }
+
+ ///
+ /// Removes one 1-based page (DeletePage @0x004965D0 ).
+ ///
+ ///
+ /// Deleting the last remaining page leaves an EMPTY journal rather than a
+ /// blank page — retail's own ClearCurrentPage handles the "one page
+ /// left" case separately, and inventing a replacement here would make the
+ /// journal impossible to empty.
+ ///
+ public bool DeletePage(int pageNumber)
+ {
+ lock (_gate)
+ {
+ if (_disposed || pageNumber < 1 || pageNumber > _pages.Count)
+ return false;
+
+ _pages.RemoveAt(pageNumber - 1);
+ if (_currentPage > _pages.Count)
+ _currentPage = _pages.Count;
+ if (_pages.Count == 0)
+ _currentPage = 0;
+
+ ClearTimerLocked();
+ _dirty = true;
+ Bump();
+ return true;
+ }
+ }
+
+ /// Moves to a 1-based page (GotoPage @0x00496430 ).
+ public bool GotoPage(int pageNumber)
+ {
+ lock (_gate)
+ {
+ if (_disposed || pageNumber < 1 || pageNumber > _pages.Count)
+ return false;
+ if (_currentPage == pageNumber)
+ return true;
+
+ _currentPage = pageNumber;
+ // The countdown belongs to the page it was started on.
+ ClearTimerLocked();
+ Bump();
+ return true;
+ }
+ }
+
+ ///
+ /// Commits edited text into the current page
+ /// (SaveThisPage @0x00495360 ).
+ ///
+ public void UpdateCurrent(string label, string title, string notes)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _currentPage == 0) return;
+
+ JournalPage updated = (_pages[_currentPage - 1] with
+ {
+ Label = label ?? string.Empty,
+ Title = title ?? string.Empty,
+ Notes = notes ?? string.Empty,
+ }).Clipped();
+
+ if (updated == _pages[_currentPage - 1])
+ return;
+
+ _pages[_currentPage - 1] = updated;
+ _dirty = true;
+ Bump();
+ }
+ }
+
+ /// The "Record" button (UpdateLocation @0x004958F0 ).
+ public void RecordLocation(float x, float y)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _currentPage == 0) return;
+ _pages[_currentPage - 1] = _pages[_currentPage - 1] with
+ {
+ LocationX = x,
+ LocationY = y,
+ HasLocation = true,
+ };
+ _dirty = true;
+ Bump();
+ }
+ }
+
+ public void SetTimer(int days, int hours, int minutes)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _currentPage == 0) return;
+ _pages[_currentPage - 1] = _pages[_currentPage - 1] with
+ {
+ TimerDays = Math.Max(0, days),
+ TimerHours = Math.Max(0, hours),
+ TimerMinutes = Math.Max(0, minutes),
+ };
+ _dirty = true;
+ Bump();
+ }
+ }
+
+ /// Starts the current page's countdown from its authored fields.
+ public bool StartTimer(DateTime now)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _currentPage == 0) return false;
+
+ JournalPage page = _pages[_currentPage - 1];
+ double seconds = page.TimerDuration.TotalSeconds;
+ if (seconds <= 0d)
+ return false;
+
+ _timerStartedAt = now;
+ _timerSecondsAtStart = seconds;
+ _pages[_currentPage - 1] = page with { RunningTimerSeconds = seconds };
+ _dirty = true;
+ Bump();
+ return true;
+ }
+ }
+
+ /// Stops the countdown (ResetTimer @0x00495150 ).
+ public void ResetTimer()
+ {
+ lock (_gate)
+ {
+ if (_disposed) return;
+ bool wasRunning = _timerStartedAt is not null;
+ ClearTimerLocked();
+ if (_currentPage != 0)
+ {
+ _pages[_currentPage - 1] =
+ _pages[_currentPage - 1] with { RunningTimerSeconds = 0d };
+ }
+
+ if (wasRunning) _dirty = true;
+ Bump();
+ }
+ }
+
+ ///
+ /// True when the current page is the last one
+ /// (IsLastPage @0x00494ED0 ) — the "Last" button's own gate.
+ ///
+ public bool IsLastPage
+ {
+ get { lock (_gate) return _currentPage != 0 && _currentPage == _pages.Count; }
+ }
+
+ public RuntimeJournalOwnershipSnapshot CaptureOwnership()
+ {
+ lock (_gate) return new RuntimeJournalOwnershipSnapshot(_disposed, _pages.Count);
+ }
+
+ ///
+ /// Cleared at generation reset: the journal is PER-CHARACTER, so carrying
+ /// it across a reconnect would show one character another's notes. The
+ /// host saves before the reset and loads after it.
+ ///
+ public void ResetSession()
+ {
+ lock (_gate) ClearLocked();
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed) return;
+ ClearLocked();
+ _disposed = true;
+ }
+ }
+
+ private void ClearLocked()
+ {
+ bool changed = _pages.Count != 0 || _currentPage != 0;
+ _pages.Clear();
+ _currentPage = 0;
+ _dirty = false;
+ ClearTimerLocked();
+ if (changed) Bump();
+ }
+
+ private void ClearTimerLocked()
+ {
+ _timerStartedAt = null;
+ _timerSecondsAtStart = 0d;
+ }
+
+ private double RemainingLocked(DateTime now)
+ {
+ if (_timerStartedAt is not { } started)
+ return _currentPage == 0 ? 0d : _pages[_currentPage - 1].RunningTimerSeconds;
+
+ double remaining = _timerSecondsAtStart - (now - started).TotalSeconds;
+ return remaining > 0d ? remaining : 0d;
+ }
+
+ private void Bump() => _revision++;
+
+ private sealed class JournalView(RuntimeJournalState owner) : IRuntimeJournalView
+ {
+ public RuntimeJournalSnapshot Snapshot
+ {
+ get
+ {
+ lock (owner._gate)
+ return new RuntimeJournalSnapshot(
+ owner._revision,
+ owner._pages.Count,
+ owner._currentPage,
+ owner._dirty);
+ }
+ }
+
+ public IReadOnlyList Pages
+ {
+ get { lock (owner._gate) return owner._pages.ToArray(); }
+ }
+
+ public JournalPage Current
+ {
+ get
+ {
+ lock (owner._gate)
+ return owner._currentPage == 0
+ ? JournalPage.Empty
+ : owner._pages[owner._currentPage - 1];
+ }
+ }
+
+ public double RemainingTimerSeconds(DateTime now)
+ {
+ lock (owner._gate) return owner.RemainingLocked(now);
+ }
+ }
+}
diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs
index e497507a..12106757 100644
--- a/src/AcDream.Runtime/RuntimeGenerationReset.cs
+++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs
@@ -78,15 +78,21 @@ public enum RuntimeGenerationResetStage
/// previous character's quests.
///
Contracts = 16,
- BeginEntityRetirement = 17,
- RetireEntities = 18,
- DrainHostProjection = 19,
- CompleteCanonicalEntities = 20,
- CompleteHostProjection = 21,
- ChatIdentity = 22,
- PlayerSnapshots = 23,
- PlayerIdentity = 24,
- Complete = 25,
+ ///
+ /// Campaign QJ (2026-08-21): the journal is PER-CHARACTER, so carrying it
+ /// across a reconnect would show one character another's notes. The host
+ /// saves before the reset and loads again after it.
+ ///
+ Journal = 17,
+ BeginEntityRetirement = 18,
+ RetireEntities = 19,
+ DrainHostProjection = 20,
+ CompleteCanonicalEntities = 21,
+ CompleteHostProjection = 22,
+ ChatIdentity = 23,
+ PlayerSnapshots = 24,
+ PlayerIdentity = 25,
+ Complete = 26,
}
public readonly record struct RuntimeGenerationResetSnapshot(
@@ -138,6 +144,7 @@ public sealed class RuntimeGenerationReset
private readonly RuntimeAllegianceState _allegiance;
private readonly RuntimeTradeState _trade;
private readonly RuntimeContractState _contracts;
+ private readonly RuntimeJournalState _journal;
private readonly RuntimeHouseState _house;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
@@ -158,7 +165,8 @@ public sealed class RuntimeGenerationReset
RuntimeAllegianceState allegiance,
RuntimeTradeState trade,
RuntimeHouseState house,
- RuntimeContractState contracts)
+ RuntimeContractState contracts,
+ RuntimeJournalState journal)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
@@ -182,6 +190,7 @@ public sealed class RuntimeGenerationReset
_house = house ?? throw new ArgumentNullException(nameof(house));
_contracts = contracts
?? throw new ArgumentNullException(nameof(contracts));
+ _journal = journal ?? throw new ArgumentNullException(nameof(journal));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
@@ -364,6 +373,9 @@ public sealed class RuntimeGenerationReset
case RuntimeGenerationResetStage.Contracts:
Advance(state, _contracts.ResetSession);
break;
+ case RuntimeGenerationResetStage.Journal:
+ Advance(state, _journal.ResetSession);
+ break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects
diff --git a/tests/AcDream.Core.Tests/Journal/JournalFileTests.cs b/tests/AcDream.Core.Tests/Journal/JournalFileTests.cs
new file mode 100644
index 00000000..de202a99
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Journal/JournalFileTests.cs
@@ -0,0 +1,175 @@
+using System.Collections.Generic;
+using System.Linq;
+using AcDream.Core.Journal;
+
+namespace AcDream.Core.Tests.Journal;
+
+///
+/// Campaign QJ slice QJ1: retail's journal file
+/// (gmJournalUI::LoadPages @0x00496AC0 /
+/// SavePages @0x00497270 ).
+///
+public sealed class JournalFileTests
+{
+ [Fact]
+ public void APageRoundTripsThroughTheFile()
+ {
+ var page = new JournalPage(
+ Label: "Aerlinthe",
+ Title: "Recall ring",
+ Notes: "Talk to the archmage first.",
+ TimerDays: 1,
+ TimerHours: 2,
+ TimerMinutes: 3,
+ LocationX: 12.5f,
+ LocationY: -8.25f,
+ HasLocation: true,
+ RunningTimerSeconds: 90d);
+
+ JournalReadResult result = JournalFile.Read(JournalFile.Write([page]));
+
+ Assert.Null(result.Error);
+ Assert.Equal(page, Assert.Single(result.Pages));
+ }
+
+ [Fact]
+ public void SeveralPagesKeepTheirFileOrder()
+ {
+ // is written but page order IS file order — a reader that
+ // trusted the number would reshuffle a hand-edited file.
+ List pages =
+ [
+ new(Label: "one"), new(Label: "two"), new(Label: "three"),
+ ];
+
+ JournalReadResult result = JournalFile.Read(JournalFile.Write(pages));
+
+ Assert.Equal(
+ new[] { "one", "two", "three" },
+ result.Pages.Select(p => p.Label).ToArray());
+ }
+
+ [Fact]
+ public void AFileThatDoesNotOpenWithAPageMarkerIsRefused()
+ {
+ // Retail's own strictness, and its own message. Accepting this would
+ // scatter the first page's text into no page at all.
+ JournalReadResult result = JournalFile.Read(" orphaned\n\n");
+
+ Assert.Equal(JournalFile.MalformedFileMessage, result.Error);
+ Assert.Empty(result.Pages);
+ }
+
+ [Fact]
+ public void AnEmptyFileIsACharacterWhoHasWrittenNothing()
+ {
+ // Absence is not corruption: refusing here would show an error to
+ // every character opening the journal for the first time.
+ JournalReadResult result = JournalFile.Read(string.Empty);
+
+ Assert.Null(result.Error);
+ Assert.Empty(result.Pages);
+ }
+
+ [Fact]
+ public void AnUnknownTagIsSkippedRatherThanRefused()
+ {
+ // A newer client's tag must not make this one reject the file.
+ JournalReadResult result = JournalFile.Read(
+ "\n kept\n whatever\n");
+
+ Assert.Null(result.Error);
+ Assert.Equal("kept", Assert.Single(result.Pages).Title);
+ }
+
+ [Fact]
+ public void TheWrittenFormIsRetailsTagOrder()
+ {
+ string text = JournalFile.Write([new JournalPage(Label: "L", Title: "T")]);
+
+ string[] tags = text
+ .Split('\n', System.StringSplitOptions.RemoveEmptyEntries)
+ .Select(line => line.Length >= 6 ? line[..6] : line)
+ .ToArray();
+
+ Assert.Equal(
+ new[]
+ {
+ "", "", "", "", "",
+ "", "", "", "",
+ },
+ tags);
+ }
+
+ [Fact]
+ public void ARecordedLocationOfZeroIsStillARecordedLocation()
+ {
+ // (0, 0) is a real place. Writing the tags only when the numbers are
+ // non-zero would lose the distinction between "recorded there" and
+ // "never recorded".
+ var page = new JournalPage(LocationX: 0f, LocationY: 0f, HasLocation: true);
+
+ JournalPage read = Assert.Single(JournalFile.Read(JournalFile.Write([page])).Pages);
+
+ Assert.True(read.HasLocation);
+ }
+
+ [Fact]
+ public void APageWithNoLocationDoesNotGainOne()
+ {
+ JournalPage read = Assert.Single(
+ JournalFile.Read(JournalFile.Write([new JournalPage(Title: "T")])).Pages);
+
+ Assert.False(read.HasLocation);
+ }
+
+ [Fact]
+ public void ANewlineInsideNotesCannotSplitThePage()
+ {
+ // The notes box is multi-line while the file is line-oriented. An
+ // embedded newline would read back as a tagless line and silently
+ // truncate the notes — or worse, land inside the next page.
+ var page = new JournalPage(Notes: "first line\nsecond line", Title: "kept");
+
+ JournalReadResult result = JournalFile.Read(JournalFile.Write([page]));
+
+ JournalPage read = Assert.Single(result.Pages);
+ Assert.Equal("first line second line", read.Notes);
+ Assert.Equal("kept", read.Title);
+ }
+
+ [Fact]
+ public void OverlongFieldsAreClippedToTheirAuthoredMaximums()
+ {
+ // The edit boxes enforce these; a hand-edited file does not.
+ string text =
+ "\n"
+ + " " + new string('a', 100) + "\n"
+ + " " + new string('b', 100) + "\n";
+
+ JournalPage page = Assert.Single(JournalFile.Read(text).Pages);
+
+ Assert.Equal(JournalPage.MaxLabelLength, page.Label.Length);
+ Assert.Equal(JournalPage.MaxTitleLength, page.Title.Length);
+ }
+
+ [Theory]
+ [InlineData("Frostfell", "Acdream", "Journal-Frostfell-Acdream.txt")]
+ // A name is outside data and must not be able to redirect a write.
+ [InlineData("a/b", "c\\d", "Journal-a_b-c_d.txt")]
+ public void TheFileNameFollowsRetailsPattern(
+ string server, string character, string expected)
+ => Assert.Equal(expected, JournalFile.FileNameFor(server, character));
+
+ [Fact]
+ public void NumbersAreCultureInvariant()
+ {
+ // A comma decimal separator would write a file this reader cannot read
+ // back — and the user's locale is not the file's.
+ string text = JournalFile.Write(
+ [new JournalPage(LocationX: 1.5f, LocationY: 2.25f, HasLocation: true)]);
+
+ Assert.Contains(" 1.5", text);
+ Assert.DoesNotContain(",", text);
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
index cc59f3e9..3bb98b94 100644
--- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
+++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
@@ -244,6 +244,7 @@ public sealed class GameRuntimeTests
// its three sibling J-owners, before the identity and entity
// foundations.
GameRuntimeTeardownStage.ContractsDisposed,
+ GameRuntimeTeardownStage.JournalDisposed,
GameRuntimeTeardownStage.IdentityDisposed,
GameRuntimeTeardownStage.EntityObjectsDisposed,
];
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeJournalStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeJournalStateTests.cs
new file mode 100644
index 00000000..96eb3bfc
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeJournalStateTests.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AcDream.Core.Journal;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+///
+/// Campaign QJ slice QJ2: the per-character journal owner.
+///
+public sealed class RuntimeJournalStateTests
+{
+ private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
+
+ private static RuntimeJournalState WithPages(params string[] labels)
+ {
+ var state = new RuntimeJournalState();
+ state.Load(labels.Select(l => new JournalPage(Label: l)).ToArray());
+ return state;
+ }
+
+ [Fact]
+ public void AFreshJournalHasNoPagesAndNoCurrentPage()
+ {
+ using var state = new RuntimeJournalState();
+
+ RuntimeJournalSnapshot snapshot = state.View.Snapshot;
+
+ Assert.Equal(0, snapshot.PageCount);
+ Assert.Equal(0, snapshot.CurrentPage);
+ Assert.Equal(JournalPage.Empty, state.View.Current);
+ }
+
+ [Fact]
+ public void LoadingOpensOnTheFirstPageAndIsNotAnEdit()
+ {
+ // Pages are 1-BASED, matching retail's m_CurrentPage and the "~ 1 ~"
+ // the panel shows. And a load must not mark the journal dirty — the
+ // file already says exactly this, so saving straight back would be
+ // pure churn.
+ using RuntimeJournalState state = WithPages("a", "b");
+
+ Assert.Equal(1, state.View.Snapshot.CurrentPage);
+ Assert.Equal("a", state.View.Current.Label);
+ Assert.False(state.IsDirty);
+ }
+
+ [Fact]
+ public void NewPageAppendsAndMovesToIt()
+ {
+ using RuntimeJournalState state = WithPages("a");
+
+ state.NewPage();
+
+ Assert.Equal(2, state.View.Snapshot.PageCount);
+ Assert.Equal(2, state.View.Snapshot.CurrentPage);
+ Assert.Equal(JournalPage.Empty, state.View.Current);
+ Assert.True(state.IsDirty);
+ }
+
+ [Fact]
+ public void EditsLandOnTheCurrentPageOnly()
+ {
+ using RuntimeJournalState state = WithPages("a", "b");
+ state.GotoPage(2);
+
+ state.UpdateCurrent("label", "title", "notes");
+
+ Assert.Equal("a", state.View.Pages[0].Label);
+ Assert.Equal("label", state.View.Pages[1].Label);
+ Assert.Equal("title", state.View.Pages[1].Title);
+ }
+
+ [Fact]
+ public void AnEditThatChangesNothingDoesNotDirtyTheJournal()
+ {
+ // The panel commits the edit boxes on every page change and hide, so a
+ // no-op commit must not make a file write look necessary.
+ using RuntimeJournalState state = WithPages("a");
+ state.UpdateCurrent("a", string.Empty, string.Empty);
+
+ Assert.False(state.IsDirty);
+ }
+
+ [Fact]
+ public void EditsAreClippedToTheAuthoredMaximums()
+ {
+ using RuntimeJournalState state = WithPages("a");
+
+ state.UpdateCurrent(new string('x', 100), new string('y', 100), "notes");
+
+ Assert.Equal(JournalPage.MaxLabelLength, state.View.Current.Label.Length);
+ Assert.Equal(JournalPage.MaxTitleLength, state.View.Current.Title.Length);
+ }
+
+ [Fact]
+ public void DeletingTheLastRemainingPageEmptiesTheJournal()
+ {
+ // Inventing a replacement blank page would make the journal impossible
+ // to empty.
+ using RuntimeJournalState state = WithPages("only");
+
+ Assert.True(state.DeletePage(1));
+
+ Assert.Equal(0, state.View.Snapshot.PageCount);
+ Assert.Equal(0, state.View.Snapshot.CurrentPage);
+ }
+
+ [Fact]
+ public void DeletingPastTheEndPullsTheCurrentPageBack()
+ {
+ using RuntimeJournalState state = WithPages("a", "b", "c");
+ state.GotoPage(3);
+
+ state.DeletePage(3);
+
+ Assert.Equal(2, state.View.Snapshot.CurrentPage);
+ Assert.Equal("b", state.View.Current.Label);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(4)]
+ [InlineData(-1)]
+ public void AnOutOfRangePageIsRefusedRatherThanClamped(int page)
+ {
+ // Clamping would move the player somewhere they did not ask for.
+ using RuntimeJournalState state = WithPages("a", "b", "c");
+
+ Assert.False(state.GotoPage(page));
+ Assert.False(state.DeletePage(page));
+ Assert.Equal(1, state.View.Snapshot.CurrentPage);
+ }
+
+ [Fact]
+ public void IsLastPageTracksTheCurrentPage()
+ {
+ using RuntimeJournalState state = WithPages("a", "b");
+
+ Assert.False(state.IsLastPage);
+ state.GotoPage(2);
+ Assert.True(state.IsLastPage);
+ }
+
+ [Fact]
+ public void RecordingALocationOfZeroStillCountsAsRecorded()
+ {
+ using RuntimeJournalState state = WithPages("a");
+
+ state.RecordLocation(0f, 0f);
+
+ Assert.True(state.View.Current.HasLocation);
+ }
+
+ // ── the timer ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void StartingATimerNeedsADurationToCountDown()
+ {
+ using RuntimeJournalState state = WithPages("a");
+
+ Assert.False(state.StartTimer(Now));
+
+ state.SetTimer(0, 0, 5);
+ Assert.True(state.StartTimer(Now));
+ }
+
+ [Fact]
+ public void TheCountdownRunsAgainstTheClock()
+ {
+ using RuntimeJournalState state = WithPages("a");
+ state.SetTimer(0, 1, 0);
+ state.StartTimer(Now);
+
+ Assert.Equal(3600d, state.View.RemainingTimerSeconds(Now));
+ Assert.Equal(3540d, state.View.RemainingTimerSeconds(Now.AddMinutes(1)));
+ }
+
+ [Fact]
+ public void AnExpiredCountdownStopsAtZeroRatherThanGoingNegative()
+ {
+ using RuntimeJournalState state = WithPages("a");
+ state.SetTimer(0, 0, 1);
+ state.StartTimer(Now);
+
+ Assert.Equal(0d, state.View.RemainingTimerSeconds(Now.AddHours(1)));
+ }
+
+ [Fact]
+ public void LeavingThePageStopsItsCountdown()
+ {
+ // The countdown belongs to the page it was started on; carrying it to
+ // another page would attribute one page's timer to another.
+ using RuntimeJournalState state = WithPages("a", "b");
+ state.SetTimer(0, 1, 0);
+ state.StartTimer(Now);
+
+ state.GotoPage(2);
+
+ Assert.Equal(0d, state.View.RemainingTimerSeconds(Now));
+ }
+
+ [Fact]
+ public void TheSaveSnapshotFoldsInTheLiveCountdown()
+ {
+ // The page record holds the value at START; what belongs in the file
+ // is what is left NOW, or a reload would resurrect the full duration.
+ using RuntimeJournalState state = WithPages("a");
+ state.SetTimer(0, 1, 0);
+ state.StartTimer(Now);
+
+ IReadOnlyList saved = state.CaptureForSave(Now.AddMinutes(30));
+
+ Assert.Equal(1800d, Assert.Single(saved).RunningTimerSeconds);
+ }
+
+ [Fact]
+ public void MarkSavedClearsTheDirtyFlag()
+ {
+ using RuntimeJournalState state = WithPages("a");
+ state.NewPage();
+ Assert.True(state.IsDirty);
+
+ state.MarkSaved();
+
+ Assert.False(state.IsDirty);
+ }
+
+ // ── lifetime ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ResetSessionClearsBecauseTheJournalIsPerCharacter()
+ {
+ // Carrying it across a reconnect would show one character another's
+ // notes.
+ using RuntimeJournalState state = WithPages("a", "b");
+
+ state.ResetSession();
+
+ Assert.Equal(0, state.View.Snapshot.PageCount);
+ Assert.Equal(0, state.View.Snapshot.CurrentPage);
+ }
+
+ [Fact]
+ public void EveryMutationAdvancesTheRevision()
+ {
+ using RuntimeJournalState state = WithPages("a");
+ long start = state.View.Snapshot.Revision;
+
+ state.NewPage();
+ long afterNew = state.View.Snapshot.Revision;
+ state.UpdateCurrent("x", "y", "z");
+
+ Assert.True(afterNew > start);
+ Assert.True(state.View.Snapshot.Revision > afterNew);
+ }
+
+ [Fact]
+ public void OwnershipConvergesOnlyAfterDisposal()
+ {
+ RuntimeJournalState state = WithPages("a");
+ Assert.False(state.CaptureOwnership().IsConverged);
+
+ state.Dispose();
+
+ Assert.True(state.CaptureOwnership().IsConverged);
+ }
+
+ [Fact]
+ public void MutationsAfterDisposalAreIgnoredRatherThanThrowing()
+ {
+ RuntimeJournalState state = WithPages("a");
+ state.Dispose();
+
+ state.NewPage();
+ state.UpdateCurrent("x", "y", "z");
+ state.Load([new JournalPage(Label: "b")]);
+
+ Assert.True(state.CaptureOwnership().IsConverged);
+ }
+}