Three fixes from the first connected round. The location readout is authored EDITABLE (0x16), so it builds as a UiField — not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved it as text, got null, and threw every write away in silence: Record reached the model and reached the FILE, and never reached the screen. That is exactly what was reported, and it is a whole class of bug, so the sweep that found it is now a test over every element all three controllers bind. The handlers mutated the model and left redrawing to the next frame's Tick. Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update() instead — at the moment of the click. The deferred version happened to work in the client and made the behaviour untestable and a frame late; the notes-page tests I had not written until now fail against it. Abandon is wired. "Retail's abandon path is a contract-registry command we have not ported" was wrong — it is game action 0x0316 with a single contract id, and ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed locally, so a refusal leaves the quest visibly intact rather than vanishing it optimistically and having it reappear on the next full table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
8.2 KiB
C#
207 lines
8.2 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Collections.Generic;
|
|
using AcDream.Core.Net.Messages;
|
|
|
|
namespace AcDream.Core.Net.Tests.Messages;
|
|
|
|
/// <summary>
|
|
/// Campaign QT slice QT1: retail's two contract-tracker game events.
|
|
/// </summary>
|
|
public sealed class ContractTrackerMessagesTests
|
|
{
|
|
private static readonly DateTime Arrival = new(2026, 8, 21, 13, 5, 9, DateTimeKind.Utc);
|
|
|
|
/// <summary>Writes one tracker struct exactly as ACE's writer does.</summary>
|
|
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<byte>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>The u16 count / u16 buckets header, packed into one dword.</summary>
|
|
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<uint, ContractTracker> 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<uint, ContractTracker>? 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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign QJ: the outbound abandon action.
|
|
/// </summary>
|
|
public sealed class AbandonContractRequestTests
|
|
{
|
|
[Fact]
|
|
public void TheAbandonPayloadIsTheContractIdAlone()
|
|
{
|
|
// ACE's GameActionAbandonContract reads exactly one uint32 and nothing
|
|
// else; a longer payload desyncs the whole game-action stream.
|
|
byte[] frame = ClientCommandRequests.BuildAbandonContract(
|
|
sequence: 7u, contractId: 0x1234u);
|
|
|
|
// 0xF7B1 envelope, sequence, opcode, then the payload.
|
|
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(0)));
|
|
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(4)));
|
|
Assert.Equal(0x0316u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(8)));
|
|
Assert.Equal(0x1234u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(12)));
|
|
Assert.Equal(16, frame.Length);
|
|
}
|
|
}
|