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) {