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