diff --git a/src/AcDream.Content/ContractTableReader.cs b/src/AcDream.Content/ContractTableReader.cs
new file mode 100644
index 00000000..9c7f87d7
--- /dev/null
+++ b/src/AcDream.Content/ContractTableReader.cs
@@ -0,0 +1,69 @@
+using System.Collections.Frozen;
+using System.Collections.Generic;
+using AcDream.Core.Quests;
+using DatContractTable = DatReaderWriter.DBObjs.ContractTable;
+
+namespace AcDream.Content;
+
+///
+/// Projects portal.dat's ContractTable into acdream's presentation-free
+/// .
+///
+///
+/// Same shape as and
+/// MagicCatalog.Load: one static entry point over
+/// , frozen at projection, and no Chorizite
+/// types crossing into the returned model.
+///
+/// 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.
+///
+///
+public static class ContractTableReader
+{
+ ///
+ /// Retail's ContractTable dat id (ACE:
+ /// ACE.DatLoader.FileTypes.ContractTable.FILE_ID).
+ ///
+ public const uint ContractTableDid = 0x0E00001Du;
+
+ ///
+ /// Loads the installed contract catalog, or
+ /// when the table is absent.
+ ///
+ ///
+ /// 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.
+ ///
+ public static ContractCatalog Load(IDatReaderWriter dats)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+
+ DatContractTable? table = dats.Get(ContractTableDid);
+ if (table is null || table.Contracts.Count == 0)
+ return ContractCatalog.Empty;
+
+ var projected = new Dictionary(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());
+ }
+}
diff --git a/src/AcDream.Core/Quests/ContractEntry.cs b/src/AcDream.Core/Quests/ContractEntry.cs
new file mode 100644
index 00000000..272e4057
--- /dev/null
+++ b/src/AcDream.Core/Quests/ContractEntry.cs
@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+
+namespace AcDream.Core.Quests;
+
+///
+/// One contract's authored description, from portal.dat's ContractTable.
+///
+///
+///
+/// The wire carries only an id, a stage and two timers — every word the player
+/// reads comes from here, keyed by .
+///
+///
+/// The Questflag* names are the server's own bookkeeping. The client
+/// never reads or writes a quest flag (r10-quest-dialogs.md §1.3); it
+/// keeps the names because gmContractsUI::FillProgressString @0x00498DE0
+/// branches on whether is EMPTY, which is how
+/// it tells "finished for good" from "finished for now".
+///
+///
+public sealed record ContractEntry(
+ uint Version,
+ uint ContractId,
+ string ContractName,
+ string Description,
+ ///
+ /// A printf format, NOT a literal — retail feeds it one integer
+ /// (stage - 4). The installed table has entries like
+ /// "%d/20 Tuskers". Rendering it verbatim shows the player a raw
+ /// format specifier.
+ ///
+ 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);
+}
+
+/// The installed contract catalog, keyed by contract id.
+public sealed class ContractCatalog(IReadOnlyDictionary contracts)
+{
+ public static readonly ContractCatalog Empty =
+ new(new Dictionary());
+
+ public IReadOnlyDictionary Contracts { get; } = contracts;
+
+ public int Count => Contracts.Count;
+
+ ///
+ /// The entry for , or
+ /// .
+ ///
+ ///
+ /// 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.
+ ///
+ public ContractEntry Lookup(uint contractId) =>
+ Contracts.TryGetValue(contractId, out ContractEntry? entry)
+ ? entry
+ : ContractEntry.Unknown;
+}
diff --git a/src/AcDream.Core/Quests/ContractProgressText.cs b/src/AcDream.Core/Quests/ContractProgressText.cs
new file mode 100644
index 00000000..29cc5b2f
--- /dev/null
+++ b/src/AcDream.Core/Quests/ContractProgressText.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Globalization;
+using System.Text;
+
+namespace AcDream.Core.Quests;
+
+///
+/// The contract tracker's progress column — a faithful port of
+/// gmContractsUI::FillProgressString @0x00498DE0.
+///
+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
+
+ ///
+ /// Port of ClientUISystem::DeltaTimeToString @0x00565E10.
+ ///
+ ///
+ ///
+ /// Largest-unit-first, each unit omitted when zero, seconds always shown:
+ /// "2d 3h 4m 5s", "45s". A "month" is a flat 30 days.
+ ///
+ ///
+ /// 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 0x00565F0E, mov byte ptr [esp+eax+0x1b], cl
+ /// with cl == 0 and eax == strlen writes the terminator over
+ /// buffer[len - 1]. Without it, the caller composes
+ /// "Done (1h 30s to Repeat)" with a double space.
+ ///
+ ///
+ 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(' ');
+ }
+ }
+
+ ///
+ /// The progress text for one tracked contract.
+ ///
+ ///
+ /// The wire stage. Deliberately a raw uint rather than an enum:
+ /// retail encodes a progress COUNTER as 4 + n, so the values above
+ /// three are data, not names.
+ ///
+ /// Seconds until the repeat cooldown ends.
+ /// When this state arrived — the countdown anchor.
+ /// The authored contract, or
+ /// .
+ /// The current time.
+ ///
+ ///
+ /// Two details worth stating because they look like mistakes:
+ ///
+ ///
+ /// TimeWhenDone is never read. Only TimeWhenRepeats
+ /// reaches this text. The other timer is on the wire and simply does not
+ /// drive the progress column.
+ ///
+ ///
+ /// An empty QuestflagRepeatTime is what distinguishes "Done"
+ /// from "Available". A contract with no repeat flag is finished for
+ /// good; one with a repeat flag whose timer has run out is offered again.
+ ///
+ ///
+ 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;
+ }
+
+ ///
+ /// Substitutes retail's single integer argument into an authored progress
+ /// format.
+ ///
+ ///
+ /// FillProgressString passes exactly ONE integer, so only the first
+ /// %d 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
+ /// %d (e.g. "%d/20 Tuskers"), so the single-substitution
+ /// reading covers the whole shipped catalog rather than merely the common
+ /// case.
+ ///
+ 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));
+ }
+}
diff --git a/tests/AcDream.Content.Tests/ContractTableReaderInstalledDatTests.cs b/tests/AcDream.Content.Tests/ContractTableReaderInstalledDatTests.cs
new file mode 100644
index 00000000..76fa144e
--- /dev/null
+++ b/tests/AcDream.Content.Tests/ContractTableReaderInstalledDatTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using AcDream.Core.Quests;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+
+namespace AcDream.Content.Tests;
+
+///
+/// Installed-DAT gate for : proves the real
+/// ContractTable (portal.dat 0x0E00001D) loads through the SAME
+/// production uses. Nothing read this table
+/// before Campaign QT, so "does Chorizite decode it at all, or merely declare
+/// the type?" was a live question — it decodes it.
+///
+[Trait("Lane", "InstalledDat")]
+public sealed class ContractTableReaderInstalledDatTests
+{
+ private static ContractCatalog Load()
+ {
+ string? datDir = ContentConformanceDats.ResolveDatDir();
+ if (datDir is null)
+ {
+ Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
+ Assert.Fail(
+ "Lane=InstalledDat requires an installed retail DAT directory; "
+ + "see docs/release-gate.md.");
+ }
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var adapter = new DatCollectionAdapter(dats);
+ return ContractTableReader.Load(adapter);
+ }
+
+ [Fact]
+ public void TheInstalledTableLoadsWithItsFullContractRoster()
+ {
+ ContractCatalog catalog = Load();
+
+ // 322 in the installed build. Asserted as a floor rather than an
+ // equality so a different dat revision is not a failure — the point is
+ // that the table decodes at all.
+ Assert.True(
+ catalog.Count >= 300,
+ $"expected the full contract roster, got {catalog.Count}");
+ }
+
+ [Fact]
+ public void EveryContractCarriesTheNameThePanelDraws()
+ {
+ // The wire sends only an id; if these come back empty the panel has
+ // nothing to show, and the failure would look like a UI bug.
+ ContractCatalog catalog = Load();
+
+ int named = catalog.Contracts.Values.Count(c => c.ContractName.Length > 0);
+
+ Assert.True(
+ named > catalog.Count / 2,
+ $"only {named} of {catalog.Count} contracts have a name");
+ }
+
+ [Fact]
+ public void EveryAuthoredProgressFormatUsesExactlyOneIntegerSpecifier()
+ {
+ // ContractProgressText substitutes only the FIRST %d, because retail's
+ // FillProgressString passes exactly one argument. This is the
+ // measurement that makes that reading safe rather than merely
+ // convenient — if a future dat ships a format with two specifiers, the
+ // single-substitution port needs revisiting and this fails first.
+ ContractCatalog catalog = Load();
+
+ var offenders = catalog.Contracts.Values
+ .Where(c => c.DescriptionProgress.Length > 0)
+ .Where(c => CountSpecifiers(c.DescriptionProgress) != 1)
+ .Select(c => $"0x{c.ContractId:X8} \"{c.DescriptionProgress}\"")
+ .ToArray();
+
+ Assert.Empty(offenders);
+ }
+
+ [Fact]
+ public void AMissingContractResolvesToTheUnknownEntryRatherThanThrowing()
+ {
+ // The server may track a contract this dat build has never heard of,
+ // and the panel still has to draw the row.
+ ContractCatalog catalog = Load();
+
+ ContractEntry entry = catalog.Lookup(0xDEADBEEFu);
+
+ Assert.Same(ContractEntry.Unknown, entry);
+ }
+
+ private static int CountSpecifiers(string format)
+ {
+ int count = 0;
+ for (int i = 0; i < format.Length - 1; i++)
+ {
+ if (format[i] == '%' && format[i + 1] != '%')
+ count++;
+ }
+ return count;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs
new file mode 100644
index 00000000..7de63e38
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs
@@ -0,0 +1,183 @@
+using System;
+using AcDream.Core.Quests;
+
+namespace AcDream.Core.Tests.Quests;
+
+///
+/// Campaign QT slice QT4: gmContractsUI::FillProgressString @0x00498DE0
+/// and the ClientUISystem::DeltaTimeToString @0x00565E10 it calls.
+///
+public sealed class ContractProgressTextTests
+{
+ private static readonly DateTime Arrival = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
+
+ private static ContractEntry Entry(
+ string descriptionProgress = "", string questflagRepeatTime = "")
+ => ContractEntry.Unknown with
+ {
+ DescriptionProgress = descriptionProgress,
+ QuestflagRepeatTime = questflagRepeatTime,
+ };
+
+ // ── DeltaTimeToString ───────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(0, "0s")]
+ [InlineData(45, "45s")]
+ [InlineData(60, "1m 0s")]
+ [InlineData(3600, "1h 0s")] // minutes are OMITTED when zero
+ [InlineData(3661, "1h 1m 1s")]
+ [InlineData(86400, "1d 0s")]
+ [InlineData(2592000, "1mo 0s")] // a "month" is a flat 30 days
+ [InlineData(2592000 + 86400 + 3600 + 61, "1mo 1d 1h 1m 1s")]
+ public void DeltaTimeFormatsLargestUnitFirstAndAlwaysShowsSeconds(
+ double seconds, string expected)
+ => Assert.Equal(expected, ContractProgressText.DeltaTimeToString(seconds));
+
+ [Fact]
+ public void DeltaTimeHasNoTrailingSpace()
+ {
+ // Retail emits every part WITH a trailing space and then writes the
+ // terminator over the last one (0x00565F0E). Missing that truncation
+ // gives "Done (30s to Repeat)" with a double space — and the
+ // instruction is invisible in the decompiler output, so this is the
+ // assertion that pins the byte-level reading.
+ string text = ContractProgressText.DeltaTimeToString(30);
+
+ Assert.Equal("30s", text);
+ Assert.DoesNotContain(" ", ContractProgressText.Build(
+ 3u, 30d, Arrival, Entry(questflagRepeatTime: "flag"), Arrival));
+ }
+
+ [Fact]
+ public void DeltaTimeTruncatesTowardZeroLikeRetailsFtol()
+ {
+ Assert.Equal("59s", ContractProgressText.DeltaTimeToString(59.99));
+ }
+
+ // ── the stage arms ──────────────────────────────────────────────────
+
+ [Fact]
+ public void StageOneIsAvailable()
+ => Assert.Equal("Available", ContractProgressText.Build(
+ 1u, 0d, Arrival, Entry(), Arrival));
+
+ [Fact]
+ public void StageTwoIsInProgress()
+ => Assert.Equal("In Progress", ContractProgressText.Build(
+ 2u, 0d, Arrival, Entry(), Arrival));
+
+ [Fact]
+ public void StageThreeWithNoRepeatFlagIsDoneForGood()
+ {
+ // An empty QuestflagRepeatTime is the whole difference between a
+ // one-shot quest and a repeatable one on cooldown.
+ Assert.Equal("Done", ContractProgressText.Build(
+ 3u, 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival));
+ }
+
+ [Fact]
+ public void StageThreeWithARepeatFlagAndNoTimerIsAvailableAgain()
+ {
+ Assert.Equal("Available", ContractProgressText.Build(
+ 3u, 0d, Arrival, Entry(questflagRepeatTime: "SomeQuestRepeat"), Arrival));
+ }
+
+ [Fact]
+ public void StageThreeWithATimerStillRunningCountsDownToTheRepeat()
+ {
+ string text = ContractProgressText.Build(
+ 3u,
+ timeWhenRepeats: 3661d,
+ Arrival,
+ Entry(questflagRepeatTime: "SomeQuestRepeat"),
+ now: Arrival);
+
+ Assert.Equal("Done (1h 1m 1s to Repeat)", text);
+ }
+
+ [Fact]
+ public void TheCountdownIsAnchoredAtArrivalNotRecomputedFromTheServerValue()
+ {
+ // The server sends the remaining seconds ONCE and never sends the
+ // instant it measured them from. Anchoring at arrival is what makes
+ // the timer tick; without it the same number would be shown forever.
+ string atArrival = ContractProgressText.Build(
+ 3u, 600d, Arrival, Entry(questflagRepeatTime: "f"), Arrival);
+ string tenMinutesLater = ContractProgressText.Build(
+ 3u, 600d, Arrival, Entry(questflagRepeatTime: "f"),
+ Arrival.AddMinutes(5));
+
+ Assert.Equal("Done (10m 0s to Repeat)", atArrival);
+ Assert.Equal("Done (5m 0s to Repeat)", tenMinutesLater);
+ }
+
+ [Fact]
+ public void ATimerThatHasRunOutSinceArrivalReadsAsAvailable()
+ {
+ Assert.Equal("Available", ContractProgressText.Build(
+ 3u, 600d, Arrival, Entry(questflagRepeatTime: "f"),
+ now: Arrival.AddHours(1)));
+ }
+
+ [Fact]
+ public void TimeWhenDoneNeverReachesThisText()
+ {
+ // It IS on the wire and it does NOT drive the progress column. Passing
+ // it here instead of TimeWhenRepeats is the plausible misreading; the
+ // signature refuses it, and this test says why.
+ string text = ContractProgressText.Build(
+ 3u, timeWhenRepeats: 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival);
+
+ Assert.Equal("Done", text);
+ }
+
+ // ── the progress counter ────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(4u, "0/20 Tuskers")]
+ [InlineData(9u, "5/20 Tuskers")]
+ [InlineData(24u, "20/20 Tuskers")]
+ public void StageFourAndAboveSubstitutesTheCountIntoTheAuthoredFormat(
+ uint stage, string expected)
+ {
+ // The count is stage - 4, and DescriptionProgress is a printf format,
+ // not a literal — rendering it verbatim shows the player "%d/20".
+ Assert.Equal(expected, ContractProgressText.Build(
+ stage, 0d, Arrival, Entry(descriptionProgress: "%d/20 Tuskers"), Arrival));
+ }
+
+ [Fact]
+ public void AProgressStageWithNoAuthoredFormatFallsBackToInProgress()
+ {
+ Assert.Equal("In Progress", ContractProgressText.Build(
+ 7u, 0d, Arrival, Entry(descriptionProgress: ""), Arrival));
+ }
+
+ [Fact]
+ public void AFormatWithoutASpecifierIsShownVerbatim()
+ {
+ Assert.Equal("Gathering herbs", ContractProgressText.Build(
+ 6u, 0d, Arrival, Entry(descriptionProgress: "Gathering herbs"), Arrival));
+ }
+
+ [Fact]
+ public void OnlyTheFirstSpecifierIsSubstitutedBecauseRetailPassesOneArgument()
+ {
+ // A second %d would read past the argument in retail too. No installed
+ // contract has one (measured: 89 formats, all exactly one %d), so this
+ // pins the behaviour rather than describing shipped content.
+ Assert.Equal("3 of %d", ContractProgressText.Build(
+ 7u, 0d, Arrival, Entry(descriptionProgress: "%d of %d"), Arrival));
+ }
+
+ [Fact]
+ public void AnUnknownStageProducesNothingRatherThanGuessing()
+ {
+ // Retail returns without writing, leaving the caller's string as it
+ // found it. Inventing a label here would put text on screen that the
+ // real client never shows.
+ Assert.Equal(string.Empty, ContractProgressText.Build(
+ 0u, 0d, Arrival, Entry(), Arrival));
+ }
+}
diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs
index 9f819ed6..a64340d2 100644
--- a/tools/LayoutDump/Program.cs
+++ b/tools/LayoutDump/Program.cs
@@ -35,6 +35,55 @@ string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
+if (args.Contains("--contracts"))
+{
+ // Campaign QT slice QT2: what does the installed ContractTable actually
+ // hold, and does the reader decode it at all?
+ var table = dats.Get(0x0E00001Du);
+ if (table is null)
+ {
+ Console.WriteLine("ContractTable 0x0E00001D not found");
+ return 2;
+ }
+
+ Console.WriteLine($"ContractTable 0x{table.Id:X8}: {table.Contracts.Count} contracts");
+
+ // Which printf specifiers does the authored DescriptionProgress actually
+ // use? FillProgressString passes exactly ONE integer, so anything else
+ // would be reading past the argument in retail too.
+ var specs = new SortedDictionary(StringComparer.Ordinal);
+ int withProgress = 0;
+ foreach (var c in table.Contracts.Values)
+ {
+ string f = c.DescriptionProgress ?? "";
+ if (f.Length == 0) continue;
+ withProgress++;
+ for (int i = 0; i < f.Length - 1; i++)
+ {
+ if (f[i] != '%') continue;
+ string spec = f.Substring(i, 2);
+ specs[spec] = specs.TryGetValue(spec, out int n) ? n + 1 : 1;
+ }
+ }
+ Console.WriteLine($" {withProgress} have a DescriptionProgress; specifiers:");
+ foreach (var (spec, n) in specs)
+ Console.WriteLine($" {spec} x{n}");
+
+ int shown = 0;
+ foreach (var (key, contract) in table.Contracts.OrderBy(kv => kv.Key))
+ {
+ if (shown++ >= 5) break;
+ Console.WriteLine($" 0x{key:X8} v{contract.Version} \"{contract.ContractName}\"");
+ Console.WriteLine($" desc: {contract.Description}");
+ Console.WriteLine($" progress: {contract.DescriptionProgress}");
+ Console.WriteLine($" npc: {contract.NameNPCStart} -> {contract.NameNPCEnd}");
+ Console.WriteLine($" flags: started={contract.QuestflagStarted} "
+ + $"finished={contract.QuestflagFinished} progress={contract.QuestflagProgress} "
+ + $"repeat={contract.QuestflagRepeatTime}");
+ }
+ return 0;
+}
+
int findAt = Array.IndexOf(args, "--find");
if (findAt >= 0)
{