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

View file

@ -0,0 +1,103 @@
using System;
using System.Linq;
using AcDream.Core.Quests;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests;
/// <summary>
/// Installed-DAT gate for <see cref="ContractTableReader"/>: proves the real
/// ContractTable (portal.dat <c>0x0E00001D</c>) loads through the SAME
/// <see cref="DatCollectionAdapter"/> 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.
/// </summary>
[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;
}
}

View file

@ -0,0 +1,183 @@
using System;
using AcDream.Core.Quests;
namespace AcDream.Core.Tests.Quests;
/// <summary>
/// Campaign QT slice QT4: <c>gmContractsUI::FillProgressString @0x00498DE0</c>
/// and the <c>ClientUISystem::DeltaTimeToString @0x00565E10</c> it calls.
/// </summary>
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));
}
}

View file

@ -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<DatReaderWriter.DBObjs.ContractTable>(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<string, int>(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)
{