feat(journal): QJ3/QJ4/QJ5 — both remaining tabs are live

The Journal panel now has all three tabs working: contracts from the server,
and a per-character notebook with its searchable index.

Two ported details that a reimplementation would get wrong in a way nobody
notices until they lose work:

Every navigation button commits the current page FIRST. Retail's
ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all
five of them, which is why paging away never eats what you just typed. And the
file is written when the notes page is HIDDEN, not only at exit — a crash then
costs at most the page in front of you.

The search is CASE-SENSITIVE across label, title and notes: retail compares
with wcsstr and lowercases neither side. Making it insensitive would be
friendlier and would be a divergence, so it is ported as-is with a test naming
the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0,
@0x00493158) rather than the 500 ms the item-interaction path uses, and firing
it clears the tracker so a third click does not re-open.

Two unlabelled buttons on the notes page turned out to be prev/next: retail
switches on (idElement - 0x10000565), which names them without a caption. The
running-timer readout is authored at the same x as the three day/hour/minute
boxes, so the strip is one or the other — that overlap is the data form of
ShowEditableTimer versus ShowRunningTimer, not a layout bug.

DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is
ClientUISystem's, not gmContractsUI's — the journal timer and the contract
repeat countdown both call it, and it only lived under Quests because that was
its first caller. A bridge class to reach it across features would have been
the wrong answer to the same observation.

The journal file lives in the client's data directory rather than beside the
executable, for the same reason the chat log does. Register QJ-1.

Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 15:42:05 +02:00
parent 536d17456d
commit c5cc8ae5fc
15 changed files with 1380 additions and 76 deletions

View file

@ -1025,7 +1025,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
// refresh under the shared DatLock.
Quests: new QuestRuntimeBindings(
Contracts: d.Runtime.ContractsOwner.View,
Catalog: questCatalog),
Catalog: questCatalog,
Journal: d.Runtime.JournalOwner.View,
JournalCommands: d.Runtime.JournalOwner,
PlayerCell: () => d.PlayerController.Controller?.CellId ?? 0u,
JournalDirectory: System.IO.Path.Combine(
AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory,
"journal"),
Report: message =>
d.Communication.Chat.OnSystemMessage(message, 0x0Fu)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -226,7 +226,15 @@ internal sealed class LiveSessionRuntimeFactory
_ui.RetailUi?.RedeclareSocialPanelAfterWorldEntry();
},
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
LoadCharacterSettings: name =>
{
_interaction.Settings.LoadCharacterContext(name);
// Campaign QJ slice QJ5: retail loads the journal on
// entering the world, from the same per-character moment
// the settings context uses. RetailUiRuntime owns the file
// because the panel that writes it does.
_ui.RetailUi?.LoadJournal(name);
},
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm,
// Logout-audio round (2026-08-17): reopen the world-audio
// pool the session reset closed (see the reset manifest's

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.IO;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI;
/// <summary>
/// Reads and writes the per-character journal file.
/// </summary>
/// <remarks>
/// <para>
/// Retail loads on entering the world and saves whenever the notes page is
/// hidden (<c>gmJournalUI::OnVisibilityChanged @0x004978F0</c>) rather than
/// only at exit, so a crash costs at most the page in front of you. Both
/// moments call through here.
/// </para>
/// <para>
/// The file lives in the client's own data directory rather than beside the
/// executable, for the same reason the chat log does: the launcher replaces
/// the install atomically on update, and a file written there is wiped by the
/// next update. Register row QJ-1.
/// </para>
/// </remarks>
public sealed class JournalPersistence(
RuntimeJournalState journal,
string directory,
Action<string>? report = null)
{
private readonly RuntimeJournalState _journal =
journal ?? throw new ArgumentNullException(nameof(journal));
private readonly string _directory = string.IsNullOrWhiteSpace(directory)
? throw new ArgumentException("A journal directory is required.", nameof(directory))
: directory;
private string? _characterName;
/// <summary>The file the loaded character's journal lives in, or null.</summary>
public string? CurrentPath { get; private set; }
/// <summary>
/// Loads a character's journal, replacing whatever was in memory.
/// </summary>
/// <remarks>
/// A character with no file is the ordinary first-time case and loads as an
/// empty journal. A file that fails retail's own validity check reports the
/// message and leaves the journal EMPTY rather than partially populated —
/// a half-read notebook silently loses pages, and the next save would then
/// write the loss back over the original.
/// </remarks>
public void Load(string characterName, string serverName = "acdream")
{
if (string.IsNullOrWhiteSpace(characterName))
return;
_characterName = characterName;
CurrentPath = Path.Combine(
_directory, JournalFile.FileNameFor(serverName, characterName));
string text;
try
{
text = File.Exists(CurrentPath) ? File.ReadAllText(CurrentPath) : string.Empty;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
report?.Invoke($"Problem loading journal: {e.Message}");
_journal.Load([]);
return;
}
JournalReadResult result = JournalFile.Read(text);
if (result.Error is not null)
{
report?.Invoke(result.Error);
_journal.Load([]);
return;
}
_journal.Load(result.Pages);
}
/// <summary>
/// Writes the journal if anything has changed since the last write.
/// </summary>
/// <returns>Whether a file was written.</returns>
public bool Save(DateTime now)
{
if (CurrentPath is null || _characterName is null)
return false;
if (!_journal.IsDirty)
return false;
IReadOnlyList<JournalPage> pages = _journal.CaptureForSave(now);
try
{
Directory.CreateDirectory(_directory);
File.WriteAllText(CurrentPath, JournalFile.Write(pages));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Reported, not thrown: losing a note is bad, taking the client
// down while the player is writing one is worse.
report?.Invoke($"Problem saving journal: {e.Message}");
return false;
}
_journal.MarkSaved();
return true;
}
/// <summary>Saves and forgets the character — session teardown.</summary>
public void Close(DateTime now)
{
Save(now);
_characterName = null;
CurrentPath = null;
}
}

View file

@ -245,7 +245,7 @@ public sealed class JournalContractsPageController
// "Timed:" is the other wire timer — the one FillProgressString never
// reads. It belongs here, not in the Status column.
SetText(_timedValue, tracker.TimeWhenDone > 0d
? ContractProgressText.DeltaTimeToString(
? RetailDurationText.Format(
Math.Max(0d, tracker.TimeWhenDone - (now - tracker.ReceivedAt).TotalSeconds))
: string.Empty);
}

View file

@ -0,0 +1,272 @@
using System;
using System.Globalization;
using AcDream.Core.Journal;
using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Journal panel's notes page — retail <c>gmJournalUI</c> (element type
/// <c>0x10000048</c>, page <c>0x10000563</c>).
/// </summary>
/// <remarks>
/// <para>
/// A per-character notebook: label, title, free notes, a recorded location and
/// a countdown timer, paged with First/Previous/Next/Last.
/// </para>
/// <para>
/// <b>Every navigation commits the current page first.</b> Retail's
/// <c>ListenToElementMessage @0x004968D0</c> calls <c>SaveThisPage</c> on the
/// way out of every one of the five navigation buttons, which is why paging
/// away never loses what you just typed. Skipping that is the obvious
/// simplification and it silently eats edits.
/// </para>
/// </remarks>
public sealed class JournalNotesPageController
{
// Retail switches on (idElement - 0x10000565), so these two carry no
// caption of their own: they are the prev/next arrows.
private const uint PreviousButtonId = 0x10000565u;
private const uint NextButtonId = 0x10000566u;
private const uint NewButtonId = 0x10000567u;
private const uint LabelFieldId = 0x10000569u;
private const uint TitleFieldId = 0x1000056Bu;
private const uint NotesFieldId = 0x1000056Du;
private const uint FirstButtonId = 0x1000056Fu;
private const uint PageNumberId = 0x10000570u;
private const uint LastButtonId = 0x10000571u;
private const uint LocationTextId = 0x10000573u;
private const uint RecordButtonId = 0x10000574u;
private const uint TimerDaysFieldId = 0x10000576u;
private const uint TimerHoursFieldId = 0x10000578u;
private const uint TimerMinutesFieldId = 0x1000057Au;
private const uint RunningTimerTextId = 0x1000057Cu;
private const uint StartButtonId = 0x1000057Du;
/// <param name="Journal">The canonical owner.</param>
/// <param name="Commands">Mutations, kept off the read view.</param>
/// <param name="PlayerCell">
/// The player's current landcell, for the "Record" button. Returns 0 when
/// there is no valid cell — indoors, or not in the world.
/// </param>
/// <param name="Now">The clock the countdown runs against.</param>
public sealed record Bindings(
IRuntimeJournalView Journal,
RuntimeJournalState Commands,
Func<uint> PlayerCell,
Func<DateTime> Now);
private readonly Bindings _bindings;
private readonly UiField? _label;
private readonly UiField? _title;
private readonly UiField? _notes;
private readonly UiField? _timerDays;
private readonly UiField? _timerHours;
private readonly UiField? _timerMinutes;
private readonly UiText? _pageNumber;
private readonly UiText? _location;
private readonly UiText? _runningTimer;
private readonly UiButton? _start;
private long _renderedRevision = -1;
public JournalNotesPageController(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_label = UiElement.FindDescendant(page, LabelFieldId) as UiField;
_title = UiElement.FindDescendant(page, TitleFieldId) as UiField;
_notes = UiElement.FindDescendant(page, NotesFieldId) as UiField;
_timerDays = UiElement.FindDescendant(page, TimerDaysFieldId) as UiField;
_timerHours = UiElement.FindDescendant(page, TimerHoursFieldId) as UiField;
_timerMinutes = UiElement.FindDescendant(page, TimerMinutesFieldId) as UiField;
_pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText;
_location = UiElement.FindDescendant(page, LocationTextId) as UiText;
_runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText;
_start = UiElement.FindDescendant(page, StartButtonId) as UiButton;
// The three timer boxes take digits only. Their authored 0x1E is 2, so
// the width is already handled; this stops a letter reaching an int
// parse that would silently read as zero.
foreach (UiField? field in new[] { _timerDays, _timerHours, _timerMinutes })
{
if (field is not null)
field.CharacterFilter = static c => char.IsAsciiDigit(c);
}
// Committing on focus loss is what makes clicking straight from a text
// box to another tab keep the edit.
foreach (UiField? field in new[] { _label, _title, _notes })
{
if (field is not null)
field.OnFocusLost = _ => CommitText();
}
Bind(page, NewButtonId, () => { CommitText(); _bindings.Commands.NewPage(); });
Bind(page, FirstButtonId, () => Navigate(1));
Bind(page, LastButtonId, () => Navigate(_bindings.Journal.Snapshot.PageCount));
Bind(page, PreviousButtonId,
() => Navigate(_bindings.Journal.Snapshot.CurrentPage - 1));
Bind(page, NextButtonId,
() => Navigate(_bindings.Journal.Snapshot.CurrentPage + 1));
Bind(page, RecordButtonId, RecordLocation);
Bind(page, StartButtonId, ToggleTimer);
Refresh();
}
/// <summary>
/// Commits the edit boxes into the current page — retail's
/// <c>SaveThisPage @0x00495360</c>.
/// </summary>
public void CommitText()
{
if (_bindings.Journal.Snapshot.CurrentPage == 0)
return;
_bindings.Commands.UpdateCurrent(
_label?.Text ?? string.Empty,
_title?.Text ?? string.Empty,
_notes?.Text ?? string.Empty);
_bindings.Commands.SetTimer(
ParseField(_timerDays),
ParseField(_timerHours),
ParseField(_timerMinutes));
}
public void Tick()
{
if (_bindings.Journal.Snapshot.Revision != _renderedRevision)
Refresh();
else
RefreshTimer(); // the countdown ticks without a page rebuild
}
/// <summary>Called when the page is hidden — retail saves here.</summary>
public void OnHidden() => CommitText();
public void Refresh()
{
RuntimeJournalSnapshot snapshot = _bindings.Journal.Snapshot;
_renderedRevision = snapshot.Revision;
JournalPage page = _bindings.Journal.Current;
_label?.SetText(page.Label);
_title?.SetText(page.Title);
_notes?.SetText(page.Notes);
_timerDays?.SetText(Field(page.TimerDays));
_timerHours?.SetText(Field(page.TimerHours));
_timerMinutes?.SetText(Field(page.TimerMinutes));
// Retail's own "~ N ~". An empty journal shows no number rather than
// "~ 0 ~", which would name a page that does not exist.
SetText(_pageNumber, snapshot.CurrentPage == 0
? string.Empty
: $"~ {snapshot.CurrentPage.ToString(CultureInfo.InvariantCulture)} ~");
SetText(_location, page.HasLocation
? FormatLocation(page.LocationX, page.LocationY)
: string.Empty);
RefreshTimer();
}
/// <summary>
/// The editable-fields / running-readout swap
/// (<c>ShowEditableTimer @0x00495770</c> versus <c>ShowRunningTimer</c>).
/// </summary>
/// <remarks>
/// The readout is authored at the SAME x as the three number boxes, so
/// showing both at once overlaps them illegibly — the strip is one or the
/// other.
/// </remarks>
private void RefreshTimer()
{
double remaining = _bindings.Journal.RemainingTimerSeconds(_bindings.Now());
bool running = remaining > 0d;
if (_timerDays is not null) _timerDays.Visible = !running;
if (_timerHours is not null) _timerHours.Visible = !running;
if (_timerMinutes is not null) _timerMinutes.Visible = !running;
if (_runningTimer is not null) _runningTimer.Visible = running;
SetText(_runningTimer, running
? RetailDurationText.Format(remaining)
: string.Empty);
if (_start is not null)
_start.Label = running ? "Stop" : "Start";
}
private void Navigate(int pageNumber)
{
CommitText();
_bindings.Commands.GotoPage(pageNumber);
}
private void RecordLocation()
{
uint cell = _bindings.PlayerCell();
if (cell == 0u)
return;
if (!AcDream.Core.Ui.RadarCoordinates.TryFromCell(cell, out var coordinates))
return; // indoors: retail's own gid_to_lcoord failure
_bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y);
}
private void ToggleTimer()
{
if (_bindings.Journal.RemainingTimerSeconds(_bindings.Now()) > 0d)
{
_bindings.Commands.ResetTimer();
return;
}
CommitText(); // the fields the countdown reads
_bindings.Commands.StartTimer(_bindings.Now());
}
/// <summary>
/// The journal's own authored placeholder is <c>"00.0S, 00.0W"</c> — with
/// a space after the comma, unlike the radar's own combined form.
/// </summary>
private static string FormatLocation(float x, float y)
{
var coordinates = new AcDream.Core.Ui.RadarCoordinates(x, y);
return $"{coordinates.YText}, {coordinates.XText}";
}
private void Bind(UiElement page, uint elementId, Action action)
{
if (UiElement.FindDescendant(page, elementId) is UiButton button)
button.OnClick = action;
}
private static int ParseField(UiField? field) =>
int.TryParse(
field?.Text,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int value) && value >= 0
? value
: 0;
private static string Field(int value) =>
value == 0 ? string.Empty : value.ToString(CultureInfo.InvariantCulture);
private static void SetText(UiText? text, string value)
{
if (text is null) return;
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
}
}

View file

@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using AcDream.Core.Journal;
using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Journal panel's Page List — retail <c>gmPageListUI</c> (element type
/// <c>0x10000049</c>, page <c>0x10000564</c>).
/// </summary>
/// <remarks>
/// A searchable index over the journal's pages: number, title, timer and
/// label, with Delete and a search box. Double-clicking a row opens that page
/// on the Journal tab (<c>CheckForDoubleClick @0x00493140</c> →
/// <c>gmJournalUI::GotoPage</c>).
/// </remarks>
public sealed class JournalPageListController
{
/// <summary>The layout the row template lives in — authored <c>0x63</c>.</summary>
public const uint RowTemplateLayoutId = 0x21000067u;
/// <summary>The row template element — authored <c>0x62</c>.</summary>
public const uint RowTemplateElementId = 0x10000589u;
private const uint ListId = 0x10000583u;
private const uint DeleteButtonId = 0x10000585u;
private const uint SearchFieldId = 0x10000587u;
private const uint ResetButtonId = 0x10000588u;
private const uint RowNumberId = 0x1000058Au;
private const uint RowTitleId = 0x1000058Bu;
private const uint RowTimerId = 0x1000058Cu;
private const uint RowLabelId = 0x1000058Du;
private static readonly Vector4 SelectedNameColor = Vector4.One;
/// <param name="Journal">The canonical owner.</param>
/// <param name="Commands">Mutations.</param>
/// <param name="OpenPage">
/// Opens a page on the Journal tab — the panel owns the tab switch, this
/// page owns only the choice.
/// </param>
public sealed record Bindings(
IRuntimeJournalView Journal,
RuntimeJournalState Commands,
Action<int> OpenPage,
Func<uint, uint, UiElement?> TemplateResolver,
Func<DateTime>? Now = null);
private readonly Bindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiField? _search;
private readonly List<int> _rowPages = [];
private readonly List<(int Page, UiText? Title, Vector4 Unselected)> _rows = [];
/// <summary>
/// Retail's double-click window — <c>m_LastClickTime + 1.0</c> in
/// <c>CheckForDoubleClick @0x00493158</c>. A full SECOND, not the 500 ms
/// the item-interaction path uses; the two are separate mechanisms and
/// borrowing the wrong constant makes the list feel unresponsive.
/// </summary>
private static readonly TimeSpan DoubleClickWindow = TimeSpan.FromSeconds(1d);
private long _renderedRevision = -1;
private string _renderedSearch = string.Empty;
private int _selectedPage;
private int _lastClickPage;
private DateTime _lastClickAt;
public JournalPageListController(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_list = UiElement.FindDescendant(page, ListId) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = bindings.TemplateResolver;
_search = UiElement.FindDescendant(page, SearchFieldId) as UiField;
if (UiElement.FindDescendant(page, DeleteButtonId) is UiButton delete)
delete.OnClick = DeleteSelected;
if (UiElement.FindDescendant(page, ResetButtonId) is UiButton reset)
{
reset.OnClick = () =>
{
_search?.SetText(string.Empty);
Refresh();
};
}
Refresh();
}
/// <summary>The 1-based page the list has selected, or 0.</summary>
public int SelectedPage => _selectedPage;
/// <summary>The 1-based page numbers currently listed, in order.</summary>
public IReadOnlyList<int> RowPages => _rowPages;
/// <summary>
/// Retail's search predicate — <c>PageContainsString @0x00493B60</c>.
/// </summary>
/// <remarks>
/// Matches Label, Title OR Notes, and is CASE-SENSITIVE: retail compares
/// with <c>wcsstr</c> and never lowercases either side. Making it
/// insensitive would be friendlier and would be a divergence.
/// </remarks>
public static bool PageContainsString(JournalPage page, string search)
{
ArgumentNullException.ThrowIfNull(page);
if (string.IsNullOrEmpty(search))
return true;
return page.Label.Contains(search, StringComparison.Ordinal)
|| page.Title.Contains(search, StringComparison.Ordinal)
|| page.Notes.Contains(search, StringComparison.Ordinal);
}
public void Tick()
{
string search = _search?.Text ?? string.Empty;
if (_bindings.Journal.Snapshot.Revision != _renderedRevision
|| !string.Equals(search, _renderedSearch, StringComparison.Ordinal))
{
Refresh();
}
}
public void Refresh()
{
RuntimeJournalSnapshot snapshot = _bindings.Journal.Snapshot;
_renderedRevision = snapshot.Revision;
_renderedSearch = _search?.Text ?? string.Empty;
IReadOnlyList<JournalPage> pages = _bindings.Journal.Pages;
_rowPages.Clear();
_rows.Clear();
_list?.FlushPreservingScroll();
for (int i = 0; i < pages.Count; i++)
{
JournalPage page = pages[i];
if (!PageContainsString(page, _renderedSearch))
continue;
int pageNumber = i + 1;
_rowPages.Add(pageNumber);
if (_list is null)
continue;
UiElement? row = _list.AddItemFromTemplateList(0);
if (row is null)
continue;
SetText(UiElement.FindDescendant(row, RowNumberId) as UiText,
pageNumber.ToString(CultureInfo.InvariantCulture));
var title = UiElement.FindDescendant(row, RowTitleId) as UiText;
SetText(title, page.Title);
SetText(UiElement.FindDescendant(row, RowTimerId) as UiText,
page.IsTimerRunning
? RetailDurationText.Format(page.RunningTimerSeconds)
: page.HasTimer
? RetailDurationText.Format(page.TimerDuration.TotalSeconds)
: string.Empty);
SetText(UiElement.FindDescendant(row, RowLabelId) as UiText, page.Label);
_rows.Add((pageNumber, title, title?.DefaultColor ?? Vector4.One));
int captured = pageNumber;
if (row is UiDatElement clickable)
{
// A Type-3 row is click-through by default; without this the
// list cannot be selected at all.
clickable.ClickThrough = false;
clickable.OnClick = () => Click(captured);
}
}
// A filtered-out selection is dropped: Delete must never act on a row
// the player cannot see.
if (_selectedPage != 0 && !_rowPages.Contains(_selectedPage))
_selectedPage = 0;
ApplySelectionHighlight();
}
/// <summary>
/// One row click. Port of <c>gmPageListUI::CheckForDoubleClick
/// @0x00493140</c>: a second click on the SAME row within the window opens
/// it, and firing resets the tracker so a third click does not re-trigger.
/// </summary>
public void Click(int pageNumber)
{
DateTime now = _bindings.Now?.Invoke() ?? DateTime.UtcNow;
if (pageNumber == _lastClickPage && now - _lastClickAt <= DoubleClickWindow)
{
_lastClickPage = 0;
_lastClickAt = default;
Open(pageNumber);
return;
}
_lastClickPage = pageNumber;
_lastClickAt = now;
Select(pageNumber);
}
public void Select(int pageNumber)
{
_selectedPage = pageNumber;
ApplySelectionHighlight();
}
/// <summary>Opens a page on the Journal tab — retail's double-click.</summary>
public void Open(int pageNumber)
{
Select(pageNumber);
_bindings.OpenPage(pageNumber);
}
private void DeleteSelected()
{
if (_selectedPage == 0)
return;
_bindings.Commands.DeletePage(_selectedPage);
_selectedPage = 0;
Refresh();
}
private void ApplySelectionHighlight()
{
foreach ((int pageNumber, UiText? title, Vector4 unselected) in _rows)
{
if (title is not null)
title.DefaultColor = pageNumber == _selectedPage ? SelectedNameColor : unselected;
}
}
private static void SetText(UiText? text, string value)
{
if (text is null) return;
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
}
}

View file

@ -22,9 +22,9 @@ namespace AcDream.App.UI.Layout;
/// button 0x10000561 ("Page List") -&gt; page 0x10000564
/// </code>
/// <para>
/// Only the Contracts page is in scope for Campaign QT. The Journal notes page
/// and the Page List are their own feature; mounting the panel with those two
/// tabs inert is the intended state, not a defect.
/// All three pages are live as of Campaign QJ. Contracts is server state;
/// the other two are one per-character notebook and its index, sharing
/// <see cref="AcDream.Runtime.Gameplay.RuntimeJournalState"/>.
/// </para>
/// </remarks>
public sealed class JournalPanelController : IRetainedPanelController
@ -44,11 +44,20 @@ public sealed class JournalPanelController : IRetainedPanelController
/// <c>0x1000004B</c>.</summary>
public const uint ContractsPageId = 0x100005D4u;
/// <summary>The notes page — <c>gmJournalUI</c>, type <c>0x10000048</c>.</summary>
public const uint NotesPageId = 0x10000563u;
/// <summary>The index page — <c>gmPageListUI</c>, type <c>0x10000049</c>.</summary>
public const uint PageListPageId = 0x10000564u;
/// <summary>The panel's own corner button.</summary>
private const uint CloseButtonId = 0x10000562u;
private readonly UiTabPanel _tabPanel;
private readonly JournalContractsPageController? _contracts;
private readonly JournalNotesPageController? _notes;
private JournalPageListController? _pageList;
private readonly Action<uint, uint> _onActivePageChanged;
private bool _disposed;
/// <summary>Root element of the imported panel — the tab host itself.</summary>
@ -58,17 +67,47 @@ public sealed class JournalPanelController : IRetainedPanelController
public JournalContractsPageController? Contracts => _contracts;
public JournalNotesPageController? Notes => _notes;
public JournalPageListController? PageList => _pageList;
private readonly Action _saveJournal;
private JournalPanelController(
UiTabPanel tabPanel,
JournalContractsPageController? contracts)
JournalContractsPageController? contracts,
JournalNotesPageController? notes,
JournalPageListController? pageList,
Action saveJournal)
{
_tabPanel = tabPanel;
_contracts = contracts;
_notes = notes;
_pageList = pageList;
_saveJournal = saveJournal;
// Retail saves the journal when the notes page is HIDDEN
// (gmJournalUI::OnVisibilityChanged @0x004978F0), not only at exit, so
// a crash costs at most the page you are looking at. Leaving the tab
// is that moment here.
_onActivePageChanged = (previous, _) =>
{
if (previous == NotesPageId)
{
_notes?.OnHidden();
_saveJournal();
}
};
_tabPanel.ActivePageChanged += _onActivePageChanged;
}
public sealed record Callbacks(
Action Toggle,
JournalContractsPageController.Bindings Contracts);
JournalContractsPageController.Bindings Contracts,
JournalNotesPageController.Bindings Notes,
/// <summary>Writes the journal file — retail's save-on-hide.</summary>
Action SaveJournal,
Func<Action<int>, JournalPageListController.Bindings> PageList);
/// <summary>
/// Binds an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
@ -93,12 +132,40 @@ public sealed class JournalPanelController : IRetainedPanelController
close.OnClick = callbacks.Toggle;
JournalContractsPageController? contracts = null;
if (layout.FindElement(ContractsPageId) is { } page)
contracts = new JournalContractsPageController(page, callbacks.Contracts);
if (layout.FindElement(ContractsPageId) is { } contractsPage)
contracts = new JournalContractsPageController(contractsPage, callbacks.Contracts);
else
Console.WriteLine("[D.2b] JournalPanelController: contracts page not found.");
return new JournalPanelController(tabPanel, contracts);
JournalNotesPageController? notes = null;
if (layout.FindElement(NotesPageId) is { } notesPage)
notes = new JournalNotesPageController(notesPage, callbacks.Notes);
else
Console.WriteLine("[D.2b] JournalPanelController: notes page not found.");
JournalPageListController? pageList = null;
var built = new JournalPanelController(
tabPanel, contracts, notes, pageList: null, callbacks.SaveJournal);
if (layout.FindElement(PageListPageId) is { } listPage)
{
// The index opens a page on the NOTES tab, so it needs the panel
// that owns the tab switch — hence the deferred binding.
pageList = new JournalPageListController(
listPage,
callbacks.PageList(pageNumber =>
{
notes?.CommitText();
callbacks.Notes.Commands.GotoPage(pageNumber);
built.ShowNotes();
}));
}
else
{
Console.WriteLine("[D.2b] JournalPanelController: page list not found.");
}
built.AttachPageList(pageList);
return built;
}
/// <summary>
@ -110,11 +177,32 @@ public sealed class JournalPanelController : IRetainedPanelController
/// <summary>Switches to the Contracts tab.</summary>
public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId);
/// <summary>Switches to the notes tab — what opening a page from the index does.</summary>
public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId);
/// <summary>
/// Completes construction. The index needs a callback that switches tabs,
/// which needs the panel — so it is attached rather than constructed.
/// </summary>
private void AttachPageList(JournalPageListController? pageList)
=> _pageList = pageList;
public void Tick()
{
if (_disposed) return;
_contracts?.Tick();
_notes?.Tick();
_pageList?.Tick();
}
public void Dispose() => _disposed = true;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_tabPanel.ActivePageChanged -= _onActivePageChanged;
// The panel closing is the other half of retail's save-on-hide.
_notes?.OnHidden();
_saveJournal();
}
}

View file

@ -317,7 +317,17 @@ public sealed record MapHouseRuntimeBindings(
/// </summary>
public sealed record QuestRuntimeBindings(
AcDream.Runtime.Gameplay.IRuntimeContractView Contracts,
Func<AcDream.Core.Quests.ContractCatalog> Catalog);
Func<AcDream.Core.Quests.ContractCatalog> Catalog,
// Campaign QJ (2026-08-21): the notebook the other two tabs share. The
// command owner is passed alongside its own read view because the journal
// is WRITTEN by the panel — unlike contracts, which are server state.
AcDream.Runtime.Gameplay.IRuntimeJournalView Journal,
AcDream.Runtime.Gameplay.RuntimeJournalState JournalCommands,
Func<uint> PlayerCell,
/// <summary>Where the per-character journal file lives.</summary>
string JournalDirectory,
/// <summary>How a load or save failure reaches the player.</summary>
Action<string> Report);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@ -705,6 +715,29 @@ public sealed class RetailUiRuntime : IDisposable
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
public Layout.JournalPanelController? JournalPanelController { get; private set; }
private JournalPersistence? _journalFile;
/// <summary>
/// Campaign QJ slice QJ5. One owner, here rather than in the session
/// factory, because the panel that writes the journal lives here — two
/// instances would each track their own file path and overwrite each
/// other.
/// </summary>
private JournalPersistence JournalFile =>
_journalFile ??= new JournalPersistence(
_bindings.Quests.JournalCommands,
_bindings.Quests.JournalDirectory,
_bindings.Quests.Report);
/// <summary>Loads a character's journal — called on entering the world.</summary>
public void LoadJournal(string characterName) => JournalFile.Load(characterName);
/// <summary>
/// Writes the journal if anything changed. Retail's own moment is the notes
/// page being hidden, not only session exit.
/// </summary>
public void SaveJournal() => JournalFile.Save(DateTime.UtcNow);
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
@ -3520,7 +3553,23 @@ public sealed class RetailUiRuntime : IDisposable
{
lock (_bindings.Assets.DatLock)
return rowTemplates.Resolve(templateLayoutId, templateElementId);
}));
}),
Notes: new Layout.JournalNotesPageController.Bindings(
Journal: _bindings.Quests.Journal,
Commands: _bindings.Quests.JournalCommands,
PlayerCell: _bindings.Quests.PlayerCell,
Now: () => DateTime.UtcNow),
SaveJournal: SaveJournal,
PageList: openPage => new Layout.JournalPageListController.Bindings(
Journal: _bindings.Quests.Journal,
Commands: _bindings.Quests.JournalCommands,
OpenPage: openPage,
TemplateResolver: (templateLayoutId, templateElementId) =>
{
lock (_bindings.Assets.DatLock)
return rowTemplates.Resolve(templateLayoutId, templateElementId);
},
Now: () => DateTime.UtcNow));
Layout.JournalPanelController? controller;
lock (_bindings.Assets.DatLock)