Remaining SHOULD-FIX findings from the FA2 mechanism/blast reviews: Mechanism SF-3/SF-4 -- RuntimeFellowshipState.ApplyUpdateFellow now ports Fellowship::RecalculateEvenXPSplitting @0x005B92E0 (called from retail's AddFellow/UpdateFellow/RemoveFellow on every upsert/removal, but never from a full update -- that carries the server's own authoritative flag verbatim, lane B 6.2) and Fellowship::AddFellow @0x005B9480's locked/departed admission gate (a brand-new guid is refused while _locked unless it appears in the 0x02BE field-8 _fellows_departed table within 900s, @0x005B94A5). ApplyFullUpdate now stores update.Departed instead of discarding it. A TimeProvider dependency (defaulting to TimeProvider.System, matching the RuntimeCharacterOptionsState precedent) makes the 900s grace window testable. Mechanism SF-5 -- RuntimeAllegianceState's TryGetMember/TryGetPatron/ GetVassals now reuse ClientCommandResponses.AllegianceProfileLookups (promoted private -> internal, AcDream.Runtime added to Core.Net's InternalsVisibleTo) instead of re-implementing the retail walk a second time. Mechanism SF-6 -- RuntimeStateCheckpoint's Fellowship/Allegiance parameters are no longer trailing-optional. `default(RuntimeFellowshipSnapshot)`/ `default(RuntimeAllegianceSnapshot)` zero-init Name/AllegianceName to null, and C# does not allow a non-constant `new(...)` as an optional parameter's default value (CS1736) even when the struct declares an explicit parameterless constructor -- so the only way to guarantee a non-null default was to make the parameters required. Both snapshot types still gained an explicit parameterless constructor for callers that want an empty-but-safe `new()`. Blast SF-4 -- LiveSessionEventRouterTests gains FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove, wiring real RuntimeFellowshipState/RuntimeAllegianceState owners through the one production registration site and dispatching a real 0x00A3 envelope for both a self-quit and an other-quit -- the one non-trivial lambda in the slice (the self-guid source that decides "remove one member" vs "clear the whole snapshot") was previously untested; every other router test defaults Fellowship/Allegiance to null. Blast SF-5 -- RuntimeFellowshipState.ResetSession dropped its disposed guard to match the precedent its own doc comment names (RuntimeInventoryState.ResetExternalContainer, RuntimeCommunicationState.ResetNegotiatedChannels -- both bare delegations with no disposal guard); the reset transaction is retryable and disposal is terminal, so a throwing guard could never converge on retry. RuntimeAllegianceState.ResetSession (new this fix round) matches the same shape from the start. Blast SF-7 -- IRuntimeAllegianceView.GetVassals' per-call List<> allocation is now documented as an intentional exception to the file's "Snapshot + TryGet*, no allocation" view convention (C# cannot yield-return from inside a lock). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
681 lines
30 KiB
C#
681 lines
30 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Collections.Generic;
|
|
using AcDream.Core.Ui;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Inbound parsers + retail-shaped text rendering for the four CH4
|
|
/// client-command GameEvent responses that had a request builder
|
|
/// (<see cref="ClientCommandRequests"/>) 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
|
|
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> anchors
|
|
/// cited per method.
|
|
/// </summary>
|
|
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<PStringBase> the same way.
|
|
|
|
/// <summary>0x0149 ChannelIndex: the list of GM/staff channel names available to this account.</summary>
|
|
public static IReadOnlyList<string>? ParseChannelIndex(ReadOnlySpan<byte> payload) =>
|
|
ParseStringList(payload);
|
|
|
|
/// <summary>0x0148 ChannelList: the list of character names currently listening on the queried channel.</summary>
|
|
public static IReadOnlyList<string>? ParseChannelList(ReadOnlySpan<byte> payload) =>
|
|
ParseStringList(payload);
|
|
|
|
private static IReadOnlyList<string>? ParseStringList(ReadOnlySpan<byte> payload)
|
|
{
|
|
try
|
|
{
|
|
int pos = 0;
|
|
uint count = ReadU32(payload, ref pos);
|
|
var list = new List<string>();
|
|
for (uint i = 0; i < count; i++)
|
|
list.Add(StringReader.ReadString16L(payload, ref pos));
|
|
return list;
|
|
}
|
|
catch (FormatException) { return null; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail-shaped lines for a ChannelIndex response — verbatim port of
|
|
/// <c>Handle_Communication__ChannelIndex</c>'s header + per-entry loop
|
|
/// (all LogTextType 0x00 Default). Header text at
|
|
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:1031363</c>
|
|
/// (<c>data_7e0b10</c>).
|
|
/// </summary>
|
|
public static IEnumerable<string> FormatChannelIndexLines(IReadOnlyList<string> channels)
|
|
{
|
|
yield return "The following channels are available to you:";
|
|
foreach (string channel in channels)
|
|
yield return channel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail-shaped lines for a ChannelList response — verbatim port of
|
|
/// <c>Handle_Communication__ChannelList</c>. Header text at
|
|
/// <c>acclient_2013_pseudo_c.txt:1031367</c> (<c>data_7e0b40</c>).
|
|
/// </summary>
|
|
public static IEnumerable<string> FormatChannelListLines(IReadOnlyList<string> 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<uint>, 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<uint> Locations,
|
|
int TotalAvailable);
|
|
|
|
public static AvailableHousesResponse? ParseAvailableHouses(ReadOnlySpan<byte> 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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// ACE <c>HouseType</c> enum values (Undef=0, Cottage=1, Villa=2,
|
|
/// Mansion=3, Apartment=4) — matches the switch at
|
|
/// <c>acclient_2013_pseudo_c.txt:400205-400227</c> exactly (case 1..4;
|
|
/// out-of-range/0 leaves the type name empty, matching retail's
|
|
/// skipped-`if` fallthrough).
|
|
/// </summary>
|
|
private static string HouseTypeName(uint houseType) => houseType switch
|
|
{
|
|
1u => "cottages",
|
|
2u => "villas",
|
|
3u => "mansions",
|
|
4u => "apartments",
|
|
_ => "",
|
|
};
|
|
|
|
/// <summary>
|
|
/// Retail-shaped lines for an AvailableHouses response. Verbatim port of
|
|
/// <c>Handle_House__Recv_AvailableHouses</c> @0x00585d50 +
|
|
/// <c>DisplayListOfCoords</c> @0x00585c20: the summary line, then one
|
|
/// coordinate line per location UNLESS the type is Apartment (retail
|
|
/// skips <c>DisplayListOfCoords</c> entirely for <c>arg2 == 4</c> —
|
|
/// apartments have no world location), then the >400-locations
|
|
/// truncation notice (<c>data_7e1d70</c>,
|
|
/// <c>acclient_2013_pseudo_c.txt:1032459</c>) if <c>TotalAvailable > 0x190</c>.
|
|
/// All lines are LogTextType 0x00 Default. Coordinate formatting is
|
|
/// <see cref="AcDream.Core.Ui.RadarCoordinates"/> — the same
|
|
/// <c>(lcoord - 1024) * 0.1 + 0.5</c> port
|
|
/// <c>CPlayerSystem::InqPlayerCoords</c> uses — with retail's own
|
|
/// 5-space indent and "Y, X" order
|
|
/// (<c>" %.1f%s, %.1f%s\n"</c>, args <c>Y, Ysuffix, X</c>[, Xsuffix]).
|
|
/// </summary>
|
|
public static IEnumerable<string> 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.
|
|
|
|
/// <summary>
|
|
/// One retail <c>AllegianceData</c> record (lane C §4.1). <paramref
|
|
/// name="ParentGuid"/> is the wire's "treeParent" tag (0 for the
|
|
/// monarch, who has none) — retail's own <c>GetPatron</c>/
|
|
/// <c>GetFirstVassal</c> 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.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// <see cref="AllegianceIndex.LoggedIn"/>/<c>HasAllegianceAge</c>/
|
|
/// <c>HasPackedLevel</c>/<c>MayPassupExperience</c> bit values — ACE
|
|
/// <c>Source/ACE.Server/Network/Enum/AllegianceIndex.cs</c>, matching
|
|
/// retail's own enum verbatim (<c>acclient.h:7714-7722</c>).
|
|
/// </summary>
|
|
private const uint LoggedInBit = 0x1u;
|
|
private const uint HasAllegianceAgeBit = 0x4u;
|
|
private const uint HasPackedLevelBit = 0x8u;
|
|
private const uint MayPassupExperienceBit = 0x10u;
|
|
|
|
/// <summary>
|
|
/// The version-gated hierarchy-level fields plus the assembled record
|
|
/// list — the parts of <c>AllegianceProfile</c>/<c>AllegianceHierarchy</c>
|
|
/// that do NOT differ between <c>0x027C</c> (leading guid) and
|
|
/// <c>0x0020</c> (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
|
|
/// <c>Position</c> 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).
|
|
/// </summary>
|
|
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<AllegianceMemberRecord> Records);
|
|
|
|
/// <summary>
|
|
/// Shared lookup logic for both <see cref="AllegianceInfoResponse"/>
|
|
/// and <see cref="AllegianceUpdate"/> — the flat record list plus
|
|
/// <c>ParentGuid</c> tags IS the tree (lane C §0's DELETE verdict on
|
|
/// <c>Core/Allegiance/AllegianceTree.cs</c>); these are ports of
|
|
/// retail's own pointer-walk accessors (lane C §1.5).
|
|
///
|
|
/// <para>
|
|
/// FA2 fix-round SHOULD-FIX 5 (2026-08-12,
|
|
/// docs/research/2026-08-12-fa2-review-mechanism.md): promoted from
|
|
/// <see langword="private"/> to <see langword="internal"/> (with
|
|
/// <c>AcDream.Runtime</c> added to this project's
|
|
/// <c>InternalsVisibleTo</c>) so
|
|
/// <c>AcDream.Runtime.Gameplay.RuntimeAllegianceState</c> can reuse this
|
|
/// walk instead of re-implementing it a second time.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class AllegianceProfileLookups
|
|
{
|
|
/// <summary>Port of <c>AllegianceProfile::GetData</c>.</summary>
|
|
public static AllegianceMemberRecord? FindData(
|
|
AllegianceMemberRecord? monarch,
|
|
IReadOnlyList<AllegianceMemberRecord> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of <c>AllegianceProfile::GetPatron</c>: the monarch has no
|
|
/// patron; anyone else's patron is <see cref="FindData"/> applied
|
|
/// to their own record's <see cref="AllegianceMemberRecord.ParentGuid"/>.
|
|
/// </summary>
|
|
public static AllegianceMemberRecord? FindPatron(
|
|
AllegianceMemberRecord? monarch,
|
|
IReadOnlyList<AllegianceMemberRecord> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of <c>GetFirstVassal</c>/<c>GetNextVassal</c>. Lane C §4.4
|
|
/// point 3: each new record is PREPENDED to its parent's vassal
|
|
/// list on assembly (<c>_peer = parent->_vassal; parent->_vassal
|
|
/// = node;</c>), 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.
|
|
/// </summary>
|
|
public static IEnumerable<AllegianceMemberRecord> FindVassals(
|
|
IReadOnlyList<AllegianceMemberRecord> 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<AllegianceMemberRecord> 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<AllegianceMemberRecord> FindVassals(uint guid) =>
|
|
AllegianceProfileLookups.FindVassals(Records, guid);
|
|
}
|
|
|
|
public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan<byte> 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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile
|
|
/// push (lane C §2 row 5, §4.5): a leading <c>u32 rank</c>, then the
|
|
/// SAME <c>AllegianceProfile</c> body <c>0x027C</c> carries. Pushed on
|
|
/// every tree change to every online member regardless of whether the
|
|
/// panel ever sent <c>0x001F</c> (lane C §5.2) — a client that never
|
|
/// subscribes still receives it.
|
|
/// </summary>
|
|
public readonly record struct AllegianceUpdate(
|
|
uint Rank,
|
|
uint TotalMembers,
|
|
uint TotalVassals,
|
|
ushort RecordCount,
|
|
string AllegianceName,
|
|
AllegianceMemberRecord? Monarch,
|
|
IReadOnlyList<AllegianceMemberRecord> 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<AllegianceMemberRecord> FindVassals(uint guid) =>
|
|
AllegianceProfileLookups.FindVassals(Records, guid);
|
|
}
|
|
|
|
public static AllegianceUpdate? ParseAllegianceUpdate(ReadOnlySpan<byte> 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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads everything after the profile's leading discriminator u32
|
|
/// (targetGuid for <c>0x027C</c>, rank for <c>0x0020</c>) — the eleven
|
|
/// version gates (lane C §4.2) followed by the monarch + record list
|
|
/// with the §4.4 tree-assembly rules enforced. Returns <see
|
|
/// langword="null"/> (never throws) when a record's treeParent is
|
|
/// orphaned, self-referential, or a duplicate id — the same "discard
|
|
/// the whole message" outcome <c>AllegianceHierarchy::UnPack</c>
|
|
/// produces on an <c>Add</c> failure. Truncation still throws
|
|
/// <see cref="FormatException"/>, caught by both callers' try/catch.
|
|
/// </summary>
|
|
private static AllegianceProfileBody? ReadAllegianceProfileBody(ReadOnlySpan<byte> 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<PString>:
|
|
// 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<AllegianceMemberRecord>();
|
|
if (recordCount > 0)
|
|
{
|
|
AllegianceMemberRecord monarchRecord = ReadAllegianceData(payload, ref pos, parentGuid: 0u);
|
|
if (monarchRecord.CharacterId == 0u)
|
|
return null;
|
|
monarch = monarchRecord;
|
|
var knownIds = new HashSet<uint> { 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<byte> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's " *" online marker (<c>data_7cef28</c>,
|
|
/// <c>acclient_2013_pseudo_c.txt:1027329-1027330</c> — UTF-16 bytes
|
|
/// <c>20 00 2a 00</c> = space + asterisk) versus the empty string for an
|
|
/// offline member (<c>data_794320</c>, the generic empty-PStringBase
|
|
/// sentinel).
|
|
/// </summary>
|
|
private static string OnlineMarker(AllegianceMemberRecord member) =>
|
|
member.IsLoggedIn ? " *" : "";
|
|
|
|
/// <summary>
|
|
/// Retail-shaped lines for an AllegianceInfoResponse — verbatim port of
|
|
/// <c>Handle_Allegiance__AllegianceInfoResponseEvent</c> @0x0056a1d0.
|
|
/// Retail's own <c>AllegianceProfile::GetData</c> 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.
|
|
/// </summary>
|
|
public static IEnumerable<string> 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<byte> 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<byte> 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<byte> source, ref int pos)
|
|
{
|
|
if (source.Length - pos < 1) throw new FormatException("truncated byte");
|
|
byte value = source[pos];
|
|
pos += 1;
|
|
return value;
|
|
}
|
|
}
|