using System; using System.Buffers.Binary; using System.Collections.Generic; using AcDream.Core.Ui; namespace AcDream.Core.Net.Messages; /// /// Inbound parsers + retail-shaped text rendering for the four CH4 /// client-command GameEvent responses that had a request builder /// () but no inbound handler — issue /// #362 / register row TS-70. ACE's server-side writers are the wire-shape /// oracle (cited per method); the retail CLIENT's decompiled handlers are /// the oracle for what text gets printed and in what order — see /// docs/research/named-retail/acclient_2013_pseudo_c.txt anchors /// cited per method. /// public static class ClientCommandResponses { // ── 0x0149 ChannelIndex / 0x0148 ChannelList ─────────────────────────── // ACE: GameEventChannelIndex.cs / GameEventChannelList.cs — both write // `Writer.Write(count)` (u32) then `count` WriteString16L entries; no // other framing. Retail: ClientCommunicationSystem:: // Handle_Communication__ChannelIndex @0x0057d0c0 / // Handle_Communication__ChannelList @0x0057d230 — both parse a single // PackableList the same way. /// 0x0149 ChannelIndex: the list of GM/staff channel names available to this account. public static IReadOnlyList? ParseChannelIndex(ReadOnlySpan payload) => ParseStringList(payload); /// 0x0148 ChannelList: the list of character names currently listening on the queried channel. public static IReadOnlyList? ParseChannelList(ReadOnlySpan payload) => ParseStringList(payload); private static IReadOnlyList? ParseStringList(ReadOnlySpan payload) { try { int pos = 0; uint count = ReadU32(payload, ref pos); var list = new List(); for (uint i = 0; i < count; i++) list.Add(StringReader.ReadString16L(payload, ref pos)); return list; } catch (FormatException) { return null; } } /// /// Retail-shaped lines for a ChannelIndex response — verbatim port of /// Handle_Communication__ChannelIndex's header + per-entry loop /// (all LogTextType 0x00 Default). Header text at /// docs/research/named-retail/acclient_2013_pseudo_c.txt:1031363 /// (data_7e0b10). /// public static IEnumerable FormatChannelIndexLines(IReadOnlyList channels) { yield return "The following channels are available to you:"; foreach (string channel in channels) yield return channel; } /// /// Retail-shaped lines for a ChannelList response — verbatim port of /// Handle_Communication__ChannelList. Header text at /// acclient_2013_pseudo_c.txt:1031367 (data_7e0b40). /// public static IEnumerable FormatChannelListLines(IReadOnlyList names) { yield return "The following characters are currently listening on the channel:"; foreach (string name in names) yield return name; } // ── 0x0271 AvailableHouses ────────────────────────────────────────────── // ACE: GameEventHouseAvailableHouses.cs — Write((uint)type) + // Write(locations: List, PackableList.cs:20 — u32 count then N u32 // landblock ids) + Write(totalAvailable: int). Retail: // ClientHousingSystem::Handle_House__Recv_AvailableHouses @0x00585d50 + // DisplayListOfCoords @0x00585c20. public readonly record struct AvailableHousesResponse( uint HouseType, IReadOnlyList Locations, int TotalAvailable); public static AvailableHousesResponse? ParseAvailableHouses(ReadOnlySpan payload) { try { int pos = 0; uint houseType = ReadU32(payload, ref pos); uint count = ReadU32(payload, ref pos); var locations = new uint[count]; for (uint i = 0; i < count; i++) locations[i] = ReadU32(payload, ref pos); int totalAvailable = unchecked((int)ReadU32(payload, ref pos)); return new AvailableHousesResponse(houseType, locations, totalAvailable); } catch (FormatException) { return null; } } /// /// ACE HouseType enum values (Undef=0, Cottage=1, Villa=2, /// Mansion=3, Apartment=4) — matches the switch at /// acclient_2013_pseudo_c.txt:400205-400227 exactly (case 1..4; /// out-of-range/0 leaves the type name empty, matching retail's /// skipped-`if` fallthrough). /// private static string HouseTypeName(uint houseType) => houseType switch { 1u => "cottages", 2u => "villas", 3u => "mansions", 4u => "apartments", _ => "", }; /// /// Retail-shaped lines for an AvailableHouses response. Verbatim port of /// Handle_House__Recv_AvailableHouses @0x00585d50 + /// DisplayListOfCoords @0x00585c20: the summary line, then one /// coordinate line per location UNLESS the type is Apartment (retail /// skips DisplayListOfCoords entirely for arg2 == 4 — /// apartments have no world location), then the >400-locations /// truncation notice (data_7e1d70, /// acclient_2013_pseudo_c.txt:1032459) if TotalAvailable > 0x190. /// All lines are LogTextType 0x00 Default. Coordinate formatting is /// — the same /// (lcoord - 1024) * 0.1 + 0.5 port /// CPlayerSystem::InqPlayerCoords uses — with retail's own /// 5-space indent and "Y, X" order /// (" %.1f%s, %.1f%s\n", args Y, Ysuffix, X[, Xsuffix]). /// public static IEnumerable FormatAvailableHousesLines(AvailableHousesResponse response) { yield return string.Create( System.Globalization.CultureInfo.InvariantCulture, $"There are {response.TotalAvailable} {HouseTypeName(response.HouseType)} available."); if (response.HouseType != 4u) { foreach (uint landblockId in response.Locations) { if (RadarCoordinates.TryFromCell(landblockId, out var coordinates)) yield return $" {coordinates.YText}, {coordinates.XText}"; } if (response.TotalAvailable > 0x190) yield return "There were too many houses to display all the locations. Only the first 400 locations are displayed here."; } } // ── 0x027C AllegianceInfoResponse / 0x0020 AllegianceUpdate ───────────── // ACE: GameEventAllegianceInfoResponse.cs -> AllegianceProfileExtensions. // Write / AllegianceHierarchyExtensions.Write / AllegianceDataExtensions. // Write. Retail: ClientAllegianceSystem:: // Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0, walking // AllegianceProfile::GetData/GetPatron/GetFirstVassal/GetNextVassal. // // Campaign FA slice FA1 (2026-08-11): extended with the ELEVEN // AllegianceHierarchy::UnPack version gates // (docs/research/2026-08-11-fa-allegiance-wire.md, lane C, §4.2) and // the tree-assembly rules (§4.4: an orphan treeParent — one not // already in the tree — discards the WHOLE message; sibling order // REVERSES on assembly), then factored into a shared // ReadAllegianceProfileBody used by BOTH this parser (leading u32 = // targetGuid) and the new ParseAllegianceUpdate (leading u32 = rank) — // lane C §7.2's explicit reuse verdict: extend this parser, do not // write a second one. ACE always writes oldVersion 0x000B (newest), so // every gate below is exercised in practice; the gates exist so a // parser doesn't silently misread a hypothetical older-version blob. /// /// One retail AllegianceData record (lane C §4.1). is the wire's "treeParent" tag (0 for the /// monarch, who has none) — retail's own GetPatron/ /// GetFirstVassal walk the flat record list by this tag rather /// than storing an explicit tree. The trailing fields (FA1) were /// previously read and discarded — they are exactly the columns lane C /// §7.2 names as needed once a panel exists; TimeOnline/AllegianceAge /// remain unsurfaced because ACE hard-codes both to 0 forever (lane C /// §5.1), so surfacing them would only ever show zero. Trailing /// defaults keep the pre-FA1 4-arg positional construction sites /// (tests, historically) compiling unchanged. /// public readonly record struct AllegianceMemberRecord( uint CharacterId, uint ParentGuid, bool IsLoggedIn, string Name, ushort Rank = 0, uint Level = 0, ushort Loyalty = 0, ushort Leadership = 0, uint CpCached = 0, uint CpTithed = 0, byte Gender = 0, byte HeritageGroup = 0, bool MayPassupExperience = false); /// /// /HasAllegianceAge/ /// HasPackedLevel/MayPassupExperience bit values — ACE /// Source/ACE.Server/Network/Enum/AllegianceIndex.cs, matching /// retail's own enum verbatim (acclient.h:7714-7722). /// private const uint LoggedInBit = 0x1u; private const uint HasAllegianceAgeBit = 0x4u; private const uint HasPackedLevelBit = 0x8u; private const uint MayPassupExperienceBit = 0x10u; /// /// The version-gated hierarchy-level fields plus the assembled record /// list — the parts of AllegianceProfile/AllegianceHierarchy /// that do NOT differ between 0x027C (leading guid) and /// 0x0020 (leading rank). Officers / officer titles / the four /// monarch-and-spokes broadcast counters / the bind point are read (so /// every later field lands at the correct offset regardless of /// version) but not surfaced on the public records: ACE deliberately /// zeroes or empties officers/titles/lock/approvedVassal/broadcast /// counters (lane C §5.1) and the bind point is a 32-byte /// Position the retail chat renderer never reads either — a /// panel that needs the bind point can extend this record without /// re-deriving the parse (deferred, not dropped). /// private readonly record struct AllegianceProfileBody( uint TotalMembers, uint TotalVassals, ushort RecordCount, ushort OldVersion, string Motd, string MotdSetBy, uint ChatRoomId, string AllegianceName, uint NameLastSetTime, bool IsLocked, uint ApprovedVassal, AllegianceMemberRecord? Monarch, IReadOnlyList Records); /// /// Shared lookup logic for both /// and — the flat record list plus /// ParentGuid tags IS the tree (lane C §0's DELETE verdict on /// Core/Allegiance/AllegianceTree.cs); these are ports of /// retail's own pointer-walk accessors (lane C §1.5). /// /// /// FA2 fix-round SHOULD-FIX 5 (2026-08-12, /// docs/research/2026-08-12-fa2-review-mechanism.md): promoted from /// to (with /// AcDream.Runtime added to this project's /// InternalsVisibleTo) so /// AcDream.Runtime.Gameplay.RuntimeAllegianceState can reuse this /// walk instead of re-implementing it a second time. /// /// internal static class AllegianceProfileLookups { /// Port of AllegianceProfile::GetData. public static AllegianceMemberRecord? FindData( AllegianceMemberRecord? monarch, IReadOnlyList records, uint guid) { if (monarch is { } m && m.CharacterId == guid) return monarch; foreach (AllegianceMemberRecord record in records) if (record.CharacterId == guid) return record; return null; } /// /// Port of AllegianceProfile::GetPatron: the monarch has no /// patron; anyone else's patron is applied /// to their own record's . /// public static AllegianceMemberRecord? FindPatron( AllegianceMemberRecord? monarch, IReadOnlyList records, uint guid) { if (monarch is { } m && m.CharacterId == guid) return null; foreach (AllegianceMemberRecord record in records) if (record.CharacterId == guid) return FindData(monarch, records, record.ParentGuid); return null; } /// /// Port of GetFirstVassal/GetNextVassal. Lane C §4.4 /// point 3: each new record is PREPENDED to its parent's vassal /// list on assembly (_peer = parent->_vassal; parent->_vassal /// = node;), so the walk — and therefore the panel's vassal /// list box — visits siblings in REVERSE wire order: the record /// parsed LAST under a given parent renders first. /// public static IEnumerable FindVassals( IReadOnlyList records, uint guid) { for (int i = records.Count - 1; i >= 0; i--) if (records[i].ParentGuid == guid) yield return records[i]; } } public readonly record struct AllegianceInfoResponse( uint TargetGuid, uint TotalMembers, uint TotalVassals, ushort RecordCount, string AllegianceName, AllegianceMemberRecord? Monarch, IReadOnlyList Records, ushort OldVersion = 0, string Motd = "", string MotdSetBy = "", uint ChatRoomId = 0, uint NameLastSetTime = 0, bool IsLocked = false, uint ApprovedVassal = 0) { public AllegianceMemberRecord? FindData(uint guid) => AllegianceProfileLookups.FindData(Monarch, Records, guid); public AllegianceMemberRecord? FindPatron(uint guid) => AllegianceProfileLookups.FindPatron(Monarch, Records, guid); public IEnumerable FindVassals(uint guid) => AllegianceProfileLookups.FindVassals(Records, guid); } public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan payload) { try { int pos = 0; uint targetGuid = ReadU32(payload, ref pos); AllegianceProfileBody? body = ReadAllegianceProfileBody(payload, ref pos); if (body is null) return null; // §4.4 orphan/self-parent/duplicate — retail discards the WHOLE message AllegianceProfileBody b = body.Value; return new AllegianceInfoResponse( targetGuid, b.TotalMembers, b.TotalVassals, b.RecordCount, b.AllegianceName, b.Monarch, b.Records, b.OldVersion, b.Motd, b.MotdSetBy, b.ChatRoomId, b.NameLastSetTime, b.IsLocked, b.ApprovedVassal); } catch (FormatException) { return null; } } /// /// 0x0020 AllegianceUpdate — the unsolicited/subscribed profile /// push (lane C §2 row 5, §4.5): a leading u32 rank, then the /// SAME AllegianceProfile body 0x027C carries. Pushed on /// every tree change to every online member regardless of whether the /// panel ever sent 0x001F (lane C §5.2) — a client that never /// subscribes still receives it. /// public readonly record struct AllegianceUpdate( uint Rank, uint TotalMembers, uint TotalVassals, ushort RecordCount, string AllegianceName, AllegianceMemberRecord? Monarch, IReadOnlyList Records, ushort OldVersion = 0, string Motd = "", string MotdSetBy = "", uint ChatRoomId = 0, uint NameLastSetTime = 0, bool IsLocked = false, uint ApprovedVassal = 0) { public AllegianceMemberRecord? FindData(uint guid) => AllegianceProfileLookups.FindData(Monarch, Records, guid); public AllegianceMemberRecord? FindPatron(uint guid) => AllegianceProfileLookups.FindPatron(Monarch, Records, guid); public IEnumerable FindVassals(uint guid) => AllegianceProfileLookups.FindVassals(Records, guid); } public static AllegianceUpdate? ParseAllegianceUpdate(ReadOnlySpan payload) { try { int pos = 0; uint rank = ReadU32(payload, ref pos); AllegianceProfileBody? body = ReadAllegianceProfileBody(payload, ref pos); if (body is null) return null; AllegianceProfileBody b = body.Value; return new AllegianceUpdate( rank, b.TotalMembers, b.TotalVassals, b.RecordCount, b.AllegianceName, b.Monarch, b.Records, b.OldVersion, b.Motd, b.MotdSetBy, b.ChatRoomId, b.NameLastSetTime, b.IsLocked, b.ApprovedVassal); } catch (FormatException) { return null; } } /// /// Reads everything after the profile's leading discriminator u32 /// (targetGuid for 0x027C, rank for 0x0020) — the eleven /// version gates (lane C §4.2) followed by the monarch + record list /// with the §4.4 tree-assembly rules enforced. Returns (never throws) when a record's treeParent is /// orphaned, self-referential, or a duplicate id — the same "discard /// the whole message" outcome AllegianceHierarchy::UnPack /// produces on an Add failure. Truncation still throws /// , caught by both callers' try/catch. /// private static AllegianceProfileBody? ReadAllegianceProfileBody(ReadOnlySpan payload, ref int pos) { uint totalMembers = ReadU32(payload, ref pos); uint totalVassals = ReadU32(payload, ref pos); ushort recordCount = ReadU16(payload, ref pos); ushort oldVersion = ReadU16(payload, ref pos); // §4.2's ELEVEN gates are the eleven non-zero AllegianceVersion // enum values (acclient.h:2979-2994, SpokespersonAdded=1 through // ApprovedVassal=11), numbered below by THAT version number — not // by wire-appearance order, which is a different sequence (the // officers table at version 6 is read before officer titles at // version 9 but after the version-1/2/3/4 fields). Gate 5 // (BannedCharactersAdded) is real — it is one of the eleven // AllegianceVersion values — but gates NOTHING in UnPack: the ban // list never rides this blob, so there is no field/read for it // here (pinned by the negative test // VersionGate_4to5_BannedCharactersAddedGatesNothing). // Gate 1 (SpokespersonAdded, 1 <= oldVersion < 6) vs gate 6 // (MultipleAllegianceOfficersAdded, oldVersion >= 6): the officers // PHashTable REPLACES the legacy single spokesperson-id 4-byte // skip at version 6. Entries are consumed but not surfaced (ACE // always sends officers empty — lane C §5.1) so every later field // still lands correctly. if (oldVersion >= 6) { ushort officerCount = ReadU16(payload, ref pos); _ = ReadU16(payload, ref pos); // numBuckets — server-chosen, not consulted for (int i = 0; i < officerCount; i++) { _ = ReadU32(payload, ref pos); // guid _ = ReadU32(payload, ref pos); // officer level } } else if (oldVersion >= 1) { _ = ReadU32(payload, ref pos); // old single spokesperson id } // Gate 9 (OfficersTitlesAdded, oldVersion >= 9) — PSmartArray: // a bare i32 count, NOT the PackableHashTable u16/u16 header. if (oldVersion >= 9) { int titleCount = unchecked((int)ReadU32(payload, ref pos)); for (int i = 0; i < titleCount; i++) _ = StringReader.ReadString16L(payload, ref pos); } // Gate 2 (PoolsAdded, oldVersion >= 2): four broadcast counters. if (oldVersion >= 2) { _ = ReadU32(payload, ref pos); // monarchBroadcastTime _ = ReadU32(payload, ref pos); // monarchBroadcastsToday _ = ReadU32(payload, ref pos); // spokesBroadcastTime _ = ReadU32(payload, ref pos); // spokesBroadcastsToday } // Gate 3 (MotdAdded, oldVersion >= 3). string motd = ""; string motdSetBy = ""; if (oldVersion >= 3) { motd = StringReader.ReadString16L(payload, ref pos); motdSetBy = StringReader.ReadString16L(payload, ref pos); } // Gate 4 (ChatRoomIDAdded, oldVersion >= 4). uint chatRoomId = 0; if (oldVersion >= 4) chatRoomId = ReadU32(payload, ref pos); // Gate 7 (Bindstones, oldVersion >= 7): Position = // cell(u32) + pos(3xfloat) + rotation(4xfloat, W/X/Y/Z) = 32 // bytes. Skipped, not surfaced — see the class doc on // AllegianceProfileBody for why. if (oldVersion >= 7) { for (int i = 0; i < 8; i++) _ = ReadU32(payload, ref pos); } // Gate 8 (AllegianceName, oldVersion >= 8). string allegianceName = ""; uint nameLastSetTime = 0; if (oldVersion >= 8) { allegianceName = StringReader.ReadString16L(payload, ref pos); nameLastSetTime = ReadU32(payload, ref pos); } // Gate 10 (LockedState, oldVersion >= 10). bool isLocked = false; if (oldVersion >= 10) isLocked = ReadU32(payload, ref pos) != 0u; // Gate 11 (ApprovedVassal, oldVersion >= 11). uint approvedVassal = 0; if (oldVersion >= 11) approvedVassal = ReadU32(payload, ref pos); // §4.4 tree assembly: the monarch record (no treeParent on the // wire, never version-gated) followed by (recordCount-1) records // each carrying an explicit treeParent. // AllegianceHierarchy::Add @0x005B6E90 discards the WHOLE message // if: the record's own id is zero (MF-1 — retail's ENTIRE Add // body is wrapped in `if (_id != 0)`, so a zero id falls straight // out to `return 0` for BOTH the monarch and a child record — this // is also what makes treeParent == 0 unconditionally fatal for // every non-monarch record, since 0 can never be a knownId); a // treeParent is not already in the tree (orphan); the treeParent // equals the record's own id (self-parent); or the id duplicates // one already seen — modeled here as a running knownIds set; any // failure returns null rather than a partial/corrupted tree. AllegianceMemberRecord? monarch = null; var records = new List(); if (recordCount > 0) { AllegianceMemberRecord monarchRecord = ReadAllegianceData(payload, ref pos, parentGuid: 0u); if (monarchRecord.CharacterId == 0u) return null; monarch = monarchRecord; var knownIds = new HashSet { monarchRecord.CharacterId }; for (int i = 1; i < recordCount; i++) { uint parentGuid = ReadU32(payload, ref pos); AllegianceMemberRecord record = ReadAllegianceData(payload, ref pos, parentGuid); if (record.CharacterId == 0u || !knownIds.Contains(parentGuid) || parentGuid == record.CharacterId || knownIds.Contains(record.CharacterId)) { return null; } knownIds.Add(record.CharacterId); records.Add(record); } // Retail's LAST act before UnPack returns success // (@0x005B77A7-0x005B77B3): the monarch can never pass up, // whatever the wire bit said — force it false regardless of // ReadAllegianceData's HasPackedLevel-absent legacy-compat // fallback (SF-1/SF-3). Inert against ACE (AllegianceData.cs // never sets the bit for a monarch record) but load-bearing // the moment a record is hand-built or read from a non-ACE // server. monarch = monarch.Value with { MayPassupExperience = false }; } return new AllegianceProfileBody( totalMembers, totalVassals, recordCount, oldVersion, motd, motdSetBy, chatRoomId, allegianceName, nameLastSetTime, isLocked, approvedVassal, monarch, records); } private static AllegianceMemberRecord ReadAllegianceData( ReadOnlySpan payload, ref int pos, uint parentGuid) { uint characterId = ReadU32(payload, ref pos); uint cpCached = ReadU32(payload, ref pos); uint cpTithed = ReadU32(payload, ref pos); uint bitfield = ReadU32(payload, ref pos); byte gender = ReadByte(payload, ref pos); byte heritageGroup = ReadByte(payload, ref pos); ushort rank = ReadU16(payload, ref pos); uint level = 0; if ((bitfield & HasPackedLevelBit) != 0u) level = ReadU32(payload, ref pos); ushort loyalty = ReadU16(payload, ref pos); ushort leadership = ReadU16(payload, ref pos); if ((bitfield & HasAllegianceAgeBit) != 0u) { _ = ReadU32(payload, ref pos); // timeOnline — ACE hard-codes 0 forever (lane C §5.1) _ = ReadU32(payload, ref pos); // allegianceAge — same } else { _ = ReadU32(payload, ref pos); // legacy uTimeOnline low (double, pre-HasAllegianceAge) _ = ReadU32(payload, ref pos); // legacy uTimeOnline high } string name = StringReader.ReadString16L(payload, ref pos); // Lane C §4.1 point 1: when HasPackedLevel is absent, retail's // client sets MayPassupExperience itself regardless of the wire // bit — legacy-packet compatibility. Harmless against ACE (which // always sets HasPackedLevel) but ported for fidelity: a port // that also sets the bit is MORE faithful than one that does not. bool mayPassupExperience = (bitfield & MayPassupExperienceBit) != 0u || (bitfield & HasPackedLevelBit) == 0u; return new AllegianceMemberRecord( characterId, parentGuid, (bitfield & LoggedInBit) != 0u, name, rank, level, loyalty, leadership, cpCached, cpTithed, gender, heritageGroup, mayPassupExperience); } /// /// Retail's " *" online marker (data_7cef28, /// acclient_2013_pseudo_c.txt:1027329-1027330 — UTF-16 bytes /// 20 00 2a 00 = space + asterisk) versus the empty string for an /// offline member (data_794320, the generic empty-PStringBase /// sentinel). /// private static string OnlineMarker(AllegianceMemberRecord member) => member.IsLoggedIn ? " *" : ""; /// /// Retail-shaped lines for an AllegianceInfoResponse — verbatim port of /// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0. /// Retail's own AllegianceProfile::GetData failure (the queried /// player has no record at all — e.g. no allegiance) returns early with /// NO text printed at all; this yields an empty sequence for that case, /// matching retail exactly rather than inventing a "no allegiance" /// message retail never shows. /// public static IEnumerable FormatAllegianceInfoLines(AllegianceInfoResponse response) { AllegianceMemberRecord? self = response.FindData(response.TargetGuid); if (self is not { } selfRecord) yield break; yield return "Note: An asterisk (*) indicates that the character is currently online."; yield return $"Allegiance information for {selfRecord.Name}{OnlineMarker(selfRecord)}:"; if (response.FindPatron(response.TargetGuid) is { } patron) yield return $" Patron: {patron.Name}{OnlineMarker(patron)}"; bool wroteVassalHeader = false; foreach (AllegianceMemberRecord vassal in response.FindVassals(response.TargetGuid)) { if (!wroteVassalHeader) { yield return " Vassals: "; wroteVassalHeader = true; } yield return $" {vassal.Name}{OnlineMarker(vassal)}"; } } // ── Shared primitive readers (throw on truncation, like StringReader) ── 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)); pos += 4; return value; } private static ushort ReadU16(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 2) throw new FormatException("truncated u16"); ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); pos += 2; return value; } private static byte ReadByte(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 1) throw new FormatException("truncated byte"); byte value = source[pos]; pos += 1; return value; } }