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