From ab3934e21db6250ffb112ff145d4c5cfa4bdecc7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 14:38:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(quest):=20QT1=20=E2=80=94=20parse=20the=20?= =?UTF-8?q?contract-tracker=20events=20we=20have=20been=20dropping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both opcodes have been named in GameEventType since the wire-catalog work with nothing behind them, so every contract the server has ever sent us arrived and was discarded. Three details that a reimplementation from the enum alone would get wrong, and each has a test: The two trailing flags on 0x0315 are widened bools, not bytes, and they sit OUTSIDE the struct writer — ACE's ContractTracker.Write has them commented out precisely because the event appends them itself. Reading them as bytes decodes the delete flag from the wrong four bytes and silently drops contracts. The stage is not a dense enum. Retail encodes N completed steps as ProgressCounter + N, so a switch over the four named values sees stage 9 as unknown and shows nothing. Progress/HasProgressCounter do that arithmetic once here rather than leaving every caller to remember it. The countdown anchor is not on the wire. FillProgressString @0x00498DE0 counts down from CContractTracker::_time_of_server_update, which the server never sends — so arrival has to be stamped at parse time or the repeat timer has nothing to tick against. An empty table is a valid answer rather than a decode failure: it is how the server says "you have no contracts", and confusing the two would leave stale quests on screen permanently. A truncated one is rejected outright instead of decoding to its prefix, which would drop quests just as silently. Campaign QT slice 1 of 6. Co-Authored-By: Claude Opus 5 --- .../Messages/ContractTrackerMessages.cs | 196 ++++++++++++++++++ .../Messages/ContractTrackerMessagesTests.cs | 185 +++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs diff --git a/src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs b/src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs new file mode 100644 index 00000000..b72eae42 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs @@ -0,0 +1,196 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; + +namespace AcDream.Core.Net.Messages; + +/// +/// How far along a contract is. +/// +/// +/// Not a dense enum: retail encodes a progress COUNTER by adding the number of +/// completed steps to , so any value at or above 4 +/// is "in progress with (value - 4) done". +/// does that arithmetic rather than leaving callers to remember it. +/// +public enum ContractStage : uint +{ + Available = 1, + InProgress = 2, + DoneOrPendingRepeat = 3, + ProgressCounter = 4, +} + +/// +/// One contract's live state, as the server sees it. +/// +/// The dat Contract.Version this state was built against. +/// Key into the ContractTable dat. +/// See . +/// Seconds until the current cooldown ends. +/// Seconds until the repeat cooldown ends. +/// +/// When this state reached us. NOT on the wire — retail's CContractTracker +/// carries its own _time_of_server_update and +/// gmContractsUI::FillProgressString @0x00498DE0 counts down from it +/// (TimeWhenRepeats - (now - timeOfServerUpdate)). Anchoring at parse +/// time is what makes the countdown tick; recomputing from the server value +/// every frame would freeze it. +/// +public readonly record struct ContractTracker( + uint Version, + uint ContractId, + ContractStage Stage, + double TimeWhenDone, + double TimeWhenRepeats, + DateTime ReceivedAt) +{ + /// The wire size of one tracker struct. + internal const int WireSize = 4 + 4 + 4 + 8 + 8; + + /// + /// Completed steps, when the stage carries a counter; 0 otherwise. + /// + public uint Progress => + (uint)Stage >= (uint)ContractStage.ProgressCounter + ? (uint)Stage - (uint)ContractStage.ProgressCounter + : 0u; + + /// Whether the stage encodes a progress counter at all. + public bool HasProgressCounter => + (uint)Stage >= (uint)ContractStage.ProgressCounter; +} + +/// +/// A single-contract update: 0x0315 SendClientContractTracker. +/// +/// Remove this contract from the tracker entirely. +/// Make this the contract the panel shows. +public readonly record struct ContractTrackerUpdate( + ContractTracker Tracker, + bool Delete, + bool SetAsDisplay); + +/// +/// Parsers for retail's two contract-tracker game events. +/// +/// +/// +/// Both opcodes have been NAMED in since the wire +/// catalog work without anything parsing them, so the bytes have been arriving +/// and being dropped. +/// +/// +/// Layout confirmed from ACE's writers (ContractTrackerExtensions.Write, +/// GameEventSendClientContractTracker, ContractManager.Write) and +/// cross-checked against the retail client's own +/// PackableHashTable<unsigned long, CContractTracker> +/// instantiations at 0x00497C10. +/// +/// +public static class ContractTrackerMessages +{ + /// + /// A table larger than this is a decode error rather than a big quest log: + /// retail pcaps top out around 3,208 bytes, i.e. well under a hundred + /// entries. + /// + private const int MaxTableEntries = 4096; + + /// + /// 0x0315 — one tracker plus the two flags ACE appends AFTER the + /// struct (they are deliberately not part of Write; see its own + /// commented-out lines). + /// + public static ContractTrackerUpdate? ParseUpdate( + ReadOnlySpan payload, DateTime receivedAt) + { + int pos = 0; + try + { + ContractTracker tracker = ReadTracker(payload, ref pos, receivedAt); + uint delete = ReadU32(payload, ref pos); + uint display = ReadU32(payload, ref pos); + return new ContractTrackerUpdate(tracker, delete != 0u, display != 0u); + } + catch (FormatException) + { + return null; + } + } + + /// + /// 0x0314 — the complete replacement table. No trailing flags on + /// this path. + /// + /// + /// The trackers by contract id, or null if the payload does not decode. + /// An EMPTY table is a valid, meaningful answer — it is how the server says + /// "you have no contracts" — so it must not be confused with a decode + /// failure. + /// + public static IReadOnlyDictionary? ParseTable( + ReadOnlySpan payload, DateTime receivedAt) + { + int pos = 0; + try + { + uint header = ReadU32(payload, ref pos); + ushort count = (ushort)(header & 0xFFFFu); + ushort buckets = (ushort)(header >> 16); + + // A zero-bucket table is valid only when it is empty — the early + // return in PackableHashTable::UnPack @0x006B1A86. + if (buckets == 0 && count != 0) + throw new FormatException("invalid contract tracker table"); + if (count > MaxTableEntries) + throw new FormatException("implausible contract tracker count"); + + var result = new Dictionary(count); + for (int i = 0; i < count; i++) + { + uint key = ReadU32(payload, ref pos); + result[key] = ReadTracker(payload, ref pos, receivedAt); + } + + return result; + } + catch (FormatException) + { + return null; + } + } + + private static ContractTracker ReadTracker( + ReadOnlySpan source, ref int pos, DateTime receivedAt) + { + uint version = ReadU32(source, ref pos); + uint contractId = ReadU32(source, ref pos); + uint stage = ReadU32(source, ref pos); + double whenDone = ReadDouble(source, ref pos); + double whenRepeats = ReadDouble(source, ref pos); + return new ContractTracker( + version, + contractId, + (ContractStage)stage, + whenDone, + whenRepeats, + receivedAt); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos, 4)); + pos += 4; + return value; + } + + private static double ReadDouble(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 8) throw new FormatException("truncated double"); + double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(pos, 8)); + pos += 8; + return value; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs new file mode 100644 index 00000000..d029999a --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign QT slice QT1: retail's two contract-tracker game events. +/// +public sealed class ContractTrackerMessagesTests +{ + private static readonly DateTime Arrival = new(2026, 8, 21, 13, 5, 9, DateTimeKind.Utc); + + /// Writes one tracker struct exactly as ACE's writer does. + private static byte[] Tracker( + uint version, uint contractId, uint stage, double whenDone, double whenRepeats) + { + var buffer = new byte[28]; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0), version); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4), contractId); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(8), stage); + BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(12), whenDone); + BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(20), whenRepeats); + return buffer; + } + + private static byte[] Concat(params byte[][] parts) + { + var result = new List(); + foreach (byte[] part in parts) result.AddRange(part); + return [.. result]; + } + + private static byte[] U32(uint value) + { + var buffer = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(buffer, value); + return buffer; + } + + /// The u16 count / u16 buckets header, packed into one dword. + private static byte[] HashHeader(ushort count, ushort buckets) + => U32((uint)count | ((uint)buckets << 16)); + + // ── 0x0315, the single update ─────────────────────────────────────── + + [Fact] + public void AnUpdateDecodesEveryFieldInOrder() + { + byte[] payload = Concat( + Tracker(3u, 0x1234u, 2u, 120.5, 86400.0), + U32(0u), // DeleteContract + U32(1u)); // SetAsDisplayContract + + ContractTrackerUpdate update = + ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value; + + Assert.Equal(3u, update.Tracker.Version); + Assert.Equal(0x1234u, update.Tracker.ContractId); + Assert.Equal(ContractStage.InProgress, update.Tracker.Stage); + Assert.Equal(120.5, update.Tracker.TimeWhenDone); + Assert.Equal(86400.0, update.Tracker.TimeWhenRepeats); + Assert.False(update.Delete); + Assert.True(update.SetAsDisplay); + } + + [Fact] + public void TheTwoFlagsAreWidenedBoolsNotBytes() + { + // ACE writes Convert.ToUInt32(bool) AFTER the struct — reading them as + // bytes would decode the delete flag out of the wrong four bytes and + // silently drop contracts. + byte[] payload = Concat(Tracker(1u, 7u, 1u, 0, 0), U32(1u), U32(0u)); + + ContractTrackerUpdate update = + ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value; + + Assert.True(update.Delete); + Assert.False(update.SetAsDisplay); + } + + [Fact] + public void ArrivalIsStampedBecauseItIsNotOnTheWire() + { + // FillProgressString counts down from _time_of_server_update, which the + // server never sends. Without the stamp there is no anchor and the + // repeat timer cannot tick. + ContractTrackerUpdate update = ContractTrackerMessages.ParseUpdate( + Concat(Tracker(1u, 7u, 3u, 0, 600.0), U32(0u), U32(0u)), Arrival)!.Value; + + Assert.Equal(Arrival, update.Tracker.ReceivedAt); + } + + [Fact] + public void ATruncatedUpdateIsRejectedRatherThanPartiallyDecoded() + { + // The struct alone, with the two flags missing. + Assert.Null(ContractTrackerMessages.ParseUpdate(Tracker(1u, 7u, 1u, 0, 0), Arrival)); + Assert.Null(ContractTrackerMessages.ParseUpdate([], Arrival)); + } + + // ── the progress counter ──────────────────────────────────────────── + + [Theory] + [InlineData(1u, 0u, false)] + [InlineData(2u, 0u, false)] + [InlineData(3u, 0u, false)] + [InlineData(4u, 0u, true)] // counter present, zero done + [InlineData(9u, 5u, true)] + public void TheStageCarriesTheProgressCountAboveFour( + uint stage, uint expectedProgress, bool expectedHasCounter) + { + // Retail encodes N completed steps as ProgressCounter + N rather than + // as a separate field, so a naive enum switch would see stage 9 as an + // unknown value and show nothing. + ContractTracker tracker = ContractTrackerMessages.ParseUpdate( + Concat(Tracker(1u, 7u, stage, 0, 0), U32(0u), U32(0u)), Arrival)!.Value.Tracker; + + Assert.Equal(expectedProgress, tracker.Progress); + Assert.Equal(expectedHasCounter, tracker.HasProgressCounter); + } + + // ── 0x0314, the full table ────────────────────────────────────────── + + [Fact] + public void TheTableDecodesEveryEntryKeyedByContractId() + { + byte[] payload = Concat( + HashHeader(count: 2, buckets: 8), + U32(0x1111u), Tracker(1u, 0x1111u, 1u, 0, 0), + U32(0x2222u), Tracker(1u, 0x2222u, 6u, 10.0, 20.0)); + + IReadOnlyDictionary table = + ContractTrackerMessages.ParseTable(payload, Arrival)!; + + Assert.Equal(2, table.Count); + Assert.Equal(ContractStage.Available, table[0x1111u].Stage); + Assert.Equal(2u, table[0x2222u].Progress); + Assert.Equal(Arrival, table[0x2222u].ReceivedAt); + } + + [Fact] + public void AnEmptyTableIsAValidAnswerNotAFailure() + { + // "You have no contracts" is a real thing the server says, and it is + // how the panel gets cleared. Returning null here would leave stale + // contracts on screen forever. + IReadOnlyDictionary? table = + ContractTrackerMessages.ParseTable(HashHeader(0, 0), Arrival); + + Assert.NotNull(table); + Assert.Empty(table!); + } + + [Fact] + public void ANonEmptyTableWithNoBucketsIsRejected() + { + // PackableHashTable::UnPack early-returns on a zero-bucket table, so a + // count without buckets is a corrupt frame, not an empty one. + Assert.Null(ContractTrackerMessages.ParseTable( + Concat(HashHeader(count: 1, buckets: 0), U32(1u), Tracker(1u, 1u, 1u, 0, 0)), + Arrival)); + } + + [Fact] + public void ATruncatedTableIsRejectedRatherThanReturningThePrefix() + { + // Claiming two entries and supplying one must not decode as one — a + // half-read table would silently drop the player's quests. + Assert.Null(ContractTrackerMessages.ParseTable( + Concat(HashHeader(count: 2, buckets: 8), U32(1u), Tracker(1u, 1u, 1u, 0, 0)), + Arrival)); + } + + [Fact] + public void AnImplausibleCountIsRejectedWithoutAllocatingForIt() + { + // Retail's largest observed table is ~3 KB. A count field of 60,000 + // against a short payload is a decode error; it must fail fast rather + // than try to read 60,000 entries. + Assert.Null(ContractTrackerMessages.ParseTable( + HashHeader(count: 60000, buckets: 256), Arrival)); + } +}