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());
}
}