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

@ -1,6 +1,6 @@
using System;
using System.Globalization;
using System.Text;
using AcDream.Core.Ui;
namespace AcDream.Core.Quests;
@ -10,62 +10,6 @@ namespace AcDream.Core.Quests;
/// </summary>
public static class ContractProgressText
{
private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month
private const int SecondsPerDay = 0x15180; // 86,400
private const int SecondsPerHour = 0xE10; // 3,600
private const int SecondsPerMinute = 0x3C; // 60
/// <summary>
/// Port of <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
/// </summary>
/// <remarks>
/// <para>
/// Largest-unit-first, each unit omitted when zero, seconds always shown:
/// <c>"2d 3h 4m 5s"</c>, <c>"45s"</c>. A "month" is a flat 30 days.
/// </para>
/// <para>
/// Every part is emitted with a TRAILING space and the final one is then
/// truncated. That truncation is not visible in the decompiler output —
/// the instruction reads as noise — so it was settled by decoding the
/// bytes: at <c>0x00565F0E</c>, <c>mov byte ptr [esp+eax+0x1b], cl</c>
/// with <c>cl == 0</c> and <c>eax == strlen</c> writes the terminator over
/// <c>buffer[len - 1]</c>. Without it, the caller composes
/// <c>"Done (1h 30s to Repeat)"</c> with a double space.
/// </para>
/// </remarks>
public static string DeltaTimeToString(double seconds)
{
// Retail's _ftol2 — truncation toward zero, matching a C cast.
long total = (long)seconds;
if (total < 0) total = 0;
long months = total / SecondsPerMonth;
long rest = total % SecondsPerMonth;
long days = rest / SecondsPerDay;
rest %= SecondsPerDay;
long hours = rest / SecondsPerHour;
rest %= SecondsPerHour;
long minutes = rest / SecondsPerMinute;
long secs = rest % SecondsPerMinute;
var text = new StringBuilder();
if (months != 0) Append(text, months, "mo");
if (days != 0) Append(text, days, "d");
if (hours != 0) Append(text, hours, "h");
if (minutes != 0) Append(text, minutes, "m");
Append(text, secs, "s");
// The trailing space the last part just wrote.
return text.ToString(0, text.Length - 1);
static void Append(StringBuilder text, long value, string unit)
{
text.Append(value.ToString(CultureInfo.InvariantCulture));
text.Append(unit);
text.Append(' ');
}
}
/// <summary>
/// The progress text for one tracked contract.
/// </summary>
@ -119,7 +63,7 @@ public static class ContractProgressText
double remaining = timeWhenRepeats - elapsed;
if (remaining <= 0d) return "Available";
return $"Done ({DeltaTimeToString(remaining)} to Repeat)";
return $"Done ({RetailDurationText.Format(remaining)} to Repeat)";
}
if (stage >= 4u)

View file

@ -0,0 +1,74 @@
using System;
using System.Globalization;
using System.Text;
namespace AcDream.Core.Ui;
/// <summary>
/// Retail's client-wide duration wording —
/// <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
/// </summary>
/// <remarks>
/// Client-wide rather than per-feature: the contract tracker's repeat countdown
/// and the journal page's timer both call it, so both read identically. It
/// lived in the contract code first only because that was its first caller.
/// </remarks>
public static class RetailDurationText
{
private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month
private const int SecondsPerDay = 0x15180; // 86,400
private const int SecondsPerHour = 0xE10; // 3,600
private const int SecondsPerMinute = 0x3C; // 60
/// <summary>
/// Port of <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
/// </summary>
/// <remarks>
/// <para>
/// Largest-unit-first, each unit omitted when zero, seconds always shown:
/// <c>"2d 3h 4m 5s"</c>, <c>"45s"</c>. A "month" is a flat 30 days.
/// </para>
/// <para>
/// Every part is emitted with a TRAILING space and the final one is then
/// truncated. That truncation is not visible in the decompiler output —
/// the instruction reads as noise — so it was settled by decoding the
/// bytes: at <c>0x00565F0E</c>, <c>mov byte ptr [esp+eax+0x1b], cl</c>
/// with <c>cl == 0</c> and <c>eax == strlen</c> writes the terminator over
/// <c>buffer[len - 1]</c>. Without it, the caller composes
/// <c>"Done (1h 30s to Repeat)"</c> with a double space.
/// </para>
/// </remarks>
public static string Format(double seconds)
{
// Retail's _ftol2 — truncation toward zero, matching a C cast.
long total = (long)seconds;
if (total < 0) total = 0;
long months = total / SecondsPerMonth;
long rest = total % SecondsPerMonth;
long days = rest / SecondsPerDay;
rest %= SecondsPerDay;
long hours = rest / SecondsPerHour;
rest %= SecondsPerHour;
long minutes = rest / SecondsPerMinute;
long secs = rest % SecondsPerMinute;
var text = new StringBuilder();
if (months != 0) Append(text, months, "mo");
if (days != 0) Append(text, days, "d");
if (hours != 0) Append(text, hours, "h");
if (minutes != 0) Append(text, minutes, "m");
Append(text, secs, "s");
// The trailing space the last part just wrote.
return text.ToString(0, text.Length - 1);
static void Append(StringBuilder text, long value, string unit)
{
text.Append(value.ToString(CultureInfo.InvariantCulture));
text.Append(unit);
text.Append(' ');
}
}
}