merge(net): the wire-stack audit, and one reconciled #255
Brings `github/overnight/wire-audit` (`41f74fcd`) forward onto the V11 tree. Like the enum branch it was cut at `b70b9832`, and like the enum branch its subject is disjoint from the deletion: the audit lives in `AcDream.Core.Net` and its tests, V11 emptied `AcDream.App`. One conflict, in `docs/ISSUES.md`, resolved below. What it carries: three real parser fixes — ranged speech was carrying a range float the parser ate, a chat type that is never sent was silently dropping every transient string on it, and `xpSpent` is a dword on the wire where we were writing eight bytes. Plus the transport flag word pinned against ACE across all twenty-three bits, golden fixtures generated from ACE's own writer instead of hand-typed hex, and the audit document covering all three hundred forty-nine opcodes. **The conflict, and how it was resolved.** Both this branch and V11's closeout reopened #255 — the RetailDatLoader concurrency tests that measure the thread pool rather than the loader — on the same day, from different trees, without knowing about each other. Neither reopening is a duplicate of the other: the V11 gate saw 2 failures in 5 complete-solution Release runs on the post-deletion tree, the audit session saw 2 in 4 on the pre-deletion tree, and both saw 124/124 in isolation every time. They independently reached the same conclusion, that `TaskCreationOptions.LongRunning` is a hint rather than a guarantee, and independently proposed the same fix, a rendezvous inside the read stub. So the two notes are merged into one issue with both evidence sets kept as labelled subsections rather than one overwriting the other. Four failures across nine runs on two trees is a materially stronger case than either half, and the agreement between two blind observations is the part worth preserving. No assertion was weakened and no retry was added; the fix itself remains open. Verified on the merge result: Release build 0 errors, and `AcDream.Core.Net.Tests` at 659 passed / 0 skipped, up exactly the 59 the branch claimed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
22ae7944b6
14 changed files with 2079 additions and 39 deletions
|
|
@ -190,7 +190,10 @@ tree is recoverable from git history at `844cf092^`.
|
|||
|
||||
## #255 — Two RetailDatLoader concurrency tests measured the thread pool, not the loader
|
||||
|
||||
**Status:** REOPENED 2026-07-29 — the `LongRunning` fix is a hint, not a guarantee
|
||||
**Status:** REOPENED 2026-07-29 — the `LongRunning` fix is a hint, not a
|
||||
guarantee; `AnimationCache_Coalesces…` still fails under full-suite load on
|
||||
Windows. Reopened independently by two sessions on the same day; both evidence
|
||||
sets are kept at the end of this issue.
|
||||
**Severity:** LOW (test infrastructure only; no production defect)
|
||||
**Filed:** 2026-07-28
|
||||
**Component:** tests / xUnit parallelism, content loaders
|
||||
|
|
@ -225,7 +228,14 @@ afterwards.
|
|||
|
||||
**Files:** `tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs`.
|
||||
|
||||
### Reopened 2026-07-29 by the V11 gate
|
||||
### Reopened 2026-07-29 — two independent sessions, one diagnosis
|
||||
|
||||
Two overnight sessions hit this on the same day, on different trees, and reached
|
||||
the same conclusion without knowing about each other. Both evidence sets are
|
||||
recorded below because they probe different pressure regimes and agree, which is
|
||||
what makes the diagnosis solid rather than anecdotal.
|
||||
|
||||
#### Evidence set A — the V11 gate (post-deletion tree)
|
||||
|
||||
`AnimationCache_CoalescesSameDidAndAllowsUnrelatedReadsInParallel` failed again
|
||||
on Windows, at `RetailDatLoaderTests.cs:311` (`Assert.True(portal.MaxConcurrentReads >= 2)`),
|
||||
|
|
@ -251,6 +261,34 @@ coalescing, which is what it is for, and makes it independent of how many
|
|||
threads the host happens to offer. **Do not weaken the assertion or add a
|
||||
retry** — the assertion is correct; the harness around it is what is wrong.
|
||||
|
||||
#### Evidence set B — the wire-stack audit session (pre-deletion tree)
|
||||
|
||||
Observed independently on `b70b9832`, a tree that already contains the
|
||||
`LongRunning` fix (`c7861020`) in its base lineage: **2 failures in 4 full-suite
|
||||
runs**, with the project passing **124/124 every time it is run alone**. Both
|
||||
failures were `--no-build` runs, which start the projects faster and so apply
|
||||
more parallel pressure — consistent with the original diagnosis rather than a
|
||||
new cause.
|
||||
|
||||
`PhysicsScriptLoader_AllowsConcurrentFirstReads` was not observed failing in
|
||||
that session, but it shares the helper and should be treated as the same risk.
|
||||
|
||||
That session changed nothing under `AcDream.Content`; its diff was confined to
|
||||
`src/AcDream.Core.Net`, that project's tests, and `docs/`. So neither reopening
|
||||
is explained by the tree it was observed on — set A's V11 diff touches
|
||||
`AcDream.Content` only in pixel-format enum documentation, and set B's touches
|
||||
it not at all.
|
||||
|
||||
#### What the two sets agree on
|
||||
|
||||
Four full-suite failures across nine runs on two different trees, against
|
||||
124/124 in isolation every time. The fix direction is the same from both
|
||||
sides: synchronise the two callbacks against **each other** — a `Barrier` or
|
||||
two-party `SemaphoreSlim` rendezvous *inside* the read stub — so neither read
|
||||
can complete until both have entered. Then `MaxConcurrentReads == 2` is a
|
||||
property of the loader rather than of the scheduler, and the test cannot flake
|
||||
no matter how loaded the box is.
|
||||
|
||||
---
|
||||
|
||||
## #254 — Logout confirmation wait overran its timeout on a starved thread pool
|
||||
|
|
|
|||
1251
docs/research/2026-07-29-wire-stack-audit.md
Normal file
1251
docs/research/2026-07-29-wire-stack-audit.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -107,8 +107,14 @@ public static class GameEventWiring
|
|||
});
|
||||
registrar.Register(GameEventType.CommunicationTransientString, e =>
|
||||
{
|
||||
var p = GameEvents.ParseTransient(e.Payload.Span);
|
||||
if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType);
|
||||
// 0x02EB carries no chat type on the wire (see ParseTransient).
|
||||
// 0 is ACE's ChatMessageType.Broadcast, which its own
|
||||
// LogTextTypeEnumMapper comment names "Default" — the right
|
||||
// stand-in for a message the server sends untyped. The exact
|
||||
// retail rendering style for transient strings belongs to the
|
||||
// chat colour/text work, not to this parser.
|
||||
var s = GameEvents.ParseTransient(e.Payload.Span);
|
||||
if (s is not null) chat.OnSystemMessage(s, chatType: 0u);
|
||||
});
|
||||
registrar.Register(GameEventType.PopupString, e =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ public static class CharacterActions
|
|||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
|
||||
public const uint RaiseAttributeOpcode = 0x0045u; // u32 attr, u64 xpSpent
|
||||
public const uint RaiseVitalOpcode = 0x0044u; // u32 vital, u64 xpSpent
|
||||
public const uint RaiseSkillOpcode = 0x0046u; // u32 skillId, u64 xpSpent
|
||||
public const uint RaiseAttributeOpcode = 0x0045u; // u32 attr, u32 xpSpent
|
||||
public const uint RaiseVitalOpcode = 0x0044u; // u32 vital, u32 xpSpent
|
||||
public const uint RaiseSkillOpcode = 0x0046u; // u32 skillId, u32 xpSpent
|
||||
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
|
||||
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
|
||||
|
||||
|
|
@ -74,14 +74,31 @@ public static class CharacterActions
|
|||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Envelope + sequence + sub-opcode + id + <b>32-bit</b> xpSpent.
|
||||
///
|
||||
/// <para>The xpSpent field is a dword on the wire, not a qword. ACE's
|
||||
/// <c>GameAction/Actions/GameActionRaiseAttribute.cs</c> (and its Vital and
|
||||
/// Skill siblings) read <c>message.Payload.ReadUInt32()</c>, and
|
||||
/// holtburger's <c>RaiseAttributeData</c> declares <c>xp_spent: u32</c>
|
||||
/// and advances the offset by four. We were writing eight, making the
|
||||
/// message 24 bytes where the server expects 20 and leaving four bytes of
|
||||
/// tail the server never reads.</para>
|
||||
///
|
||||
/// <para>The parameter stays <c>ulong</c> because the cost originates from
|
||||
/// 64-bit server XP tables several layers up; narrowing that chain end to
|
||||
/// end is a separate change. No value is lost here: a cost that does not
|
||||
/// fit in a dword was never expressible on this wire in the first
|
||||
/// place.</para>
|
||||
/// </summary>
|
||||
private static byte[] BuildAttrOrVital(uint seq, uint sub, uint id, ulong xp)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
byte[] body = new byte[20];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), id);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(16), xp);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), (uint)xp);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,19 +67,27 @@ public static class GameEvents
|
|||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x02EB CommunicationTransientString payload.</summary>
|
||||
public readonly record struct TransientMessage(string Message, uint ChatType);
|
||||
|
||||
public static TransientMessage? ParseTransient(ReadOnlySpan<byte> payload)
|
||||
/// <summary>
|
||||
/// 0x02EB CommunicationTransientString payload: a bare string, and
|
||||
/// nothing else.
|
||||
///
|
||||
/// <para>Three oracles agree there is no chat type on this wire. ACE's
|
||||
/// <c>GameEvent/Events/GameEventCommunicationTransientString.cs</c> writes
|
||||
/// exactly one <c>WriteString16L(message)</c>. Retail's handler
|
||||
/// <c>ClientCommunicationSystem::Handle_Communication__TransientString</c>
|
||||
/// (0x0057d460) takes a single
|
||||
/// <c>AC1Legacy::PStringBase<char> const*</c> argument. holtburger
|
||||
/// carries no type field for it either.</para>
|
||||
///
|
||||
/// <para>This parser previously demanded a trailing <c>u32 chatType</c>.
|
||||
/// Because the string is padded to a 4-byte boundary, the remaining length
|
||||
/// was always 0, so the guard tripped and every single transient string
|
||||
/// was dropped.</para>
|
||||
/// </summary>
|
||||
public static string? ParseTransient(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try
|
||||
{
|
||||
string message = ReadString16L(payload, ref pos);
|
||||
if (payload.Length - pos < 4) return null;
|
||||
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
|
||||
return new TransientMessage(message, chatType);
|
||||
}
|
||||
try { return ReadString16L(payload, ref pos); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,26 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// GameMessages dispatched the same way as CreateObject / UpdateMotion.
|
||||
///
|
||||
/// <para>
|
||||
/// The two opcodes do NOT share a payload: ranged speech carries an extra
|
||||
/// <c>f32 range</c> between the sender guid and the chat type. Both oracles
|
||||
/// agree — ACE's
|
||||
/// <c>GameMessages/Messages/GameMessageHearRangedSpeech.cs</c> writes
|
||||
/// <c>senderID, range, chatMessageType</c> where
|
||||
/// <c>GameMessageHearSpeech.cs</c> writes only <c>senderID,
|
||||
/// chatMessageType</c>, and holtburger's
|
||||
/// <c>crates/holtburger-protocol/src/messages/chat/types.rs</c> declares
|
||||
/// <c>HearRangedSpeechData</c> with a <c>range: f32</c> that
|
||||
/// <c>HearSpeechData</c> lacks.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout:
|
||||
/// <code>
|
||||
/// u32 opcode // 0x02BB or 0x02BC
|
||||
/// string16L text
|
||||
/// string16L senderName
|
||||
/// u32 senderGuid
|
||||
/// f32 range // 0x02BC ONLY
|
||||
/// u32 chatType
|
||||
/// </code>
|
||||
/// </para>
|
||||
|
|
@ -39,12 +53,17 @@ public static class HearSpeech
|
|||
public const uint LocalOpcode = 0x02BBu;
|
||||
public const uint RangedOpcode = 0x02BCu;
|
||||
|
||||
/// <param name="Range">
|
||||
/// Audible radius carried only by <c>0x02BC HearRangedSpeech</c>. Local
|
||||
/// speech (<c>0x02BB</c>) has no such field on the wire and reports 0.
|
||||
/// </param>
|
||||
public readonly record struct Parsed(
|
||||
string Text,
|
||||
string SenderName,
|
||||
uint SenderGuid,
|
||||
uint ChatType,
|
||||
bool IsRanged);
|
||||
bool IsRanged,
|
||||
float Range);
|
||||
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
|
|
@ -61,10 +80,22 @@ public static class HearSpeech
|
|||
{
|
||||
string text = ReadString16L(body, ref pos);
|
||||
string sender = ReadString16L(body, ref pos);
|
||||
if (body.Length - pos < 8) return null;
|
||||
|
||||
// 0x02BB: guid + chatType. 0x02BC: guid + range + chatType.
|
||||
int tailSize = isRanged ? 12 : 8;
|
||||
if (body.Length - pos < tailSize) return null;
|
||||
|
||||
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4;
|
||||
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4;
|
||||
return new Parsed(text, sender, senderGuid, chatType, isRanged);
|
||||
|
||||
float range = 0f;
|
||||
if (isRanged)
|
||||
{
|
||||
range = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4;
|
||||
return new Parsed(text, sender, senderGuid, chatType, isRanged, range);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
|
|
|||
130
tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs
Normal file
130
tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only faithful mirror of ACE's server-side wire writer, so golden-byte
|
||||
/// conformance tests generate their fixtures the same way the authoritative
|
||||
/// server generates them. Hand-rolled hex would only prove that our parser
|
||||
/// agrees with whoever typed the hex; generating from the oracle's own
|
||||
/// algorithm proves it agrees with the server.
|
||||
///
|
||||
/// <para>
|
||||
/// Every primitive below is a line-for-line port of
|
||||
/// <c>ACE/Source/ACE.Server/Network/Extensions.cs</c> (read 2026-07-29 from
|
||||
/// the ACE checkout). Cited line numbers are that file's:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CalculatePadMultiple</c> — Extensions.cs:10</item>
|
||||
/// <item><c>WriteString16L</c> — Extensions.cs:12-21</item>
|
||||
/// <item><c>WritePackedDword</c> — Extensions.cs:23-34</item>
|
||||
/// <item><c>Pad</c> — Extensions.cs:51</item>
|
||||
/// <item><c>Align</c> — Extensions.cs:55-58</item>
|
||||
/// <item><c>WriteGuid</c> — Extensions.cs:121 (writes <c>guid.Full</c>, a u32)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// ACE's underlying <see cref="System.IO.BinaryWriter"/> is little-endian for
|
||||
/// the fixed-width overloads, which is what <c>Write(uint)</c>,
|
||||
/// <c>Write(ushort)</c> and <c>Write(float)</c> reproduce here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class AceWireWriter
|
||||
{
|
||||
private readonly List<byte> _buffer = new();
|
||||
|
||||
public int Length => _buffer.Count;
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:10</c>.</summary>
|
||||
private static uint CalculatePadMultiple(uint length, uint multiple)
|
||||
=> multiple * ((length + multiple - 1u) / multiple) - length;
|
||||
|
||||
/// <summary>BinaryWriter.Write(uint) — little-endian.</summary>
|
||||
public AceWireWriter Write(uint value)
|
||||
{
|
||||
_buffer.Add((byte)(value & 0xFF));
|
||||
_buffer.Add((byte)((value >> 8) & 0xFF));
|
||||
_buffer.Add((byte)((value >> 16) & 0xFF));
|
||||
_buffer.Add((byte)((value >> 24) & 0xFF));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>BinaryWriter.Write(ushort) — little-endian.</summary>
|
||||
public AceWireWriter Write(ushort value)
|
||||
{
|
||||
_buffer.Add((byte)(value & 0xFF));
|
||||
_buffer.Add((byte)((value >> 8) & 0xFF));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>BinaryWriter.Write(float) — little-endian IEEE-754.</summary>
|
||||
public AceWireWriter Write(float value)
|
||||
=> Write((uint)BitConverter.SingleToInt32Bits(value));
|
||||
|
||||
/// <summary>
|
||||
/// ACE <c>Extensions.cs:121</c> — <c>WriteGuid</c> writes the full 32-bit
|
||||
/// guid with no packing.
|
||||
/// </summary>
|
||||
public AceWireWriter WriteGuid(uint guid) => Write(guid);
|
||||
|
||||
/// <summary>
|
||||
/// ACE <c>Extensions.cs:12-21</c>. Writes a u16 length, the CP1252 bytes,
|
||||
/// then pads so that (2 + length) is a multiple of 4 — the comment in ACE
|
||||
/// reads "client expects string length to be a multiple of 4 including the
|
||||
/// 2 bytes for length".
|
||||
/// </summary>
|
||||
public AceWireWriter WriteString16L(string? data)
|
||||
{
|
||||
data ??= "";
|
||||
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(data);
|
||||
Write((ushort)data.Length);
|
||||
_buffer.AddRange(bytes);
|
||||
return Pad(CalculatePadMultiple(sizeof(ushort) + (uint)data.Length, 4u));
|
||||
}
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:23-34</c>.</summary>
|
||||
public AceWireWriter WritePackedDword(uint value)
|
||||
{
|
||||
if (value <= 32767)
|
||||
return Write((ushort)value);
|
||||
|
||||
uint packed = (value << 16) | ((value >> 16) | 0x8000);
|
||||
return Write(packed);
|
||||
}
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:51</c>.</summary>
|
||||
public AceWireWriter Pad(uint pad)
|
||||
{
|
||||
for (uint i = 0; i < pad; i++)
|
||||
_buffer.Add(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:55-58</c> — pad the stream to a 4-byte boundary.</summary>
|
||||
public AceWireWriter Align() => Pad(CalculatePadMultiple((uint)_buffer.Count, 4u));
|
||||
|
||||
public byte[] ToArray() => _buffer.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Start a top-level GameMessage body: the 4-byte opcode, exactly as
|
||||
/// <c>ACE.Server.Network.GameMessages.GameMessage</c>'s constructor writes
|
||||
/// it before the per-message payload.
|
||||
/// </summary>
|
||||
public static AceWireWriter GameMessage(uint opcode)
|
||||
=> new AceWireWriter().Write(opcode);
|
||||
|
||||
/// <summary>
|
||||
/// Start a GameEvent body (top-level opcode <c>0xF7B0</c>), mirroring
|
||||
/// <c>ACE/Source/ACE.Server/Network/GameEvent/GameEventMessage.cs:21-25</c>:
|
||||
/// <c>WriteGuid(guid)</c>, <c>Write(session.GameEventSequence++)</c>,
|
||||
/// <c>Write((uint)EventType)</c>.
|
||||
/// </summary>
|
||||
public static AceWireWriter GameEvent(uint guid, uint eventSequence, uint eventType)
|
||||
=> new AceWireWriter()
|
||||
.Write(0xF7B0u)
|
||||
.WriteGuid(guid)
|
||||
.Write(eventSequence)
|
||||
.Write(eventType);
|
||||
}
|
||||
|
|
@ -7,17 +7,40 @@ namespace AcDream.Core.Net.Tests.Messages;
|
|||
|
||||
public sealed class CharacterActionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// xpSpent is a dword, not a qword: ACE's GameActionRaiseAttribute reads
|
||||
/// <c>ReadUInt32()</c> and holtburger's RaiseAttributeData declares
|
||||
/// <c>xp_spent: u32</c>. This test previously asserted the 24-byte,
|
||||
/// 64-bit shape — note that its sibling BuildTrainSkill_U32CreditsNotU64
|
||||
/// had already been corrected to 20 bytes for the same class of mistake.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildRaiseAttribute_HasOpcode0x0045AndXp64()
|
||||
public void BuildRaiseAttribute_HasOpcode0x0045AndU32Xp()
|
||||
{
|
||||
byte[] body = CharacterActions.BuildRaiseAttribute(seq: 1, attrId: 5, xpSpent: 12345678);
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(CharacterActions.RaiseAttributeOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(5u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(12345678u,
|
||||
BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(16)));
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u)]
|
||||
[InlineData(1u)]
|
||||
[InlineData(12345678u)]
|
||||
[InlineData(uint.MaxValue)]
|
||||
public void RaiseVitalAndSkill_AlsoWriteU32Xp(uint xp)
|
||||
{
|
||||
byte[] vital = CharacterActions.BuildRaiseVital(seq: 1, vitalId: 1, xpSpent: xp);
|
||||
byte[] skill = CharacterActions.BuildRaiseSkill(seq: 1, skillId: 8, xpSpent: xp);
|
||||
|
||||
Assert.Equal(20, vital.Length);
|
||||
Assert.Equal(20, skill.Length);
|
||||
Assert.Equal(xp, BinaryPrimitives.ReadUInt32LittleEndian(vital.AsSpan(16)));
|
||||
Assert.Equal(xp, BinaryPrimitives.ReadUInt32LittleEndian(skill.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -102,19 +102,27 @@ public sealed class ChatTests
|
|||
[Fact]
|
||||
public void HearSpeech_TryParse_RangedFlag()
|
||||
{
|
||||
// 0x02BC's tail is 12 bytes, not 8: ACE's GameMessageHearRangedSpeech
|
||||
// writes senderID, range (f32), chatMessageType, where the local
|
||||
// 0x02BB message writes only senderID and chatMessageType. This test
|
||||
// previously built the local tail and so passed against a parser that
|
||||
// read range's float bits as the chat type.
|
||||
byte[] msg = PackString16L("X");
|
||||
byte[] sender = PackString16L("Y");
|
||||
byte[] inbound = new byte[4 + msg.Length + sender.Length + 8];
|
||||
byte[] inbound = new byte[4 + msg.Length + sender.Length + 12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.RangedOpcode);
|
||||
int pos = 4;
|
||||
Array.Copy(msg, 0, inbound, pos, msg.Length); pos += msg.Length;
|
||||
Array.Copy(sender, 0, inbound, pos, sender.Length); pos += sender.Length;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0); pos += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0); pos += 4;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(inbound.AsSpan(pos), 60f); pos += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0x0Bu); pos += 4;
|
||||
|
||||
var parsed = HearSpeech.TryParse(inbound);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.True(parsed!.Value.IsRanged);
|
||||
Assert.Equal(60f, parsed.Value.Range);
|
||||
Assert.Equal(0x0Bu, parsed.Value.ChatType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -376,17 +376,31 @@ public sealed class GameEventDispatcherTests
|
|||
[Fact]
|
||||
public void ParseTransient_RoundTrip()
|
||||
{
|
||||
byte[] msg = MakeString16L("Your spell fizzled!");
|
||||
byte[] chatType = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(chatType, 5u);
|
||||
byte[] payload = new byte[msg.Length + 4];
|
||||
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
|
||||
Buffer.BlockCopy(chatType, 0, payload, msg.Length, 4);
|
||||
// 0x02EB is a bare string. ACE's
|
||||
// GameEventCommunicationTransientString writes one WriteString16L and
|
||||
// stops; retail's Handle_Communication__TransientString takes a single
|
||||
// string argument. This test used to append a phantom trailing u32
|
||||
// chat type, which is what let the parser's wrong guard look correct.
|
||||
byte[] payload = MakeString16L("Your spell fizzled!");
|
||||
|
||||
var parsed = GameEvents.ParseTransient(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal("Your spell fizzled!", parsed!.Value.Message);
|
||||
Assert.Equal(5u, parsed.Value.ChatType);
|
||||
string? parsed = GameEvents.ParseTransient(payload);
|
||||
|
||||
Assert.Equal("Your spell fizzled!", parsed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression pin: the padded string consumes the whole payload, so a
|
||||
/// parser demanding four more bytes returned null for every real
|
||||
/// transient string the server has ever sent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseTransient_ExactAcePayload_IsNotDropped()
|
||||
{
|
||||
foreach (string text in new[] { "", "a", "bb", "ccc", "dddd", "Your spell fizzled!" })
|
||||
{
|
||||
byte[] payload = MakeString16L(text);
|
||||
Assert.Equal(text, GameEvents.ParseTransient(payload));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
181
tests/AcDream.Core.Net.Tests/Messages/HearSpeechGoldenTests.cs
Normal file
181
tests/AcDream.Core.Net.Tests/Messages/HearSpeechGoldenTests.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
using System;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte conformance for the local/ranged speech GameMessages,
|
||||
/// <c>HearSpeech (0x02BB)</c> and <c>HearRangedSpeech (0x02BC)</c>.
|
||||
///
|
||||
/// <para><b>Oracle derivation.</b> Bytes are generated by
|
||||
/// <see cref="AceWireWriter"/>, a faithful mirror of ACE's
|
||||
/// <c>Extensions.cs</c> writers, driven in the exact order ACE writes them.
|
||||
/// </para>
|
||||
///
|
||||
/// <para><c>GameMessages/Messages/GameMessageHearSpeech.cs</c>:</para>
|
||||
/// <code>
|
||||
/// Writer.WriteString16L(messageText);
|
||||
/// Writer.WriteString16L(senderName);
|
||||
/// Writer.Write(senderID); // u32
|
||||
/// Writer.Write((uint)chatMessageType); // u32
|
||||
/// </code>
|
||||
///
|
||||
/// <para><c>GameMessages/Messages/GameMessageHearRangedSpeech.cs</c> — note
|
||||
/// the extra <c>range</c> float, which is the whole reason these two opcodes
|
||||
/// cannot share a decode path:</para>
|
||||
/// <code>
|
||||
/// Writer.WriteString16L(messageText);
|
||||
/// Writer.WriteString16L(senderName);
|
||||
/// Writer.Write(senderID); // u32
|
||||
/// Writer.Write(range); // f32 <-- 0x02BC ONLY
|
||||
/// Writer.Write((uint)chatMessageType); // u32
|
||||
/// </code>
|
||||
///
|
||||
/// <para>Cross-checked against holtburger
|
||||
/// (<c>crates/holtburger-protocol/src/messages/chat/types.rs</c>), whose
|
||||
/// <c>HearRangedSpeechData</c> reads <c>sender</c>, <c>range: f32</c>,
|
||||
/// <c>chat_type</c> while <c>HearSpeechData</c> reads only <c>sender</c>,
|
||||
/// <c>chat_type</c>.</para>
|
||||
/// </summary>
|
||||
public class HearSpeechGoldenTests
|
||||
{
|
||||
// ACE ChatMessageType values used below: 0x0B Speech, 0x10 Tell.
|
||||
private const uint Speech = 0x0Bu;
|
||||
private const uint Tell = 0x10u;
|
||||
|
||||
private static byte[] LocalGolden(string text, string sender, uint guid, uint chatType)
|
||||
=> AceWireWriter.GameMessage(HearSpeech.LocalOpcode)
|
||||
.WriteString16L(text)
|
||||
.WriteString16L(sender)
|
||||
.Write(guid)
|
||||
.Write(chatType)
|
||||
.ToArray();
|
||||
|
||||
private static byte[] RangedGolden(
|
||||
string text, string sender, uint guid, float range, uint chatType)
|
||||
=> AceWireWriter.GameMessage(HearSpeech.RangedOpcode)
|
||||
.WriteString16L(text)
|
||||
.WriteString16L(sender)
|
||||
.Write(guid)
|
||||
.Write(range)
|
||||
.Write(chatType)
|
||||
.ToArray();
|
||||
|
||||
// ---- 0x02BB HearSpeech ---------------------------------------------------
|
||||
|
||||
public static TheoryData<string, string, uint, uint> LocalCases() => new()
|
||||
{
|
||||
{ "Hello, Dereth!", "Barris", 0x50000001u, Speech },
|
||||
// Empty strings still occupy 4 bytes each (2 length + 2 pad).
|
||||
{ "", "", 0u, 0u },
|
||||
// Lengths 1..3 exercise every residue of the 4-byte padding rule.
|
||||
{ "a", "bb", 0x7C95B01Au, Tell },
|
||||
{ "ccc", "dddd", 0x800114C0u, Speech },
|
||||
// CP1252 round-trip: retail names carry accented characters.
|
||||
{ "Café time", "Seán", 0xA9B40001u, Speech },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LocalCases))]
|
||||
public void Local_AceGoldenBytes_DecodesEveryFieldExactly(
|
||||
string text, string sender, uint guid, uint chatType)
|
||||
{
|
||||
byte[] body = LocalGolden(text, sender, guid, chatType);
|
||||
|
||||
HearSpeech.Parsed? parsed = HearSpeech.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(text, parsed!.Value.Text);
|
||||
Assert.Equal(sender, parsed.Value.SenderName);
|
||||
Assert.Equal(guid, parsed.Value.SenderGuid);
|
||||
Assert.Equal(chatType, parsed.Value.ChatType);
|
||||
Assert.False(parsed.Value.IsRanged);
|
||||
// Local speech has no range field on the wire.
|
||||
Assert.Equal(0f, parsed.Value.Range);
|
||||
}
|
||||
|
||||
// ---- 0x02BC HearRangedSpeech --------------------------------------------
|
||||
|
||||
public static TheoryData<string, string, uint, float, uint> RangedCases() => new()
|
||||
{
|
||||
{ "HELP!", "Barris", 0x50000001u, 60f, Speech },
|
||||
{ "", "", 0u, 0f, 0u },
|
||||
{ "a", "bb", 0x7C95B01Au, 12.5f, Tell },
|
||||
{ "ccc", "dddd", 0x800114C0u, 100f, Speech },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RangedCases))]
|
||||
public void Ranged_AceGoldenBytes_DecodesRangeAndChatTypeSeparately(
|
||||
string text, string sender, uint guid, float range, uint chatType)
|
||||
{
|
||||
byte[] body = RangedGolden(text, sender, guid, range, chatType);
|
||||
|
||||
HearSpeech.Parsed? parsed = HearSpeech.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(text, parsed!.Value.Text);
|
||||
Assert.Equal(sender, parsed.Value.SenderName);
|
||||
Assert.Equal(guid, parsed.Value.SenderGuid);
|
||||
Assert.Equal(range, parsed.Value.Range);
|
||||
Assert.Equal(chatType, parsed.Value.ChatType);
|
||||
Assert.True(parsed.Value.IsRanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression pin for the decode bug this file was written to catch: a
|
||||
/// shared 0x02BB layout made the ranged parser read <c>range</c>'s float
|
||||
/// bits as the chat type. With range = 60.0f those bits are 0x42700000,
|
||||
/// so the symptom was a chat type in the billions rather than 0x0B.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ranged_ChatType_IsNotTheRangeFloatBits()
|
||||
{
|
||||
const float range = 60f;
|
||||
byte[] body = RangedGolden("HELP!", "Barris", 0x50000001u, range, Speech);
|
||||
|
||||
HearSpeech.Parsed? parsed = HearSpeech.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
uint rangeBits = (uint)BitConverter.SingleToInt32Bits(range);
|
||||
Assert.Equal(0x42700000u, rangeBits);
|
||||
Assert.NotEqual(rangeBits, parsed!.Value.ChatType);
|
||||
Assert.Equal(Speech, parsed.Value.ChatType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A ranged body that is four bytes short is a local-speech-sized tail.
|
||||
/// It must be rejected rather than silently decoded with the chat type
|
||||
/// taken from whatever follows.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ranged_MissingRangeField_ReturnsNull()
|
||||
{
|
||||
byte[] truncated = AceWireWriter.GameMessage(HearSpeech.RangedOpcode)
|
||||
.WriteString16L("HELP!")
|
||||
.WriteString16L("Barris")
|
||||
.Write(0x50000001u)
|
||||
.Write(Speech)
|
||||
.ToArray();
|
||||
|
||||
Assert.Null(HearSpeech.TryParse(truncated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongOpcode_ReturnsNull()
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(0x02BDu)
|
||||
.WriteString16L("x").WriteString16L("y").Write(1u).Write(1u)
|
||||
.ToArray();
|
||||
|
||||
Assert.Null(HearSpeech.TryParse(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Local_TruncatedTail_ReturnsNull()
|
||||
{
|
||||
byte[] body = LocalGolden("Hello", "Barris", 1u, Speech);
|
||||
Assert.Null(HearSpeech.TryParse(body.AsSpan(0, body.Length - 1)));
|
||||
}
|
||||
}
|
||||
142
tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs
Normal file
142
tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte conformance for the two inbound script-playback GameMessages:
|
||||
/// <c>PlayScriptId (0xF754)</c> and <c>PlayEffect (0xF755)</c>.
|
||||
///
|
||||
/// <para><b>Oracle derivation.</b> Bytes are generated by
|
||||
/// <see cref="AceWireWriter"/> (a faithful mirror of ACE's
|
||||
/// <c>Extensions.cs</c> writers).</para>
|
||||
///
|
||||
/// <para>For <c>0xF755</c>, ACE's
|
||||
/// <c>GameMessages/Messages/GameMessageScript.cs</c> writes:</para>
|
||||
/// <code>
|
||||
/// Writer.WriteGuid(guid); // u32
|
||||
/// Writer.Write((uint)scriptId); // u32
|
||||
/// Writer.Write(speed); // f32, default 1.0f
|
||||
/// </code>
|
||||
/// <para>declaring a 16-byte message (4 opcode + 12 payload), which matches
|
||||
/// <see cref="PlayPhysicsScriptType.WireSize"/>.</para>
|
||||
///
|
||||
/// <para>For <c>0xF754</c>, ACE names the opcode <c>PlayScriptId</c>
|
||||
/// (<c>GameMessageOpcode.cs:63</c>) and the retail handler is
|
||||
/// <c>SmartBox::HandlePlayScriptID</c> (0x00452020) — guid + script DID, no
|
||||
/// intensity, giving the 12-byte body <see cref="PlayPhysicsScript.WireSize"/>
|
||||
/// asserts.</para>
|
||||
/// </summary>
|
||||
public class PlayScriptGoldenTests
|
||||
{
|
||||
// ---- 0xF754 PlayScriptId -------------------------------------------------
|
||||
|
||||
public static TheoryData<uint, uint> ScriptIdCases() => new()
|
||||
{
|
||||
{ 0x50000001u, 0x0D000001u }, // player, a portal-space script DID
|
||||
{ 0x7C95B01Au, 0x00000000u }, // zero DID must survive as zero, not null
|
||||
{ 0xFFFFFFFFu, 0xFFFFFFFFu }, // full-width guid + DID
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ScriptIdCases))]
|
||||
public void PlayScriptId_AceGoldenBytes_DecodesGuidAndDid(uint guid, uint scriptDid)
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScript.Opcode)
|
||||
.WriteGuid(guid)
|
||||
.Write(scriptDid)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(PlayPhysicsScript.WireSize, body.Length);
|
||||
|
||||
PlayPhysicsScript? parsed = PlayPhysicsScript.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(guid, parsed!.Value.Guid);
|
||||
Assert.Equal(scriptDid, parsed.Value.ScriptDid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayScriptId_WrongOpcode_ReturnsNull()
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(0xF755u)
|
||||
.WriteGuid(1u).Write(2u).ToArray();
|
||||
|
||||
Assert.Null(PlayPhysicsScript.TryParse(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayScriptId_TrailingByte_ReturnsNull()
|
||||
{
|
||||
// The parser demands an exact length; a 13-byte body is not a
|
||||
// truncated-but-usable 0xF754.
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScript.Opcode)
|
||||
.WriteGuid(1u).Write(2u).Pad(1).ToArray();
|
||||
|
||||
Assert.Null(PlayPhysicsScript.TryParse(body));
|
||||
}
|
||||
|
||||
// ---- 0xF755 PlayEffect ---------------------------------------------------
|
||||
|
||||
public static TheoryData<uint, uint, float> ScriptTypeCases() => new()
|
||||
{
|
||||
// ACE's default speed argument is 1.0f.
|
||||
{ 0x50000001u, 0x00000021u, 1.0f },
|
||||
// Intensity is a free float on the wire; fractional values must survive.
|
||||
{ 0x7C95B01Au, 0x00000083u, 0.25f },
|
||||
// Zero intensity is meaningful (script suppressed), not "absent".
|
||||
{ 0x800114C0u, 0x00000001u, 0f },
|
||||
// Unknown type values are retained losslessly for the resolver to reject.
|
||||
{ 0xA9B40001u, 0xDEADBEEFu, -3.5f },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ScriptTypeCases))]
|
||||
public void PlayEffect_AceGoldenBytes_DecodesGuidTypeAndIntensity(
|
||||
uint guid, uint rawScriptType, float intensity)
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScriptType.Opcode)
|
||||
.WriteGuid(guid)
|
||||
.Write(rawScriptType)
|
||||
.Write(intensity)
|
||||
.ToArray();
|
||||
|
||||
// ACE declares GameMessageScript's length as 16.
|
||||
Assert.Equal(PlayPhysicsScriptType.WireSize, body.Length);
|
||||
Assert.Equal(16, body.Length);
|
||||
|
||||
PlayPhysicsScriptType? parsed = PlayPhysicsScriptType.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(guid, parsed!.Value.Guid);
|
||||
Assert.Equal(rawScriptType, parsed.Value.RawScriptType);
|
||||
Assert.Equal(intensity, parsed.Value.Intensity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayEffect_NonFiniteIntensity_IsRetainedLosslessly()
|
||||
{
|
||||
// The parser documents that non-finite floats are kept for the
|
||||
// resolver to reject rather than being coerced at parse time.
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScriptType.Opcode)
|
||||
.WriteGuid(0x50000001u)
|
||||
.Write(0x00000021u)
|
||||
.Write(float.NaN)
|
||||
.ToArray();
|
||||
|
||||
PlayPhysicsScriptType? parsed = PlayPhysicsScriptType.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.True(float.IsNaN(parsed!.Value.Intensity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayEffect_WrongOpcode_ReturnsNull()
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(0xF754u)
|
||||
.WriteGuid(1u).Write(2u).Write(1.0f).ToArray();
|
||||
|
||||
Assert.Null(PlayPhysicsScriptType.TryParse(body));
|
||||
}
|
||||
}
|
||||
101
tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs
Normal file
101
tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte conformance for inbound <c>VectorUpdate (0xF74E)</c>.
|
||||
///
|
||||
/// <para><b>Oracle derivation.</b> Bytes are generated by
|
||||
/// <see cref="AceWireWriter"/>, a faithful mirror of ACE's
|
||||
/// <c>Extensions.cs</c> writers, driven in the exact order that
|
||||
/// <c>ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageVectorUpdate.cs</c>
|
||||
/// writes them:</para>
|
||||
/// <code>
|
||||
/// Writer.WriteGuid(worldObject.Guid); // u32
|
||||
/// Writer.Write(velocity); // 3 x f32
|
||||
/// Writer.Write(omega); // 3 x f32
|
||||
/// Writer.Write(... ObjectInstance); // u16
|
||||
/// Writer.Write(... ObjectVector); // u16
|
||||
/// </code>
|
||||
/// <para>ACE declares the message length as 36 bytes (4 opcode + 32 payload),
|
||||
/// which matches <see cref="VectorUpdate"/>'s <c>4 + 32</c> guard.</para>
|
||||
/// <para>Cross-checked against holtburger's client-side reader for the same
|
||||
/// opcode (<c>crates/holtburger-protocol/src/messages/</c>).</para>
|
||||
/// </summary>
|
||||
public class VectorUpdateGoldenTests
|
||||
{
|
||||
private static byte[] Golden(
|
||||
uint guid,
|
||||
Vector3 velocity,
|
||||
Vector3 omega,
|
||||
ushort instanceSequence,
|
||||
ushort vectorSequence)
|
||||
=> AceWireWriter.GameMessage(VectorUpdate.Opcode)
|
||||
.WriteGuid(guid)
|
||||
.Write(velocity.X).Write(velocity.Y).Write(velocity.Z)
|
||||
.Write(omega.X).Write(omega.Y).Write(omega.Z)
|
||||
.Write(instanceSequence)
|
||||
.Write(vectorSequence)
|
||||
.ToArray();
|
||||
|
||||
public static TheoryData<string, uint, Vector3, Vector3, ushort, ushort> Cases() => new()
|
||||
{
|
||||
// A remote player jumping: +Z velocity, no spin.
|
||||
{ "jump", 0x50000001u, new Vector3(0f, 0f, 6.1f), Vector3.Zero, 355, 42 },
|
||||
// Running with a heading change: planar velocity plus yaw omega.
|
||||
{ "run+turn", 0x7C95B01Au, new Vector3(2.94f, -1.25f, 0f), new Vector3(0f, 0f, 1.5f), 1, 2 },
|
||||
// Rest state — every field zero except the sequences.
|
||||
{ "at-rest", 0x800114C0u, Vector3.Zero, Vector3.Zero, 0, 0 },
|
||||
// Negative components on every axis, to pin sign handling.
|
||||
{ "negatives", 0xA9B40001u, new Vector3(-1f, -2f, -3f), new Vector3(-4f, -5f, -6f), 65535, 65534 },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void TryParse_AceGoldenBytes_DecodesEveryFieldExactly(
|
||||
string label,
|
||||
uint guid,
|
||||
Vector3 velocity,
|
||||
Vector3 omega,
|
||||
ushort instanceSequence,
|
||||
ushort vectorSequence)
|
||||
{
|
||||
byte[] body = Golden(guid, velocity, omega, instanceSequence, vectorSequence);
|
||||
|
||||
// ACE's GameMessageVectorUpdate declares length 36 (opcode + payload).
|
||||
Assert.Equal(36, body.Length);
|
||||
|
||||
VectorUpdate.Parsed? parsed = VectorUpdate.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(guid, parsed!.Value.Guid);
|
||||
Assert.Equal(velocity, parsed.Value.Velocity);
|
||||
Assert.Equal(omega, parsed.Value.Omega);
|
||||
Assert.Equal(instanceSequence, parsed.Value.InstanceSequence);
|
||||
Assert.Equal(vectorSequence, parsed.Value.VectorSequence);
|
||||
Assert.False(string.IsNullOrEmpty(label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_WrongOpcode_ReturnsNull()
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(0xF74Cu)
|
||||
.WriteGuid(1u)
|
||||
.Write(0f).Write(0f).Write(0f)
|
||||
.Write(0f).Write(0f).Write(0f)
|
||||
.Write((ushort)0).Write((ushort)0)
|
||||
.ToArray();
|
||||
|
||||
Assert.Null(VectorUpdate.TryParse(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TruncatedByOneByte_ReturnsNull()
|
||||
{
|
||||
byte[] body = Golden(1u, Vector3.One, Vector3.One, 1, 1);
|
||||
Assert.Null(VectorUpdate.TryParse(body.AsSpan(0, body.Length - 1)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// Value-conformance for the transport flag word against ACE's
|
||||
/// <c>Source/ACE.Server/Network/PacketHeaderFlags.cs</c>, read 2026-07-29
|
||||
/// during the wire-stack audit.
|
||||
///
|
||||
/// <para>These are wire-format facts, not choices: a flag bit is the gate for
|
||||
/// an optional-header section, so a single wrong value silently shifts every
|
||||
/// subsequent section's offset and corrupts the packet checksum. The enum is
|
||||
/// small enough to pin exhaustively, and doing so makes any future edit a
|
||||
/// deliberate act rather than a typo.</para>
|
||||
///
|
||||
/// <para>The audit found acdream's enum a 23-of-23 match with ACE's, including
|
||||
/// the sparse gaps (nothing between 0x04 and 0x100, nothing between 0x00800000
|
||||
/// and 0x01000000). This test freezes that result.</para>
|
||||
/// </summary>
|
||||
public class PacketHeaderFlagsConformanceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Transcribed from ACE's PacketHeaderFlags enum, in declaration order.
|
||||
/// The trailing comments are ACE's own.
|
||||
/// </summary>
|
||||
private static readonly (string Name, uint Value)[] AceFlags =
|
||||
{
|
||||
("None", 0x00000000u),
|
||||
("Retransmission", 0x00000001u),
|
||||
("EncryptedChecksum", 0x00000002u), // can't be paired with 0x1
|
||||
("BlobFragments", 0x00000004u),
|
||||
("ServerSwitch", 0x00000100u),
|
||||
("LogonServerAddr", 0x00000200u),
|
||||
("EmptyHeader1", 0x00000400u),
|
||||
("Referral", 0x00000800u),
|
||||
("RequestRetransmit", 0x00001000u), // Nak
|
||||
("RejectRetransmit", 0x00002000u), // Empty Ack
|
||||
("AckSequence", 0x00004000u), // Pak
|
||||
("Disconnect", 0x00008000u), // Empty Header 2
|
||||
("LoginRequest", 0x00010000u),
|
||||
("WorldLoginRequest", 0x00020000u),
|
||||
("ConnectRequest", 0x00040000u),
|
||||
("ConnectResponse", 0x00080000u),
|
||||
("NetError", 0x00100000u),
|
||||
("NetErrorDisconnect", 0x00200000u),
|
||||
("CICMDCommand", 0x00400000u),
|
||||
("TimeSync", 0x01000000u),
|
||||
("EchoRequest", 0x02000000u),
|
||||
("EchoResponse", 0x04000000u),
|
||||
("Flow", 0x08000000u),
|
||||
};
|
||||
|
||||
public static TheoryData<string, uint> Flags()
|
||||
{
|
||||
var data = new TheoryData<string, uint>();
|
||||
foreach ((string name, uint value) in AceFlags)
|
||||
data.Add(name, value);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Flags))]
|
||||
public void Flag_MatchesAceValue(string name, uint aceValue)
|
||||
{
|
||||
Assert.True(
|
||||
Enum.IsDefined(typeof(PacketHeaderFlags), aceValue) || aceValue == 0,
|
||||
$"ACE declares {name} = 0x{aceValue:X8}; acdream has no such value.");
|
||||
|
||||
object parsed = Enum.Parse(typeof(PacketHeaderFlags), name);
|
||||
Assert.Equal(aceValue, (uint)(PacketHeaderFlags)parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumHasNoMembersAceDoesNotDeclare()
|
||||
{
|
||||
HashSet<string> ace = AceFlags.Select(f => f.Name).ToHashSet(StringComparer.Ordinal);
|
||||
string[] ours = Enum.GetNames(typeof(PacketHeaderFlags));
|
||||
|
||||
string[] extra = ours.Where(n => !ace.Contains(n)).ToArray();
|
||||
|
||||
Assert.True(
|
||||
extra.Length == 0,
|
||||
$"acdream declares flags ACE does not: {string.Join(", ", extra)}");
|
||||
Assert.Equal(AceFlags.Length, ours.Length);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue