feat(quest): QT2/QT4 — the contract catalog, and retail's progress column

The wire carries an id, a stage and two timers. Every word the player reads
lives in portal.dat's ContractTable, which nothing in the tree had ever
opened — the only reference counted its entries in a CLI diagnostic. Chorizite
does decode it (322 contracts installed), which was a real question given it
declares TabooTable without decoding it.

FillProgressString @0x00498DE0 is the one real algorithm in this panel, and it
is now ported whole. Its x87 compares are the usual fcom/sahf pattern, so the
(status & 0x41) tests decode as "<= 0" rather than "< 0" — the difference
between a cooldown that expires and one that never does.

Three readings recorded as tests because each looks like a mistake:
TimeWhenDone is on the wire and is never read; an EMPTY QuestflagRepeatTime is
the entire difference between "Done" and "Available"; and DescriptionProgress
is a printf format taking stage-4, not a literal — rendering it verbatim shows
the player "%d/20 Tuskers".

DeltaTimeToString @0x00565E10 emits every part with a trailing space and then
overwrites the last one. That truncation is invisible in the decompiler output
(the instruction reads as pointer noise), so it was settled by decoding the
bytes: mov byte ptr [esp+eax+0x1b], cl with cl == 0 and eax == strlen writes
the terminator over buffer[len-1]. Guessing either way was a coin flip that
decides whether every repeat timer reads "Done (1h 30s  to Repeat)".

The single-%d substitution is a MEASUREMENT, not a convenience: 89 of the 322
installed contracts author a progress format and every one uses exactly one
specifier. An installed-DAT test asserts that, so a future dat that ships two
fails there rather than silently rendering a raw specifier.

LayoutDump gained --contracts, which is how all of the above was measured.

Campaign QT slices 2 and 4 of 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 14:53:21 +02:00
parent f629ce7f3d
commit ef6b7310c5
6 changed files with 637 additions and 0 deletions

View file

@ -0,0 +1,69 @@
using System.Collections.Frozen;
using System.Collections.Generic;
using AcDream.Core.Quests;
using DatContractTable = DatReaderWriter.DBObjs.ContractTable;
namespace AcDream.Content;
/// <summary>
/// Projects portal.dat's ContractTable into acdream's presentation-free
/// <see cref="ContractCatalog"/>.
/// </summary>
/// <remarks>
/// Same shape as <see cref="CharGen.ChargenTableReader"/> and
/// <c>MagicCatalog.Load</c>: one static entry point over
/// <see cref="IDatReaderWriter"/>, frozen at projection, and no Chorizite
/// types crossing into the returned model.
/// <para>
/// Nothing read this table before Campaign QT — the only reference in the tree
/// counted its entries in a CLI diagnostic. The installed build holds 322
/// contracts.
/// </para>
/// </remarks>
public static class ContractTableReader
{
/// <summary>
/// Retail's ContractTable dat id (ACE:
/// <c>ACE.DatLoader.FileTypes.ContractTable.FILE_ID</c>).
/// </summary>
public const uint ContractTableDid = 0x0E00001Du;
/// <summary>
/// Loads the installed contract catalog, or
/// <see cref="ContractCatalog.Empty"/> when the table is absent.
/// </summary>
/// <remarks>
/// An absent table is not fatal. It costs the player the contract NAMES,
/// not the tracker: the wire state stands on its own, and the panel still
/// has stages and timers to draw.
/// </remarks>
public static ContractCatalog Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
DatContractTable? table = dats.Get<DatContractTable>(ContractTableDid);
if (table is null || table.Contracts.Count == 0)
return ContractCatalog.Empty;
var projected = new Dictionary<uint, ContractEntry>(table.Contracts.Count);
foreach ((uint key, DatReaderWriter.Types.Contract contract) in table.Contracts)
{
projected[key] = new ContractEntry(
contract.Version,
contract.ContractId,
contract.ContractName ?? string.Empty,
contract.Description ?? string.Empty,
contract.DescriptionProgress ?? string.Empty,
contract.NameNPCStart ?? string.Empty,
contract.NameNPCEnd ?? string.Empty,
contract.QuestflagStamped ?? string.Empty,
contract.QuestflagStarted ?? string.Empty,
contract.QuestflagFinished ?? string.Empty,
contract.QuestflagProgress ?? string.Empty,
contract.QuestflagTimer ?? string.Empty,
contract.QuestflagRepeatTime ?? string.Empty);
}
return new ContractCatalog(projected.ToFrozenDictionary());
}
}

View file

@ -0,0 +1,73 @@
using System.Collections.Generic;
namespace AcDream.Core.Quests;
/// <summary>
/// One contract's authored description, from portal.dat's ContractTable.
/// </summary>
/// <remarks>
/// <para>
/// The wire carries only an id, a stage and two timers — every word the player
/// reads comes from here, keyed by <see cref="ContractId"/>.
/// </para>
/// <para>
/// The <c>Questflag*</c> names are the server's own bookkeeping. The client
/// never reads or writes a quest flag (<c>r10-quest-dialogs.md</c> §1.3); it
/// keeps the names because <c>gmContractsUI::FillProgressString @0x00498DE0</c>
/// branches on whether <see cref="QuestflagRepeatTime"/> is EMPTY, which is how
/// it tells "finished for good" from "finished for now".
/// </para>
/// </remarks>
public sealed record ContractEntry(
uint Version,
uint ContractId,
string ContractName,
string Description,
/// <summary>
/// A printf format, NOT a literal — retail feeds it one integer
/// (<c>stage - 4</c>). The installed table has entries like
/// <c>"%d/20 Tuskers"</c>. Rendering it verbatim shows the player a raw
/// format specifier.
/// </summary>
string DescriptionProgress,
string NameNpcStart,
string NameNpcEnd,
string QuestflagStamped,
string QuestflagStarted,
string QuestflagFinished,
string QuestflagProgress,
string QuestflagTimer,
string QuestflagRepeatTime)
{
public static readonly ContractEntry Unknown = new(
0u, 0u,
string.Empty, string.Empty, string.Empty,
string.Empty, string.Empty,
string.Empty, string.Empty, string.Empty,
string.Empty, string.Empty, string.Empty);
}
/// <summary>The installed contract catalog, keyed by contract id.</summary>
public sealed class ContractCatalog(IReadOnlyDictionary<uint, ContractEntry> contracts)
{
public static readonly ContractCatalog Empty =
new(new Dictionary<uint, ContractEntry>());
public IReadOnlyDictionary<uint, ContractEntry> Contracts { get; } = contracts;
public int Count => Contracts.Count;
/// <summary>
/// The entry for <paramref name="contractId"/>, or
/// <see cref="ContractEntry.Unknown"/>.
/// </summary>
/// <remarks>
/// A miss is normal rather than exceptional: the server may track a
/// contract this client's dat build has never heard of, and the panel still
/// has to draw the row.
/// </remarks>
public ContractEntry Lookup(uint contractId) =>
Contracts.TryGetValue(contractId, out ContractEntry? entry)
? entry
: ContractEntry.Unknown;
}

View file

@ -0,0 +1,160 @@
using System;
using System.Globalization;
using System.Text;
namespace AcDream.Core.Quests;
/// <summary>
/// The contract tracker's progress column — a faithful port of
/// <c>gmContractsUI::FillProgressString @0x00498DE0</c>.
/// </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>
/// <param name="stage">
/// The wire stage. Deliberately a raw <c>uint</c> rather than an enum:
/// retail encodes a progress COUNTER as <c>4 + n</c>, so the values above
/// three are data, not names.
/// </param>
/// <param name="timeWhenRepeats">Seconds until the repeat cooldown ends.</param>
/// <param name="receivedAt">When this state arrived — the countdown anchor.</param>
/// <param name="entry">The authored contract, or
/// <see cref="ContractEntry.Unknown"/>.</param>
/// <param name="now">The current time.</param>
/// <remarks>
/// <para>
/// Two details worth stating because they look like mistakes:
/// </para>
/// <para>
/// <b><c>TimeWhenDone</c> is never read.</b> Only <c>TimeWhenRepeats</c>
/// reaches this text. The other timer is on the wire and simply does not
/// drive the progress column.
/// </para>
/// <para>
/// <b>An empty <c>QuestflagRepeatTime</c> is what distinguishes "Done"
/// from "Available".</b> A contract with no repeat flag is finished for
/// good; one with a repeat flag whose timer has run out is offered again.
/// </para>
/// </remarks>
public static string Build(
uint stage,
double timeWhenRepeats,
DateTime receivedAt,
ContractEntry entry,
DateTime now)
{
ArgumentNullException.ThrowIfNull(entry);
if (stage == 1u) return "Available";
if (stage == 2u) return "In Progress";
if (stage == 3u)
{
if (timeWhenRepeats <= 0d)
{
return entry.QuestflagRepeatTime.Length == 0 ? "Done" : "Available";
}
// Retail counts down from when the state ARRIVED, using its own
// clock — the server never sends that instant.
double elapsed = (now - receivedAt).TotalSeconds;
double remaining = timeWhenRepeats - elapsed;
if (remaining <= 0d) return "Available";
return $"Done ({DeltaTimeToString(remaining)} to Repeat)";
}
if (stage >= 4u)
{
// A counter with nothing authored to put it in.
if (entry.DescriptionProgress.Length == 0) return "In Progress";
return FormatProgress(entry.DescriptionProgress, stage - 4u);
}
// Stage 0 or anything else: retail returns without writing, leaving the
// caller's string as it found it.
return string.Empty;
}
/// <summary>
/// Substitutes retail's single integer argument into an authored progress
/// format.
/// </summary>
/// <remarks>
/// <c>FillProgressString</c> passes exactly ONE integer, so only the first
/// <c>%d</c> can be honoured — a second specifier would read past the
/// argument in retail too. Measured against the installed table: 89 of 322
/// contracts author a progress format and every one uses exactly one
/// <c>%d</c> (e.g. <c>"%d/20 Tuskers"</c>), so the single-substitution
/// reading covers the whole shipped catalog rather than merely the common
/// case.
/// </remarks>
private static string FormatProgress(string format, uint value)
{
int at = format.IndexOf("%d", StringComparison.Ordinal);
if (at < 0) return format;
return string.Concat(
format.AsSpan(0, at),
value.ToString(CultureInfo.InvariantCulture),
format.AsSpan(at + 2));
}
}