diff --git a/DistrictServerCSharp/.gitignore b/DistrictServerCSharp/.gitignore new file mode 100644 index 0000000..fd75041 --- /dev/null +++ b/DistrictServerCSharp/.gitignore @@ -0,0 +1,14 @@ +bin/ +obj/ +Logs/ +_scratch_verify/ +_scratch_drive/ +_drive_scratch/ +_build_scratch_pub/ +_ds_cs_publish/ +_map_ds.py +_bracecheck.py +_verify_ds.py +# The repo root .gitignore's NuGet `packages/` pattern would otherwise match +# this source folder on case-insensitive filesystems; re-include it. +!Packages/ diff --git a/DistrictServerCSharp/Accounts/Account.cs b/DistrictServerCSharp/Accounts/Account.cs new file mode 100644 index 0000000..691ef32 --- /dev/null +++ b/DistrictServerCSharp/Accounts/Account.cs @@ -0,0 +1,291 @@ +namespace DistrictServerCSharp.Accounts; + +/// +/// Per-account state for one client on the district server, ported from +/// Account.cpp. Every accessor is guarded by a lock because the UDP receive +/// thread and the TCP world-control thread can touch the same account. +/// +public sealed class Account +{ + public enum HandshakeState + { + WaitingForAuth, + ChallengeSent, + Complete + } + + private readonly object _gate = new(); + private readonly uint _id; + private byte[] _authToken = new byte[20]; + private byte[] _encryptionKey = new byte[16]; + private bool _characterProfileAvailable; + private uint _characterId; + private byte _characterFaction; + private byte _characterGender; + private byte _appearanceVersion; + private string _characterName = ""; + private string _clanName = ""; + private byte[] _appearance = Array.Empty(); + private bool _endpointBound; + private uint _endpointAddress; + private ushort _endpointPort; + private bool _authenticated; + private uint _nextServerPacketId; + private ushort _nextServerReliableSequence = 1; + private uint _lastClientPacketId; + private uint _handshakeChallenge; + private HandshakeState _currentHandshakeState = HandshakeState.WaitingForAuth; + private uint _challengeSendCount; + private uint _udpReceiveCount; + + public Account(uint id, byte[] authToken, byte[] encryptionKey) + { + _id = id; + if (authToken.Length >= 20) + Array.Copy(authToken, _authToken, 20); + if (encryptionKey.Length >= 16) + Array.Copy(encryptionKey, _encryptionKey, 16); + } + + public uint GetId() + { + lock (_gate) return _id; + } + + /// + /// Rebinds the account to a fresh connection: new keys, endpoint unbound, + /// handshake and sequence counters reset. This is the hook that lets a + /// relaunched client reconnect without inheriting the previous session's + /// state. + /// + public void UpdateKeys(byte[] authToken, byte[] encryptionKey) + { + lock (_gate) + { + if (authToken.Length >= 20) + Array.Copy(authToken, _authToken, 20); + if (encryptionKey.Length >= 16) + Array.Copy(encryptionKey, _encryptionKey, 16); + _endpointBound = false; + _endpointAddress = 0; + _endpointPort = 0; + _authenticated = false; + _nextServerPacketId = 0; + _nextServerReliableSequence = 1; + _lastClientPacketId = 0; + _handshakeChallenge = 0; + _currentHandshakeState = HandshakeState.WaitingForAuth; + _challengeSendCount = 0; + _udpReceiveCount = 0; + } + } + + public byte[] GetAuthToken() + { + lock (_gate) return (byte[])_authToken.Clone(); + } + + public byte[] GetEncryptionKey() + { + lock (_gate) return (byte[])_encryptionKey.Clone(); + } + + public void SetCharacterProfile( + uint characterId, + byte faction, + byte gender, + byte appearanceVersion, + string characterName, + string clanName, + byte[] appearance) + { + lock (_gate) + { + _characterProfileAvailable = characterId != 0; + _characterId = characterId; + _characterFaction = faction; + _characterGender = gender; + _appearanceVersion = appearanceVersion; + _characterName = characterName; + _clanName = clanName; + _appearance = (byte[])appearance.Clone(); + } + } + + public void ClearCharacterProfile() + { + lock (_gate) + { + _characterProfileAvailable = false; + _characterId = 0; + _characterFaction = 0; + _characterGender = 0; + _appearanceVersion = 0; + _characterName = ""; + _clanName = ""; + _appearance = Array.Empty(); + } + } + + public bool HasCharacterProfile() + { + lock (_gate) return _characterProfileAvailable; + } + + public uint GetCharacterId() + { + lock (_gate) return _characterId; + } + + public byte GetCharacterFaction() + { + lock (_gate) return _characterFaction; + } + + public byte GetCharacterGender() + { + lock (_gate) return _characterGender; + } + + public byte GetAppearanceVersion() + { + lock (_gate) return _appearanceVersion; + } + + public string GetCharacterName() + { + lock (_gate) return _characterName; + } + + public string GetClanName() + { + lock (_gate) return _clanName; + } + + public int GetAppearanceSize() + { + lock (_gate) return _appearance.Length; + } + + public byte[] GetAppearance() + { + lock (_gate) return (byte[])_appearance.Clone(); + } + + public void BindEndpoint(uint addressNetworkOrder, ushort portHostOrder) + { + lock (_gate) + { + _endpointAddress = addressNetworkOrder; + _endpointPort = portHostOrder; + _endpointBound = true; + } + } + + public bool MatchesEndpoint(uint addressNetworkOrder, ushort portHostOrder) + { + lock (_gate) + { + return _endpointBound && + _endpointAddress == addressNetworkOrder && + _endpointPort == portHostOrder; + } + } + + public bool HasEndpoint() + { + lock (_gate) return _endpointBound; + } + + public uint GetEndpointAddress() + { + lock (_gate) return _endpointAddress; + } + + public ushort GetEndpointPort() + { + lock (_gate) return _endpointPort; + } + + public void SetAuthenticated(bool value) + { + lock (_gate) _authenticated = value; + } + + public bool IsAuthenticated() + { + lock (_gate) return _authenticated; + } + + public uint AllocateServerPacketId() + { + lock (_gate) + { + uint value = _nextServerPacketId; + _nextServerPacketId = (_nextServerPacketId + 1u) & 0x3FFFFFFFu; + return value; + } + } + + public ushort AllocateServerReliableSequence() + { + lock (_gate) + { + ushort value = _nextServerReliableSequence; + _nextServerReliableSequence = (ushort)((_nextServerReliableSequence + 1u) % 1024u); + if (_nextServerReliableSequence == 0) + _nextServerReliableSequence = 1; + return value; + } + } + + public void SetLastClientPacketId(uint value) + { + lock (_gate) _lastClientPacketId = value; + } + + public uint GetLastClientPacketId() + { + lock (_gate) return _lastClientPacketId; + } + + public void SetHandshakeChallenge(uint value) + { + lock (_gate) _handshakeChallenge = value; + } + + public uint GetHandshakeChallenge() + { + lock (_gate) return _handshakeChallenge; + } + + public void SetHandshakeState(HandshakeState value) + { + lock (_gate) _currentHandshakeState = value; + } + + public HandshakeState GetHandshakeState() + { + lock (_gate) return _currentHandshakeState; + } + + public uint IncrementChallengeSendCount() + { + lock (_gate) return ++_challengeSendCount; + } + + public uint GetChallengeSendCount() + { + lock (_gate) return _challengeSendCount; + } + + public uint IncrementUdpReceiveCount() + { + lock (_gate) return ++_udpReceiveCount; + } + + public uint GetUdpReceiveCount() + { + lock (_gate) return _udpReceiveCount; + } +} diff --git a/DistrictServerCSharp/Accounts/AccountManager.cs b/DistrictServerCSharp/Accounts/AccountManager.cs new file mode 100644 index 0000000..b64b116 --- /dev/null +++ b/DistrictServerCSharp/Accounts/AccountManager.cs @@ -0,0 +1,79 @@ +using System.Net; +using DistrictServerCSharp.Net; + +namespace DistrictServerCSharp.Accounts; + +/// +/// The district's account registry, ported from the g_accounts list in +/// DistrictServer.cpp. Accounts arrive via the world-server character handoff +/// and are looked up by id (AUTH) or by UDP endpoint. +/// +public static class AccountManager +{ + private static readonly object Gate = new(); + private static readonly List Accounts = new(); + + public static Account? Find(uint id) + { + lock (Gate) + { + foreach (Account account in Accounts) + { + if (account.GetId() == id) + return account; + } + return null; + } + } + + public static Account? FindByEndpoint(IPEndPoint endpoint) + { + uint address = EndpointAddress.ToWireValue(endpoint.Address); + ushort port = (ushort)endpoint.Port; + + lock (Gate) + { + foreach (Account account in Accounts) + { + if (account.MatchesEndpoint(address, port)) + return account; + } + return null; + } + } + + /// All currently registered accounts (for remote-pawn viewers). + public static List GetAll() + { + lock (Gate) + { + return new List(Accounts); + } + } + + /// + /// Finds the account by id, rebinding its keys for a reconnect, or creates + /// a fresh one. reports whether an existing + /// account was rebound. + /// + public static Account AddOrUpdate(uint id, byte[] authToken, byte[] encryptionKey, out bool replaced) + { + lock (Gate) + { + foreach (Account account in Accounts) + { + if (account.GetId() == id) + { + account.UpdateKeys(authToken, encryptionKey); + replaced = true; + return account; + } + } + + var created = new Account(id, authToken, encryptionKey); + Accounts.Add(created); + replaced = false; + return created; + } + } +} diff --git a/DistrictServerCSharp/Bits/BitReader.cs b/DistrictServerCSharp/Bits/BitReader.cs new file mode 100644 index 0000000..adadf24 --- /dev/null +++ b/DistrictServerCSharp/Bits/BitReader.cs @@ -0,0 +1,170 @@ +namespace DistrictServerCSharp.Bits; + +/// +/// LSB-first bit reader, ported from the C++ BitReader in ApbUdp.cpp. Reads +/// over a byte buffer between a begin and end bit position. +/// +public sealed class BitReader +{ + private readonly byte[] _data; + private int _position; + private readonly int _end; + + public BitReader(byte[] data, int beginBit, int endBit) + { + _data = data; + _position = beginBit; + _end = endBit; + } + + public int Tell => _position; + + public int Remaining => _position <= _end ? _end - _position : 0; + + public bool ReadBit(out bool value) + { + if (!ReadBits(1, out uint temporary)) + { + value = false; + return false; + } + + value = temporary != 0; + return true; + } + + public bool ReadBits(int count, out uint value) + { + value = 0; + + if (count > 32 || count > Remaining) + return false; + + for (int index = 0; index < count; ++index) + { + int absolute = _position + index; + uint bit = (uint)((_data[absolute / 8] >> (absolute % 8)) & 1); + + value |= bit << index; + } + + _position += count; + return true; + } + + public bool ReadByte(out byte value) + { + if (!ReadBits(8, out uint temporary)) + { + value = 0; + return false; + } + + value = (byte)temporary; + return true; + } + + /// + /// UE3's FBitReader::ReadInt(ValueMax): a variable-length encoding that + /// reads bits least-significant first and stops as soon as the accumulated + /// value plus the next mask would reach ValueMax. + /// + public bool ReadBoundedInt(uint valueMax, out uint value) + { + value = 0; + + if (valueMax <= 1) + return true; + + for (uint mask = 1; + value + mask < valueMax; + mask <<= 1) + { + if (!ReadBit(out bool bit)) + return false; + + if (bit) + value |= mask; + + if ((mask & 0x80000000u) != 0) + break; + } + + return true; + } + + /// + /// Reads a UE3 FString: a 32-bit length (positive = ANSI byte count + /// including terminator, negative = UTF-16 code-unit count) followed by the + /// characters and a trailing null. + /// + public bool ReadFString(out string value) + { + value = string.Empty; + + if (!ReadBits(32, out uint rawLength)) + return false; + + int length = (int)rawLength; + + if (length == 0) + return true; + + if (length > 0) + { + if (length > 65536 || + (long)length * 8 > Remaining) + { + return false; + } + + var builder = new System.Text.StringBuilder(length); + + for (int index = 0; index < length; ++index) + { + if (!ReadByte(out byte character)) + return false; + + if (character != 0) + builder.Append((char)character); + } + + value = builder.ToString(); + return true; + } + + long wideCount = -(long)length; + + if (wideCount <= 0 || + wideCount > 32768 || + wideCount * 16 > Remaining) + { + return false; + } + + var wideBuilder = new System.Text.StringBuilder((int)wideCount); + + for (long index = 0; index < wideCount; ++index) + { + if (!ReadBits(16, out uint character)) + return false; + + if (character == 0) + continue; + + wideBuilder.Append(character <= 0x7Fu ? (char)character : '?'); + } + + value = wideBuilder.ToString(); + return true; + } + + public bool Skip(int count) + { + if (count > Remaining) + return false; + + _position += count; + return true; + } +} diff --git a/DistrictServerCSharp/Bits/BitWriter.cs b/DistrictServerCSharp/Bits/BitWriter.cs new file mode 100644 index 0000000..ab1c456 --- /dev/null +++ b/DistrictServerCSharp/Bits/BitWriter.cs @@ -0,0 +1,226 @@ +namespace DistrictServerCSharp.Bits; + +/// +/// LSB-first bit writer, ported from the C++ BitWriter in ApbUdp.cpp. Builds a +/// growable byte buffer; call to append the +/// UE3 packet trailer bit and get the final bytes. +/// +public sealed class BitWriter +{ + private readonly List _data = new(); + private int _writtenBits; + + public void WriteBit(bool value) + { + int byteIndex = _writtenBits / 8; + int bitIndex = _writtenBits % 8; + + if (byteIndex >= _data.Count) + _data.Add(0); + + if (value) + _data[byteIndex] |= (byte)(1u << bitIndex); + + ++_writtenBits; + } + + public void WriteBits(uint value, int count) + { + for (int index = 0; index < count; ++index) + WriteBit(((value >> index) & 1u) != 0); + } + + public void WriteBytes(ReadOnlySpan value) + { + foreach (byte b in value) + WriteBits(b, 8); + } + + /// + /// UE3's FBitWriter::SerializeInt(Value, ValueMax): a variable-length + /// encoding that emits bits least-significant first and stops as soon as + /// the accumulated value plus the next mask would reach ValueMax. This is + /// the exact counterpart of , and it + /// is how channel indices (max 0x3FF), packet ids (max 0x40000000) and + /// package-map net indices (max 0x80000000) go on the wire. + /// + public void WriteBoundedInt(uint value, uint valueMax) + { + uint accumulated = 0; + + for (uint mask = 1; + accumulated + mask < valueMax; + mask <<= 1) + { + bool bit = (value & mask) != 0; + if (bit) + accumulated |= mask; + WriteBit(bit); + + if ((mask & 0x80000000u) != 0) + break; + } + } + + /// + /// FVector::SerializeCompressed, recovered from FUN_10DCC5E0: + /// bits = bitlength(max(|X|,|Y|,|Z|)) clamped to [1,20] + /// SerializeInt(bits - 1, 20) + /// Bias = 1 << bits; Max = 1 << (bits + 1) + /// SerializeInt(X + Bias, Max), then Y, then Z + /// On load the client computes X = DX - Bias, which is why a bunch that + /// ends before this data decodes to (-2,-2,-2): it reads bits-1 = 0, + /// giving Bias = 2 and DX = 0. + /// + public void WriteCompressedVector(float x, float y, float z) + { + int ix = (int)(x < 0 ? x - 0.5f : x + 0.5f); + int iy = (int)(y < 0 ? y - 0.5f : y + 0.5f); + int iz = (int)(z < 0 ? z - 0.5f : z + 0.5f); + + uint largest = (uint)Math.Abs(ix); + largest = Math.Max(largest, (uint)Math.Abs(iy)); + largest = Math.Max(largest, (uint)Math.Abs(iz)); + + uint bits = 0; + while (largest >> (int)bits != 0) + ++bits; + if (bits < 1) bits = 1; + if (bits > 20) bits = 20; + + WriteBoundedInt(bits - 1, 20); + + uint bias = 1u << (int)bits; + uint maximum = 1u << (int)(bits + 1); + + WriteBoundedInt((uint)(ix + (int)bias), maximum); + WriteBoundedInt((uint)(iy + (int)bias), maximum); + WriteBoundedInt((uint)(iz + (int)bias), maximum); + } + + /// + /// FRotator::SerializeCompressed, recovered from the client's rotation + /// reader. Each axis is reduced to its high byte (value >> 8) and written + /// as a presence bit plus 8 bits only when non-zero. This is NOT the + /// width-field format FVector uses; a pure-yaw rotation writes only the + /// yaw byte (pitch/roll each write a single 0 bit). + /// + /// PORT-ORIGINAL: the C++ oracle has no rotator writer at all (only + /// WriteCompressedVector, ApbUdp.cpp:262; its sole rotation touch is + /// an AimRotation int READ at ApbUdp.cpp:2410). So the encoding is + /// client-RE-derived, hand-checked only (yaw 16384 -> high byte 0x40 -> + /// 16384), and has neither a C++ counterpart to diff against nor a reader + /// on either side to round-trip through. Not yet exercised against a live + /// client. + /// + public void WriteCompressedRotator(int pitch, int yaw, int roll) + { + byte bytePitch = (byte)(((uint)pitch & 0xFFFFu) >> 8); + byte byteYaw = (byte)(((uint)yaw & 0xFFFFu) >> 8); + byte byteRoll = (byte)(((uint)roll & 0xFFFFu) >> 8); + + WriteBit(bytePitch != 0); + if (bytePitch != 0) + WriteBits(bytePitch, 8); + + WriteBit(byteYaw != 0); + if (byteYaw != 0) + WriteBits(byteYaw, 8); + + WriteBit(byteRoll != 0); + if (byteRoll != 0) + WriteBits(byteRoll, 8); + } + + /// + /// FString as UE3 serialises it: a 32-bit length (including the + /// terminator) followed by the characters and a trailing null. Same + /// encoding the text control messages already use. + /// + public void WriteFString(string text, bool nullTerminated = true) + { + uint length = (uint)(text.Length + (nullTerminated ? 1 : 0)); + WriteBits(length, 32); + foreach (char character in text) + WriteBits(character, 8); + if (nullTerminated) + WriteBits(0, 8); + } + + /// + /// UPackageMap::SerializeName first serializes a one-bit selector. For + /// non-hardcoded names the selector is zero and the name follows as an + /// FString. The previous implementation omitted this bit, so the low bit + /// of the FString length became the selector. For the terrain name the + /// serialized length is 55 (odd), which shifted the remaining payload and + /// decoded as: + /// FName(Index=76533, Number=405772312), bools=(0,0,0) + /// instead of the existing terrain FName and (1,1,0). + /// + /// APB's network reader derives the FName Number from the string when + /// using this path; do not append a raw 32-bit number unless explicitly + /// testing an alternate build. + /// + public void WriteName( + string text, + uint number = 0, + bool includeNumber = false, + bool nullTerminated = true) + { + // v0.5 proved false decodes as NAME_None and consumes no FString. + // True selects the FString representation. + WriteBit(true); // FString name follows + WriteFString(text, nullTerminated); + if (includeNumber) + WriteBits(number, 32); + } + + /// + /// UPackageMapLevel::SerializeObject. An object reference is one flag bit + /// followed by a bounded int: + /// flag = 0 -> package map reference, SerializeInt(NetIndex, 0x80000000) + /// flag = 1 -> channel reference, SerializeInt(ChIndex, 0x3FF) + /// Classes and other loaded assets use the package-map form; actors that + /// already have an open channel use the channel form. + /// + public void WriteObjectByNetIndex(uint netIndex) + { + WriteBit(false); + WriteBoundedInt(netIndex, 0x80000000u); + } + + public void WriteObjectByChannel(uint channelIndex) + { + WriteBit(true); + WriteBoundedInt(channelIndex, 0x3FFu); + } + + public byte[] FinishWithTrailer() + { + WriteBit(true); + return _data.ToArray(); + } + + /// Number of bits written so far (no trailer). + public int BitCount => _writtenBits; + + /// Raw bytes written so far, without adding a terminator. + public byte[] Snapshot() => _data.ToArray(); + + /// + /// Appends bits taken from , + /// least-significant bit of each byte first, matching how + /// lays bits out. + /// + public void WriteBitsFrom(ReadOnlySpan source, int count) + { + for (int index = 0; index < count; ++index) + { + int byteIndex = index / 8; + int bitIndex = index % 8; + if (byteIndex >= source.Length) + break; + WriteBit(((source[byteIndex] >> bitIndex) & 1u) != 0); + } + } +} diff --git a/DistrictServerCSharp/Bits/RWBits.cs b/DistrictServerCSharp/Bits/RWBits.cs new file mode 100644 index 0000000..53ca2fb --- /dev/null +++ b/DistrictServerCSharp/Bits/RWBits.cs @@ -0,0 +1,67 @@ +namespace DistrictServerCSharp.Bits; + +/// +/// Classic UE3 bit helpers carried over from the C++ DistrictServer +/// (RWBits.h). extracts a value from a byte buffer at a +/// bit offset; stores a value and returns the new bit +/// position. Both are LSB-first. +/// +public static class RWBits +{ + /// Reads a value from at the given bit position. + /// How many bits to read (1..32). + /// Buffer to read from. + /// Position in the buffer, in bits. + /// The decoded value, or 0 if is out of range. + public static uint ReadBits(uint bits, byte[] input, uint inputBits) + { + uint seekBits, rem, seek = 0, ret = 0, mask = 0xFFFFFFFF; + + if (bits > 32) return 0; + if (bits < 32) mask = (1u << (int)bits) - 1; + + for (;;) + { + seekBits = inputBits & 7; + ret |= ((uint)(input[inputBits >> 3] >> (int)seekBits) & mask) << (int)seek; + rem = 8 - seekBits; + if (rem >= bits) break; + bits -= rem; + inputBits += rem; + seek += rem; + mask = (1u << (int)bits) - 1; + } + + return ret; + } + + /// Stores a value into at the given bit position. + /// Number to store. + /// How many bits it occupies (1..32). + /// Buffer to write into. + /// Position in the buffer, in bits. + /// The bit position where the stored number finishes. + public static uint WriteBits(uint data, uint bits, byte[] output, uint outputBits) + { + uint seekBits, rem, mask; + + if (bits > 32) return outputBits; + if (bits < 32) data &= (1u << (int)bits) - 1; + + for (;;) + { + seekBits = outputBits & 7; + mask = (1u << (int)seekBits) - 1; + if ((bits + seekBits) < 8) mask |= ~(((1u << (int)bits) << (int)seekBits) - 1); + output[outputBits >> 3] &= (byte)mask; // zero + output[outputBits >> 3] |= (byte)(data << (int)seekBits); + rem = 8 - seekBits; + if (rem >= bits) break; + outputBits += rem; + bits -= rem; + data >>= (int)rem; + } + + return outputBits + bits; + } +} diff --git a/DistrictServerCSharp/Config/DistrictConfig.cs b/DistrictServerCSharp/Config/DistrictConfig.cs new file mode 100644 index 0000000..67b67dc --- /dev/null +++ b/DistrictServerCSharp/Config/DistrictConfig.cs @@ -0,0 +1,835 @@ +using System.Globalization; +using System.Text; + +namespace DistrictServerCSharp.Config; + +public enum AckMode +{ + None, + Plain, + XteaLittle, + XteaBig, + XteaCbcLittle, + XteaCbcBig +} + +public enum ChallengeMode +{ + AckOnly, + BinaryCombined, + BinarySeparate, + TextCombined, + // Matches the retail district server: RTW replaced the whole UE3 login + // handshake with a single round trip. Its AUTH handler does no validation + // and no USES negotiation -- it copies the master package map onto the + // connection, marks every package as already present and immediately + // replies "WELCOME LEVEL=". No CHALLENGE is ever sent. + WelcomeDirect +} + +public enum DistrictMap +{ + Social, + Financial, + Waterfront +} + +public sealed record UsesPackage(string Name, string Guid, int Generation, uint Flags, uint NetObjectCount); + +public sealed record StreamingPlanEntry(string PackageName, bool ShouldBeLoaded, bool ShouldBeVisible, bool ShouldBlockOnLoad); + +/// +/// HandshakeProbe.ini / environment-variable configuration, ported from the +/// anonymous-namespace helpers at the top of DistrictServer.cpp. Environment +/// variables win over the INI, matching the C++ precedence. +/// +public static class DistrictConfig +{ + public const byte NmtHandshakeStart = 26; + public const byte NmtHandshakeChallenge = 27; + public const byte NmtHandshakeResponse = 28; + public const byte NmtHandshakeComplete = 29; + + // ------------------------------------------------------------------ + // Wire field constants (build 3908 live class net cache). See the C++ + // comments in DistrictServer.cpp 2749..2983 for provenance. + // ------------------------------------------------------------------ + public const uint PlayerControllerFieldMax = 634; + public const uint FieldControllerPlayerReplicationInfo = 21; + public const uint FieldPawn = 22; + public const uint FieldGivePawn = 39; + public const uint FieldClientRestart = 74; + public const uint FieldClientSetViewTarget = 76; + public const uint FieldControllerDead = 185; + public const uint FieldServerRequestCustomisation = 277; + public const uint FieldClientPrecacheCustomisation = 279; + public const uint FieldServerRequestCharacterData = 489; + public const uint FieldServerRequestCharacterStats = 491; + public const uint FieldServerRequestCharacterRolesData = 498; + public const uint FieldClientGotoState = 37; + public const uint FieldClientIgnoreMoveInput = 82; + public const uint FieldDualServerMove = 53; + public const uint FieldOldServerMove = 54; + public const uint FieldServerMove = 55; + public const uint FieldClientAckGoodMove = 59; + public const uint FieldAnsDistrictEnter = 133; + public const uint FieldUpdateLevelStreaming = 89; + public const uint FieldFlushLevelStreaming = 93; + public const uint FieldClientSetHud = 41; + public const uint FieldClientSetInitialStateFallback = 538; + public const uint FieldClientGoToSpawnZoneSelectScreenFallback = 369; + public const uint FieldServerSelectSpawnZoneFallback = 371; + public const uint FieldServerNotifyClientLoadedFallback = 372; + public const uint FieldClientReplicateHudMarkerFallback = 392; + public const uint FieldClientDeleteHudMarkerFallback = 394; + public const uint FieldControllerHoldableItemManager = 164; + public const uint FieldControllerInventory = 165; + public const uint FieldHoldableOwningPawn = 30; + public const uint HoldableItemManagerFieldMax = 31; + public const uint StorageInventoryFieldMax = 3594; + public const uint FieldLocation = 6; + public const uint FieldVelocity = 4; + public const uint FieldRotation = 5; + public const uint FieldKnownFixedWidthUpdate = 78; + public const uint FieldKnownPostStreamUpdate = 484; + public const uint FieldServerUpdateLevelVisibility = 90; + public const uint FieldAskDistrictEnter = 132; + public const uint FieldToggleWantsToCrouchServer = 194; + + // Channels + public const ushort ControllerChannel = 2; + public const ushort GriChannel = 3; + public const ushort PawnChannel = 4; + public const ushort SpawnZoneActorChannel = 5; + public const ushort CustomisationReplicatorChannel = 7; + public const ushort CustomisationReplicatorChannelStride = 2; + public const ushort PlayerReplicationInfoChannel = 8; + + // Remote-pawn replication (multiplayer visibility). Each (viewer, target) + // pair gets two actor channels on the VIEWER's connection starting here. + public const ushort RemotePawnChannelBase = 30; + public const ushort HoldableItemManagerChannelFallback = 20; + public const ushort StorageInventoryChannelFallback = 21; + + // Archetype object indices inside APBGame (APBGame begins at global + // package-map index 31103). + public const uint GriArchetypeObjectIndex = 12676; + public const uint PawnArchetypeObjectIndex = 3827; + public const uint HudClassObjectIndex = 20839; + public const uint PlayerReplicationInfoArchetypeObjectIndex = 12826; + public const uint CustomisationReplicatorArchetypeObjectIndex = 14630; + + // Default__cAPBPlayerController lives in APBGame at object index 12426. + public const uint ControllerArchetypeObjectIndex = 12426; + + public const uint GriFieldMaxFallback = 57; + public const uint FieldGriMatchHasBegunFallback = 23; + public const uint ClientReceiveCharacterInfoFallback = 487; + public const uint ClientReceiveCharacterDataFallback = 490; + public const uint ClientReceiveCharacterStatsFallback = 492; + public const uint ClientReceiveCharacterRolesDataFallback = 499; + + public const uint EnforcerSpawnZoneLocalNetIndex = 182u; + public const uint CriminalSpawnZoneLocalNetIndex = 183u; + + public const uint CustomisationReplicatorFieldMax = 25; + public const uint CustomisationDataPacketSize = 256u; + public const uint FieldReplicatorOwner = 9; + public const uint FieldReplicatorNetOwner = 13; + public const uint FieldReplicatorServerSendData = 21; + public const uint FieldReplicatorClientReceiveData = 22; + public const uint FieldReplicatorClientNotifyTransferComplete = 23; + public const uint FieldReplicatorServerNotifyOperationComplete = 24; + + public const uint PawnFieldMax = 110; + public const uint FieldPawnPlayerReplicationInfo = 25; + public const uint FieldPawnController = 42; + public const uint FieldPawnControllerCharacterUid = 62; + public const uint FieldPawnCustomisationGuids = 68; + public const uint FieldPawnGender = 87; + public const uint FieldPawnFaction = 88; + public const uint ReflectedFieldPawnIsWinded = 81; + public const uint DefaultFieldPawnIsWinded = 95; + + // ------------------------------------------------------------------ + // Configuration state (set once at startup, read everywhere). + // ------------------------------------------------------------------ + public static AckMode AckModeValue { get; set; } = AckMode.Plain; + public static ChallengeMode ChallengeModeValue { get; set; } = ChallengeMode.BinaryCombined; + public static uint FixedChallenge { get; set; } + + public static string AckModeName(AckMode mode) => mode switch + { + AckMode.None => "none", + AckMode.Plain => "plain", + AckMode.XteaLittle => "xtea-le", + AckMode.XteaBig => "xtea-be", + AckMode.XteaCbcLittle => "xtea-cbc-le", + AckMode.XteaCbcBig => "xtea-cbc-be", + _ => "unknown" + }; + + public static string ChallengeModeName(ChallengeMode mode) => mode switch + { + ChallengeMode.AckOnly => "ack-only", + ChallengeMode.BinaryCombined => "binary-combined", + ChallengeMode.BinarySeparate => "binary-separate", + ChallengeMode.WelcomeDirect => "welcome-direct", + ChallengeMode.TextCombined => "text-combined", + _ => "unknown" + }; + + // ------------------------------------------------------------------ + // INI / environment access + // ------------------------------------------------------------------ + + /// Path of HandshakeProbe.ini next to the executable. + public static string GetHandshakeConfigPath() + { + string? executablePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(executablePath)) + return "HandshakeProbe.ini"; + + string? directory = Path.GetDirectoryName(executablePath); + return Path.Combine(directory ?? ".", "HandshakeProbe.ini"); + } + + private static readonly Dictionary> IniCache = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Reads a [Handshake] key from HandshakeProbe.ini, falling back to + /// . The INI is cached per boot. + /// + private static string ReadIni(string key, string fallback) + { + string path = GetHandshakeConfigPath(); + + if (!IniCache.TryGetValue(path, out var section)) + { + section = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + if (File.Exists(path)) + { + bool inHandshake = false; + foreach (string rawLine in File.ReadAllLines(path)) + { + string line = rawLine.Trim(); + if (line.StartsWith('[') && line.EndsWith(']')) + { + inHandshake = line.Equals("[Handshake]", StringComparison.OrdinalIgnoreCase); + continue; + } + if (!inHandshake || line.Length == 0 || line.StartsWith(';') || line.StartsWith('#')) + continue; + int eq = line.IndexOf('='); + if (eq < 0) + continue; + string name = line[..eq].Trim(); + string value = line[(eq + 1)..].Trim(); + section[name] = value; + } + } + } + catch (IOException) + { + // Best effort. + } + IniCache[path] = section; + } + + return section.TryGetValue(key, out string? cachedValue) ? cachedValue : fallback; + } + + /// + /// Reads a setting: environment variable first, then the [Handshake] INI + /// key, then . + /// + public static string ReadSetting(string environmentName, string iniKey, string fallback) + { + string? environmentValue = Environment.GetEnvironmentVariable(environmentName); + if (!string.IsNullOrEmpty(environmentValue)) + return environmentValue; + + return ReadIni(iniKey, fallback); + } + + public static int ReadInt(string environmentName, string iniKey, int fallback, int minimum, int maximum) + { + string configured = ReadSetting(environmentName, iniKey, fallback.ToString(CultureInfo.InvariantCulture)); + + if (!int.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) + return fallback; + + return Math.Clamp(parsed, minimum, maximum); + } + + public static bool ReadBool(string environmentName, string iniKey, bool fallback) + { + string configured = ReadSetting(environmentName, iniKey, fallback ? "1" : "0").ToLowerInvariant(); + + return configured == "1" || configured == "true" || configured == "yes" || configured == "on"; + } + + private static string Lower(string value) => value.ToLowerInvariant(); + + /// + /// Per-packet binary capture gate (APB_CAPTURE_PACKETS / CapturePackets), + /// ported from PacketCaptureEnabled() in DistrictServer.cpp. OFF by + /// default: at the remote-pawn push rate (~90 packets/sec with two + /// players) writing one .bin per packet is a file-create storm. + /// + public static bool PacketCaptureEnabled() + => ReadBool("APB_CAPTURE_PACKETS", "CapturePackets", false); + + // ------------------------------------------------------------------ + // District identity + // ------------------------------------------------------------------ + + public static int ConfiguredDistrictId() => ReadInt("APB_DISTRICT_ID", "DistrictId", 1, 1, 9); + + public static int ConfiguredDistrictLanguage() => ReadInt("APB_DISTRICT_LANGUAGE", "DistrictLanguage", 0, 0, 9); + + public static int ConfiguredDistrictUdpPort() => ReadInt("APB_DISTRICT_UDP_PORT", "DistrictUdpPort", 6969, 1, 65535); + + public static string ConfiguredWorldServerAddress() => ReadSetting("APB_WORLD_SERVER_ADDRESS", "WorldServerAddress", "127.0.0.1"); + + public static int ConfiguredWorldServerPort() => ReadInt("APB_WORLD_SERVER_PORT", "WorldServerPort", 2108, 1, 65535); + + public static DistrictMap GetConfiguredDistrictMap() + { + string configured = Lower(ReadSetting("APB_DISTRICT_MAP", "DistrictMap", "social")); + + if (configured == "financial") + return DistrictMap.Financial; + if (configured == "waterfront") + return DistrictMap.Waterfront; + + return DistrictMap.Social; + } + + public static string DistrictMapName(DistrictMap map) => map switch + { + DistrictMap.Financial => "financial", + DistrictMap.Waterfront => "waterfront", + _ => "social" + }; + + public static string DistrictWelcomeLevel(DistrictMap map) => map switch + { + DistrictMap.Financial => "financialdistrict_master", + DistrictMap.Waterfront => "waterfrontdistrict_master", + _ => "rworldsocialdistrict_master" + }; + + /// Values must match WorldServer.Districts.DistrictTypes exactly. + public static int DistrictType(DistrictMap map) => map switch + { + DistrictMap.Financial => 2, + DistrictMap.Waterfront => 21, + _ => 1 + }; + + public static bool IsActionDistrict(DistrictMap map) => map is DistrictMap.Financial or DistrictMap.Waterfront; + + private static readonly float[][] FinancialSpawnDirections = + { + new[] { 132272.0f, 147040.0f, -309.0f }, new[] { 156080.0f, 109424.0f, 174.0f }, + new[] { 178388.671875f, 116560.390625f, -436.0f }, new[] { 177296.0f, 151200.0f, 70.0f }, + new[] { 140080.0f, 85728.0f, 55.0f }, new[] { 109984.0f, 116976.0f, 127.0f }, + new[] { 145680.0f, 159440.0f, 53.0f }, new[] { 119600.0f, 175792.0f, 104.0f }, + new[] { 96400.0f, 153408.0f, 73.0f }, new[] { 87046.0f, 122320.0f, -599.0f }, + new[] { 106416.0f, 79856.0f, -356.0f }, new[] { 83328.0f, 85632.0f, -370.0f } + }; + + private static readonly float[][] WaterfrontSpawnDirections = + { + new[] { 153991.515625f, 153226.953125f, -71.640625f }, + new[] { 141546.359375f, 151233.453125f, 115.513573f }, + new[] { 139401.250000f, 174139.859375f, 560.000000f }, + new[] { 157084.515625f, 191974.093750f, 164.000000f }, + new[] { 180265.453125f, 194094.125000f, 400.000000f }, + new[] { 118356.828125f, 149498.750000f, 456.962189f }, + new[] { 120761.632812f, 138693.656250f, 823.237183f }, + new[] { 121693.109375f, 117800.804688f, -459.318970f }, + new[] { 110317.710938f, 110141.164062f, 204.023132f }, + new[] { 124334.117188f, 79278.312500f, 90.914635f }, + new[] { 108768.539062f, 59787.183594f, 607.129456f }, + new[] { 86740.882812f, 67946.921875f, 485.603271f } + }; + + public static bool TryGetActionSpawnDirection(DistrictMap map, int index, out float x, out float y, out float z) + { + if (!IsActionDistrict(map) || index >= 12) + { + x = y = z = 0; + return false; + } + + float[] locations = map == DistrictMap.Waterfront ? WaterfrontSpawnDirections[index] : FinancialSpawnDirections[index]; + x = locations[0]; + y = locations[1]; + z = locations[2]; + return true; + } + + // Social district: the two cPlayerCharacterSpawnDirection actors in the + // cooked Design map. Enforcer zone _2 (zone index 0) and Criminal zone _3 + // (zone index 1); identical to the coordinates the HUD spawn-zone markers + // advertise on the map-select screen, so the pawn spawn is always a + // location the client already sees as valid. + private static readonly float[][] SocialSpawnLocations = + { + new[] { 33472.0f, 37488.0f, 208.0f }, + new[] { 33376.0f, 37312.0f, 208.0f } + }; + + /// + /// Resolves a selected spawn-zone index to a world location. Ported from + /// TryGetSpawnZoneLocation (DistrictServer.cpp:397-424): action districts + /// delegate to the captured spawn directions, Social uses the two cooked + /// spawn-direction actors. + /// + public static bool TryGetSpawnZoneLocation(DistrictMap map, int zoneIndex, out float x, out float y, out float z) + { + if (IsActionDistrict(map)) + return TryGetActionSpawnDirection(map, zoneIndex, out x, out y, out z); + + if (zoneIndex < 0 || zoneIndex >= 2) + { + x = y = z = 0; + return false; + } + + x = SocialSpawnLocations[zoneIndex][0]; + y = SocialSpawnLocations[zoneIndex][1]; + z = SocialSpawnLocations[zoneIndex][2]; + return true; + } + + // ------------------------------------------------------------------ + // Ack / challenge modes + // ------------------------------------------------------------------ + + public static AckMode ReadAckMode() + { + string configured = Lower(ReadSetting("APB_ACK_MODE", "AckMode", "plain")); + + if (configured.Length == 0 || configured == "plain") + return AckMode.Plain; + if (configured == "none" || configured == "off") + return AckMode.None; + if (configured == "xtea-le" || configured == "xtea_little") + return AckMode.XteaLittle; + if (configured == "xtea-be" || configured == "xtea_big") + return AckMode.XteaBig; + if (configured == "xtea-cbc-le" || configured == "cbc-le") + return AckMode.XteaCbcLittle; + if (configured == "xtea-cbc-be" || configured == "cbc-be") + return AckMode.XteaCbcBig; + + Logging.DistrictLogger.Log(Logging.LogLevel.Warn, "HandshakeConfig", "Unknown APB_ACK_MODE='{0}'; using plain.", configured); + return AckMode.Plain; + } + + public static ChallengeMode ReadChallengeMode() + { + string configured = Lower(ReadSetting("APB_CHALLENGE_MODE", "ChallengeMode", "binary-combined")); + + if (configured.Length == 0 || configured == "binary" || configured == "binary-combined") + return ChallengeMode.BinaryCombined; + if (configured == "ack" || configured == "ack-only" || configured == "none") + return ChallengeMode.AckOnly; + if (configured == "binary-separate") + return ChallengeMode.BinarySeparate; + if (configured == "text" || configured == "text-combined") + return ChallengeMode.TextCombined; + if (configured == "welcome-direct" || configured == "welcome" || configured == "retail") + return ChallengeMode.WelcomeDirect; + + Logging.DistrictLogger.Log(Logging.LogLevel.Warn, "HandshakeConfig", "Unknown APB_CHALLENGE_MODE='{0}'; using binary-combined.", configured); + return ChallengeMode.BinaryCombined; + } + + public static uint ReadFixedChallenge() + { + string configured = ReadSetting("APB_CHALLENGE_VALUE", "ChallengeValue", "0x12345678"); + + if (configured.Length == 0) + return 0; + + if (configured.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && + uint.TryParse(configured[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint hexValue)) + { + return hexValue; + } + + if (uint.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint decimalValue)) + return decimalValue; + + Logging.DistrictLogger.Log(Logging.LogLevel.Warn, "HandshakeConfig", "Invalid APB_CHALLENGE_VALUE='{0}'; using generated values.", configured); + return 0; + } + + /// Deterministic per-account challenge, mirroring GenerateChallenge. + public static uint GenerateChallenge(uint accountId) + { + if (FixedChallenge != 0) + return FixedChallenge; + + uint value = (uint)Environment.TickCount; + + value ^= (uint)Environment.ProcessId << 16; + value ^= accountId * 0x45D9F3Bu; + value ^= 0x895FCF62u; + + if (value == 0) + value = 0x4A6F7921u; + + return value; + } + + // ------------------------------------------------------------------ + // USES package map (order fixes every net index base) + // ------------------------------------------------------------------ + + public static readonly UsesPackage[] UsesPackages = + { + // base engine packages -- order fixed, these set the net index + // bases the actor archetypes rely on (APBGame @ 31103) + new("Core", "64E4564377FD4719BBDEA11C3BD45D91", 2, 0, 1389), + new("Engine", "8EE3CA0F4CB7449BB88DFB58E7F8BDB1", 2, 0, 29714), + new("APBGame", "4CBD618E6E3142108E8772E25C54FA9B", 2, 0, 30874), + + // district content. Appended after APBGame so the bases above + // are unchanged and Default__cAPBPlayerController stays at 43529. + new("RWorldSocialDistrict_MASTER", "89110187E3E640F69F01A6E7E623AC64", 1, 0, 57), + new("apbskydome", "7B72FDE904F846848DBD44DBA1AD4D8A", 1, 0, 392), + new("apbskydome-lightfunction", "8D341C75E01045F385465073DBC01FB5", 1, 0, 33), + new("Weather", "70932DB4EB7041709269A116C6517AF2", 1, 0, 8), + new("apbskydome-testassets", "41971CF765BE4A1DA0711F4B76CE4561", 1, 0, 182), + new("rworldsocialdistrict_artprops_blockout", "B47A36BF63824D1BAB895A5BCACF35EC", 1, 0, 9080), + new("rworld_skateramps", "4659850979CE4DAEAF00E4658970B65C", 1, 0, 22), + new("rworld_searchlight", "8C5A0147D8F048C69D514DD3A7C55FF3", 1, 0, 65), + new("rworld_blimp", "C1030DF8CC814ECAAF4883794F9F9148", 1, 0, 84), + new("rworld_exteriorstatuepoint", "3E1FF6B5896B4B98849CD9F1F955437D", 1, 0, 30), + new("rworld_djmixingdecks", "4A65FA2CF70B4AB5A4D97C5FDDC77CFA", 1, 0, 125), + new("rworld_parklight", "B24845813110438FA87D66AFAFFB89E4", 1, 0, 79), + new("Terrain", "7DDF841E2E184D1481395445A2DB2C4B", 1, 0, 449), + new("rworldsocialdistrict_tile_000_000_block_250_000terrain", "8DD60FC520FF45049E40ECBEA4FE3E99", 1, 0, 124), + new("rworldsocialdistrict_tile_000_000_block_250_000terrain_package", "D687BFE4F0E64147A446EA4797F9986E", 1, 0, 24), + new("rworldsocialdistrict_design", "661E450687FE469CA727C0B9FF59986C", 1, 0, 666), + new("rworld_social_interactive_pods", "5D8DDC850A5B4D7BBB293E15F613D499", 1, 0, 401), + new("anim_social_contact", "FE0FADB6B2A1427D8DC1024BEADF37F4", 1, 0, 127), + new("rworldsocialdistrict_block01", "C8D29894C13841B1AF95A7E60D1408B1", 1, 0, 1205), + new("rworld_showroom_props", "0578999EBB924B7C9D20EA707CF96755", 1, 0, 692), + new("rworldsocialdistrict_rworldsocialdistrict_block01_package", "A31B73B8F522439B93B8AB5E2DAD8282", 1, 0, 219), + new("rworldsocialdistrict_block02", "97CD9B950BFD4E1694F775C0A14F7F6D", 1, 0, 1062), + new("rworldsocialdistrict_rworldsocialdistrict_block02_package", "F28718AC8D384344BE6AF0AF55B4704F", 1, 0, 214), + new("rworldsocialdistrict_block03", "DFAC5D43DCA142F0B29CE30DF059B4A2", 1, 0, 622), + new("rworld_nightclubtables", "B40F4D2311534148ACB05E22BD2D15E6", 1, 0, 153), + new("rworld_bar_props", "4FCFE058C0C541D0976D6ED25272A90B", 1, 0, 341), + new("rworldsocialdistrict_rworldsocialdistrict_block03_package", "8046DE28B5FC4D5C87B79A8D8281765A", 1, 0, 117), + new("rworldsocialdistrict_block04", "B4573481BCA64D1A8145B8F6ED97C213", 1, 0, 1530), + new("rworldsocialdistrict_rworldsocialdistrict_block04_package", "7A66FDFC03D54061BF1E821CA156C6ED", 1, 0, 402), + new("rworldsocialdistrict_props_block01", "6A4A2CC30BB34D0198E3512F0DC92C63", 1, 0, 2857), + new("rworld_speakers", "91BD3E07E9814CFAA776E72689983901", 1, 0, 53), + new("rworld_jukebox", "5B9BA851011342F79DC747296C996486", 1, 0, 75), + new("rworld_doors", "B05C72E40CAC467C8FAAC2360D8E93DE", 1, 0, 83), + new("rworld_drinksfridge", "52EFE4EE8A8B4B208D4DE29594554728", 1, 0, 40), + new("rworldsocialdistrict_props_block02", "09F7F565C68C40D59E9ADF0C2372B4C3", 1, 0, 1967), + new("rworld_wallpanels", "6B06A7DB560E49D6A12C46BC1B9D6F88", 1, 0, 95), + new("fd_block02", "70764DD7992B4B0781C7D83633081148", 1, 0, 18), + new("rworldsocialdistrict_props_block03", "D7C931B8B5CE4F58ADD78199D71AA468", 1, 0, 1688), + new("rworldsocialdistrict_props_block04", "7045601034E54AC18DA7C0B9CCF5752A", 1, 0, 471), + new("rworldsocialdistrict_vista", "0B85F26D88AE4039863A8BCE9E80BB16", 1, 0, 34), + new("social_vista", "6A74F0D9A8A34A1E9931BCC199FC9F18", 1, 0, 104), + new("csd_vista", "3B12DF5D349744719A039816E2286D66", 1, 0, 55), + new("rworldsocialdistrict_beacons", "9EBBA758736843B79E10604B552A64CE", 1, 0, 26), + new("rworldsocialdistrict_rworldsocialdistrict_vista_package", "ECB0ED8BFA7943309281EACD1774DB2A", 1, 0, 6) + }; + + /// Base net index of a package under the USES ordering above. + public static uint PackageFirstNetIndex(string packageName) + { + uint baseIndex = 0; + foreach (UsesPackage package in UsesPackages) + { + if (string.Equals(package.Name, packageName, StringComparison.Ordinal)) + return baseIndex; + baseIndex += package.NetObjectCount; + } + return 0; + } + + /// Global net index of an object, given its index within its own package. + public static uint GlobalNetIndex(string packageName, uint objectNetIndex) + => PackageFirstNetIndex(packageName) + objectNetIndex; + + // ------------------------------------------------------------------ + // Wire field accessors (configurable via APB_* env vars) + // ------------------------------------------------------------------ + + public static uint PawnIsWindedWireField() + => (uint)ReadInt("APB_PAWN_IS_WINDED_FIELD", "PawnIsWindedField", (int)DefaultFieldPawnIsWinded, 0, (int)(PawnFieldMax - 1u)); + + public static uint GriFieldMax() + => (uint)ReadInt("APB_GRI_FIELD_MAX", "GriFieldMax", (int)GriFieldMaxFallback, 2, 4096); + + public static uint GriMatchHasBegunWireField() + => (uint)ReadInt("APB_GRI_MATCH_HAS_BEGUN_FIELD", "GriMatchHasBegunField", (int)FieldGriMatchHasBegunFallback, 0, (int)(GriFieldMax() - 1u)); + + public static uint ClientReceiveCharacterInfoWireField() + => (uint)ReadInt("APB_CLIENT_RECEIVE_CHARACTER_INFO_FIELD", "ClientReceiveCharacterInfoField", (int)ClientReceiveCharacterInfoFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ClientReceiveCharacterDataWireField() + => (uint)ReadInt("APB_CLIENT_RECEIVE_CHARACTER_DATA_FIELD", "ClientReceiveCharacterDataField", (int)ClientReceiveCharacterDataFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ClientReceiveCharacterStatsWireField() + => (uint)ReadInt("APB_CLIENT_RECEIVE_CHARACTER_STATS_FIELD", "ClientReceiveCharacterStatsField", (int)ClientReceiveCharacterStatsFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ClientReceiveCharacterRolesDataWireField() + => (uint)ReadInt("APB_CLIENT_RECEIVE_CHARACTER_ROLES_DATA_FIELD", "ClientReceiveCharacterRolesDataField", (int)ClientReceiveCharacterRolesDataFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ClientSetInitialStateWireField() + => (uint)ReadInt("APB_CLIENT_INITIAL_STATE_FIELD", "ClientSetInitialStateField", (int)FieldClientSetInitialStateFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ClientGoToSpawnZoneSelectScreenWireField() + => (uint)ReadInt("APB_MAPSELECT_FIELD", "ClientGoToSpawnZoneSelectScreenField", (int)FieldClientGoToSpawnZoneSelectScreenFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ServerSelectSpawnZoneWireField() + => (uint)ReadInt("APB_SERVER_SELECT_SPAWN_ZONE_FIELD", "ServerSelectSpawnZoneField", (int)FieldServerSelectSpawnZoneFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ServerNotifyClientLoadedWireField() + => (uint)ReadInt("APB_SERVER_NOTIFY_LOADED_FIELD", "ServerNotifyClientLoadedField", (int)FieldServerNotifyClientLoadedFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + public static uint ClientReplicateHudMarkerWireField() + => (uint)ReadInt("APB_CLIENT_REPLICATE_HUD_MARKER_FIELD", "ClientReplicateHUDMarkerField", (int)FieldClientReplicateHudMarkerFallback, 0, (int)(PlayerControllerFieldMax - 1u)); + + // ------------------------------------------------------------------ + // Remote-pawn replication (multiplayer visibility) + // ------------------------------------------------------------------ + + public static bool RemotePawnReplicationEnabled() + => ReadBool("APB_REMOTE_PAWN_REPLICATION", "RemotePawnReplication", true); + + public static bool RemotePawnSendPri() + => ReadBool("APB_REMOTE_PAWN_SEND_PRI", "RemotePawnSendPRI", true); + + public static bool RemotePawnSendDescriptor() + => ReadBool("APB_REMOTE_PAWN_SEND_DESCRIPTOR", "RemotePawnSendDescriptor", true); + + public static bool RemotePawnSendLocation() + => ReadBool("APB_REMOTE_PAWN_SEND_LOCATION", "RemotePawnSendLocation", true); + + public static bool RemotePawnSendVelocity() + => ReadBool("APB_REMOTE_PAWN_SEND_VELOCITY", "RemotePawnSendVelocity", true); + + public static bool RemotePawnSendRotation() + => ReadBool("APB_REMOTE_PAWN_SEND_ROTATION", "RemotePawnSendRotation", true); + + public static int RemotePawnOpenDelayMilliseconds() + => ReadInt("APB_REMOTE_PAWN_OPEN_DELAY_MS", "RemotePawnOpenDelayMilliseconds", 4000, 0, 20000); + + // ------------------------------------------------------------------ + // Spawn / controller coordinates + // ------------------------------------------------------------------ + + private static float ReadFloatSetting(string environmentName, string iniKey, string fallback) + { + string value = ReadSetting(environmentName, iniKey, fallback); + return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed) ? parsed : 0.0f; + } + + public static void ReadControllerLocation(out float x, out float y, out float z) + { + DistrictMap map = GetConfiguredDistrictMap(); + if (map == DistrictMap.Financial) + { + x = ReadFloatSetting("APB_FINANCIAL_SPAWN_X", "FinancialSpawnX", "154240.0"); + y = ReadFloatSetting("APB_FINANCIAL_SPAWN_Y", "FinancialSpawnY", "101216.0"); + z = ReadFloatSetting("APB_FINANCIAL_SPAWN_Z", "FinancialSpawnZ", "500.0"); + return; + } + + if (map == DistrictMap.Waterfront) + { + x = ReadFloatSetting("APB_WATERFRONT_SPAWN_X", "WaterfrontSpawnX", "162640.0"); + y = ReadFloatSetting("APB_WATERFRONT_SPAWN_Y", "WaterfrontSpawnY", "159616.0"); + z = ReadFloatSetting("APB_WATERFRONT_SPAWN_Z", "WaterfrontSpawnZ", "500.0"); + return; + } + + // Controller-specific coordinates win. If omitted, reuse the pawn spawn + // coordinates so one known-good position controls both experiments. + x = ReadControllerCoordinate("APB_CONTROLLER_X", "ControllerX", "APB_SPAWN_X", "SpawnX", "0"); + y = ReadControllerCoordinate("APB_CONTROLLER_Y", "ControllerY", "APB_SPAWN_Y", "SpawnY", "0"); + z = ReadControllerCoordinate("APB_CONTROLLER_Z", "ControllerZ", "APB_SPAWN_Z", "SpawnZ", "500"); + } + + private static float ReadControllerCoordinate(string env, string ini, string spawnEnv, string spawnIni, string fallback) + { + string value = ReadSetting(env, ini, ""); + if (value.Length == 0) + value = ReadSetting(spawnEnv, spawnIni, fallback); + return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed) ? parsed : 0.0f; + } + + // ------------------------------------------------------------------ + // Streaming plan + // ------------------------------------------------------------------ + + private static bool ParsePlanBoolean(string value, bool fallback) + { + if (value == "1" || value == "true" || value == "TRUE" || value == "yes" || value == "YES") + return true; + if (value == "0" || value == "false" || value == "FALSE" || value == "no" || value == "NO") + return false; + return fallback; + } + + private static List SplitSetting(string value, char delimiter) + { + var result = new List(); + var current = new StringBuilder(); + + foreach (char character in value) + { + if (character == delimiter) + { + result.Add(current.ToString()); + current.Clear(); + continue; + } + + if (character != '\r' && character != '\n') + current.Append(character); + } + + result.Add(current.ToString()); + return result; + } + + private static string TrimSetting(string value) => value.Trim(' ', '\t'); + + private const string DefaultStreamingPlan = + "rworldsocialdistrict_artprops_blockout|1|1|0;" + + "rworldsocialdistrict_tile_000_000_block_250_000terrain|1|1|0;" + + "rworldsocialdistrict_design|1|1|0;" + + "cc_background_1|0|0|0;" + + "cc_matinee|0|0|0;" + + "vc_matinee|0|0|0;" + + "wardrobe_matinee|0|0|0;" + + "rworldsocialdistrict_block01|1|1|0;" + + "rworldsocialdistrict_block02|1|1|0;" + + "rworldsocialdistrict_block03|1|1|0;" + + "rworldsocialdistrict_block04|1|1|0;" + + "rworldsocialdistrict_props_block01|1|1|0;" + + "rworldsocialdistrict_props_block02|1|1|0;" + + "rworldsocialdistrict_props_block03|1|1|0;" + + "rworldsocialdistrict_props_block04|1|1|0;" + + "rworldsocialdistrict_vista|1|1|0;" + + "rworldsocialdistrict_beacons|1|1|0"; + + private static List ParsePlan(string plan) + { + var result = new List(); + + foreach (string rawEntry in SplitSetting(plan, ';')) + { + string entry = TrimSetting(rawEntry); + if (entry.Length == 0) + continue; + + List fields = SplitSetting(entry, '|'); + if (fields.Count == 0) + continue; + + string packageName = TrimSetting(fields[0]); + if (packageName.Length == 0) + continue; + + bool loaded = fields.Count > 1 ? ParsePlanBoolean(TrimSetting(fields[1]), true) : true; + bool visible = fields.Count > 2 ? ParsePlanBoolean(TrimSetting(fields[2]), loaded) : loaded; + bool block = fields.Count > 3 ? ParsePlanBoolean(TrimSetting(fields[3]), false) : false; + + result.Add(new StreamingPlanEntry(packageName, loaded, visible, block)); + } + + return result; + } + + /// Reads the streaming plan, preferring the district's profile file for action districts. + public static List ReadStreamingPlan() + { + string configured = ReadSetting("APB_STREAMING_PLAN", "StreamingPlan", DefaultStreamingPlan); + DistrictMap districtMap = GetConfiguredDistrictMap(); + + if (IsActionDistrict(districtMap)) + { + bool waterfront = districtMap == DistrictMap.Waterfront; + string profilePath = ReadSetting( + waterfront ? "APB_WATERFRONT_STREAMING_PLAN_FILE" : "APB_FINANCIAL_STREAMING_PLAN_FILE", + waterfront ? "WaterfrontStreamingPlanFile" : "FinancialStreamingPlanFile", + waterfront ? "WaterfrontStreamingPlan.txt" : "FinancialStreamingPlan.txt"); + + if (!profilePath.Contains(':') && profilePath[0] != '\\' && profilePath[0] != '/') + { + string? directory = Path.GetDirectoryName(GetHandshakeConfigPath()); + if (!string.IsNullOrEmpty(directory)) + profilePath = Path.Combine(directory, profilePath); + } + + var joined = new StringBuilder(); + int lineCount = 0; + try + { + foreach (string rawLine in File.ReadAllLines(profilePath)) + { + string line = TrimSetting(rawLine); + if (line.Length == 0 || line[0] == '#' || line[0] == ';') + continue; + if (lineCount++ != 0) + joined.Append(';'); + joined.Append(line); + } + } + catch (IOException) + { + lineCount = 0; + } + + configured = joined.ToString(); + Logging.DistrictLogger.Log( + File.Exists(profilePath) ? Logging.LogLevel.Info : Logging.LogLevel.Error, + "District Stream RPC", + "{0} streaming profile '{1}': {2} entries read.", + DistrictMapName(districtMap), + profilePath, + lineCount); + } + + List plan = ParsePlan(configured); + + uint expectedEntryCount = districtMap == DistrictMap.Financial ? 254u : districtMap == DistrictMap.Waterfront ? 237u : 17u; + + if (plan.Count != expectedEntryCount || plan.Any(item => item.PackageName.Length < 3)) + { + Logging.DistrictLogger.Log( + Logging.LogLevel.Warn, + "District Stream RPC", + "Configured {0} StreamingPlan parsed as {1} entries; expected {2}. Refusing to substitute a different district profile.", + DistrictMapName(districtMap), + plan.Count, + expectedEntryCount); + + if (IsActionDistrict(districtMap)) + return new List(); + + plan = ParsePlan(DefaultStreamingPlan); + } + + Logging.DistrictLogger.Log(Logging.LogLevel.Info, "District Stream RPC", "Validated streaming plan: {0} entries.", plan.Count); + return plan; + } +} diff --git a/DistrictServerCSharp/Controller/ControllerFeedbackService.cs b/DistrictServerCSharp/Controller/ControllerFeedbackService.cs new file mode 100644 index 0000000..8b3c035 --- /dev/null +++ b/DistrictServerCSharp/Controller/ControllerFeedbackService.cs @@ -0,0 +1,1545 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Pawn; +using DistrictServerCSharp.Protocol; +using DistrictServerCSharp.SpawnZone; +using DistrictServerCSharp.Streaming; + +namespace DistrictServerCSharp.Controller; + +/// Per-account controller streaming/customisation feedback state. +public sealed class ControllerStreamingFeedbackState +{ + public HashSet VisiblePackages = new(); + public HashSet HiddenPackages = new(); + public bool BarrierLogged; + public bool ServerNotifyClientLoadedSeen; + public bool SpawnZoneMarkerSent; + public bool ServerSelectSpawnZoneSeen; + public bool PossessionSent; + public long BarrierCompletedTick; + + public bool PawnWantsToCrouch; + public uint CrouchToggleCount; + + public bool CustomisationTransferStarted; + public bool CustomisationTransferSent; + public bool CustomisationCompletionSent; + public bool CustomisationTransferCompleted; + public uint CustomisationTransferGeneration; + public ushort CustomisationActiveReplicatorChannel = DistrictConfig.CustomisationReplicatorChannel; + public int CustomisationRequestBaseOffset; + public int CustomisationLastRequestedBaseIndex = -1; + public int CustomisationLastSentBaseIndex = -1; + public bool MissingStartupFeedbackWarningLogged; + public uint LastPacketId; + public ushort LastChannelSequence; +} + +/// Per-account movement feedback state (ACK counts + hard-landing winded detection). +public sealed class ControllerMovementFeedbackState +{ + public ulong Received; + public ulong Acked; + public IPEndPoint Endpoint = null!; + public bool HasMotionSample; + public int LastLocationX; + public int LastLocationY; + public int LastLocationZ; + public int PrevLocationX; + public int PrevLocationY; + public int PrevLocationZ; + + // Per-axis velocity from consecutive ServerMove samples, in UE3 + // units/second. Replicated to remote viewers (Actor field 4) so the + // remote pawn's AnimTree blends walk/run from actual speed. + public float VelocityX; + public float VelocityY; + public float VelocityZ; + + // Last non-zero horizontal heading (UE3 yaw units, 65536/turn), derived + // from the movement direction as a fallback. + public int LastYaw; + + // Client's actual facing from the ServerMove View parameter (packed + // pitch<<16 | yaw in 16-bit rotation units). Preferred over the velocity + // heading for remote-pawn rotation so the pawn faces where the owner aims. + public bool HasViewYaw; + public int LastViewYaw; + + // Tick of the last accepted motion sample; used to decay the replicated + // velocity to zero once the owner stops moving. + public long LastMotionSampleTick; + + public float LastTimeStamp; + public float LastEstimatedVelocityZ; + + public bool HardFallArmed; + public float MinimumEstimatedVelocityZ; + public int StableLandingSamples; + + public bool WindedClearPending; + public long WindedClearTick; + public int WindedClearAttempt; + public int WindedClearTotalAttempts; + public int WindedClearRetryMilliseconds; + + public ulong HardLandingSequence; + public float LastHardLandingVelocityZ; + public int LastHardLandingZ; +} + +/// +/// A point-in-time copy of a player's motion, handed to the remote-pawn +/// replication service so it can publish the owner's location/velocity/facing +/// to in-world viewers without touching the shared movement state directly. +/// +public readonly struct MotionSnapshot +{ + public readonly int LocationX; + public readonly int LocationY; + public readonly int LocationZ; + public readonly float VelocityX; + public readonly float VelocityY; + public readonly float VelocityZ; + public readonly int Yaw; + public readonly bool HasViewYaw; + public readonly long LastMotionSampleTick; + + public MotionSnapshot(int locationX, int locationY, int locationZ, + float velocityX, float velocityY, float velocityZ, + int yaw, bool hasViewYaw, long lastMotionSampleTick) + { + LocationX = locationX; + LocationY = locationY; + LocationZ = locationZ; + VelocityX = velocityX; + VelocityY = velocityY; + VelocityZ = velocityZ; + Yaw = yaw; + HasViewYaw = hasViewYaw; + LastMotionSampleTick = lastMotionSampleTick; + } +} + +/// +/// Client→server controller actor-channel feedback, ported from +/// ProcessControllerActorFeedback / ProcessControllerMovementRpc in +/// DistrictServer.cpp. Decodes movement RPCs (acking them and reconstructing +/// hard-landing winded), spawn-zone selection (field 371), the visible-level +/// streaming barrier, character-data requests, crouch toggles and +/// customisation transfer requests. +/// +public sealed class ControllerFeedbackService +{ + private readonly HandshakeService _handshake; + private readonly ReliableQueue _reliableQueue; + private readonly PawnLifecycleService _pawnLifecycle; + private readonly SpawnZoneService _spawnZone; + private readonly LevelStreamingService _streaming; + + private readonly object _feedbackGate = new(); + private readonly Dictionary _feedbackStates = new(); + + private readonly object _movementGate = new(); + private readonly Dictionary _movementStates = new(); + + public ControllerFeedbackService( + HandshakeService handshake, + ReliableQueue reliableQueue, + PawnLifecycleService pawnLifecycle, + SpawnZoneService spawnZone, + LevelStreamingService streaming) + { + _handshake = handshake; + _reliableQueue = reliableQueue; + _pawnLifecycle = pawnLifecycle; + _spawnZone = spawnZone; + _streaming = streaming; + + // The possession gate reads SpawnZoneMarkerSent, so publishing the + // fact that markers were sent must land in the per-account state. + // Go through GetOrCreateState: a raw indexer throws + // KeyNotFoundException straight out of the UDP receive loop when no + // controller field has been decoded for this account yet, which a + // fresh JOIN (which clears the state) makes reachable. + _spawnZone.OnMarkersSent += account => + { + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + lock (_feedbackGate) + { + state.SpawnZoneMarkerSent = true; + } + }; + } + + /// + /// Fired after a ServerMove sample with a client location is accepted, so + /// the remote-pawn replication service can push the owner's position to + /// its in-world viewers (throttled per link). + /// + public event Action? OnMotionSample; + + /// Latest accepted motion sample for an account, if any. + public bool TryGetMotionSnapshot(uint accountId, out MotionSnapshot snapshot) + { + lock (_movementGate) + { + if (_movementStates.TryGetValue(accountId, out ControllerMovementFeedbackState? state) && state.HasMotionSample) + { + snapshot = new MotionSnapshot( + state.LastLocationX, state.LastLocationY, state.LastLocationZ, + state.VelocityX, state.VelocityY, state.VelocityZ, + state.HasViewYaw ? state.LastViewYaw : state.LastYaw, + state.HasViewYaw, state.LastMotionSampleTick); + return true; + } + } + + snapshot = default; + return false; + } + + public ControllerStreamingFeedbackState GetOrCreateState(uint accountId) + { + lock (_feedbackGate) + { + if (!_feedbackStates.TryGetValue(accountId, out ControllerStreamingFeedbackState? state)) + { + state = new ControllerStreamingFeedbackState(); + _feedbackStates[accountId] = state; + } + return state; + } + } + + public void Reset(uint accountId) + { + lock (_feedbackGate) + { + _feedbackStates.Remove(accountId); + } + lock (_movementGate) + { + _movementStates.Remove(accountId); + } + } + + // ------------------------------------------------------------------ + // Main dispatch + // ------------------------------------------------------------------ + + public void ProcessControllerActorFeedback(IPEndPoint endpoint, Account? account, Packet packet) + { + if (account == null || !DistrictConfig.ReadBool("APB_DECODE_CONTROLLER_ACTOR_FIELDS", "DecodeControllerActorFields", true)) + return; + + foreach (Bunch bunch in packet.Bunches) + { + if (bunch.Kind != BunchKind.Data || bunch.ChannelIndex != DistrictConfig.ControllerChannel || bunch.DataBitCount == 0) + continue; + + // CSA door/use RPCs (338..345): persist the decrypted bunch and + // handle the key-pressed (343) and reset (340) cases. + if (FieldDecoders.DecodeActorFieldIndex(bunch, DistrictConfig.PlayerControllerFieldMax, out uint firstFieldIndex, out int firstFieldParameterBits, out string firstFieldError) && + firstFieldIndex >= 338u && firstFieldIndex <= 345u) + { + bool hasLocation = false; + int locationX = 0, locationY = 0, locationZ = 0; + lock (_movementGate) + { + if (_movementStates.TryGetValue(account.GetId(), out ControllerMovementFeedbackState? movementState) && movementState.HasMotionSample) + { + hasLocation = true; + locationX = movementState.LastLocationX; + locationY = movementState.LastLocationY; + locationZ = movementState.LastLocationZ; + } + } + + DistrictLogger.Log(LogLevel.Success, "District CSA RX", + "account={0} packetId={1} seq={2} reliable={3} field={4} bits={5} location={6}({7},{8},{9}) raw={10}", + account.GetId(), packet.PacketId, bunch.ChannelSequence, bunch.Reliable ? 1 : 0, + firstFieldIndex, bunch.DataBitCount, hasLocation ? "" : "unknown", locationX, locationY, locationZ, + Diagnostics.Hex(bunch.RawData, bunch.RawData.Length)); + + if (firstFieldIndex == 343u) + { + var keyPressed = new CSAKeyPressedRpc(); + if (FieldDecoders.DecodeCSAKeyPressedRpc(bunch, DistrictConfig.PlayerControllerFieldMax, 343u, keyPressed, out string keyPressedError)) + { + DistrictLogger.Log(LogLevel.Success, "District CSA Target", + "account={0} mapping={1} aim={2} camera={3:F3} target={4}:{5} consumedBits={6}/{7}.", + account.GetId(), keyPressed.InputMapping, keyPressed.AimRotation, keyPressed.CameraCollidePercent, + keyPressed.TargetByChannel ? "channel" : "netindex", keyPressed.TargetReference, + keyPressed.ConsumedBits, bunch.DataBitCount); + } + else + { + DistrictLogger.Log(LogLevel.Error, "District CSA Target", "{0}", keyPressedError); + } + } + + if (firstFieldIndex == 340u) + { + uint resetPacketId = account.AllocateServerPacketId(); + bool resetSent = _reliableQueue.SendTrackedReliablePacket(endpoint, account, resetPacketId, + PacketBuilders.BuildActorDefaultRpcPacket(resetPacketId, DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + 344u, DistrictConfig.PlayerControllerFieldMax, 1), + "CLIENT-RESET-PENDING-CSA"); + + DistrictLogger.Log(resetSent ? LogLevel.Success : LogLevel.Error, "District CSA", + "Reset pending CSA account={0} sourceField={1} sourcePacketId={2} resetField=344 resetPacketId={3} sent={4}.", + account.GetId(), firstFieldIndex, packet.PacketId, resetPacketId, resetSent ? 1 : 0); + } + } + + if (ProcessControllerMovementRpc(endpoint, account, packet, bunch)) + continue; + + var fields = new List(); + bool decoded = FieldDecoders.DecodeControllerActorFields( + bunch, + DistrictConfig.PlayerControllerFieldMax, + DistrictConfig.FieldServerUpdateLevelVisibility, + DistrictConfig.ServerNotifyClientLoadedWireField(), + DistrictConfig.ServerSelectSpawnZoneWireField(), + fields, + out string decodeError); + + if (!decoded && fields.Count == 0) + { + DistrictLogger.Log(LogLevel.Warn, "District Controller RX", + "account={0} packetId={1} seq={2} bits={3} decode failed: {4}", + account.GetId(), packet.PacketId, bunch.ChannelSequence, bunch.DataBitCount, decodeError); + continue; + } + + foreach (ControllerActorField field in fields) + { + if (field.IsServerNotifyClientLoaded) + { + HandleServerNotifyClientLoaded(endpoint, account, packet, bunch, field); + continue; + } + + if (field.IsServerSelectSpawnZone) + { + HandleServerSelectSpawnZone(endpoint, account, packet, bunch, field); + continue; + } + + if (!field.IsServerUpdateLevelVisibility) + { + // Field 78 / 484 are known fixed-width updates, deliberately + // skipped by the decoder; do not spam logs for them. + if (field.FieldIndex == DistrictConfig.FieldKnownFixedWidthUpdate || + field.FieldIndex == DistrictConfig.FieldKnownPostStreamUpdate) + { + continue; + } + + if (field.IsServerRequestCharacterData) + { + SendClientReceiveCharacterData(endpoint, account, field.RequestedCharacterUid, packet.PacketId, bunch.ChannelSequence); + continue; + } + + if (field.IsServerRequestCharacterStats) + { + SendClientReceiveCharacterStats(endpoint, account, field.RequestedCharacterStatsUid, packet.PacketId, bunch.ChannelSequence); + continue; + } + + if (field.IsServerRequestCharacterRolesData) + { + SendClientReceiveCharacterRolesData(endpoint, account, field.RequestedCharacterRolesUid, packet.PacketId, bunch.ChannelSequence); + continue; + } + + if (field.FieldIndex == DistrictConfig.FieldToggleWantsToCrouchServer) + { + ControllerStreamingFeedbackState crouchState = GetOrCreateState(account.GetId()); + bool crouched; + uint toggleCount; + lock (_feedbackGate) + { + crouchState.PawnWantsToCrouch = !crouchState.PawnWantsToCrouch; + ++crouchState.CrouchToggleCount; + crouched = crouchState.PawnWantsToCrouch; + toggleCount = crouchState.CrouchToggleCount; + } + + DistrictLogger.Log(LogLevel.Success, "District Crouch", + "Received ToggleWantsToCrouchServer account={0} field={1} packetId={2} seq={3} bits={4}..{5} toggle={6} target={7}.", + account.GetId(), field.FieldIndex, packet.PacketId, bunch.ChannelSequence, field.BeginBit, field.EndBit, toggleCount, crouched ? "crouched" : "standing"); + + SendPawnCrouchState(endpoint, account, crouched, packet.PacketId, bunch.ChannelSequence, toggleCount); + continue; + } + + if (field.FieldIndex == DistrictConfig.FieldServerRequestCustomisation) + { + HandleServerRequestCustomisation(endpoint, account, packet, bunch); + continue; + } + + if (DistrictConfig.ReadBool("APB_LOG_UNKNOWN_CONTROLLER_FIELDS", "LogUnknownControllerFields", true)) + { + DistrictLogger.Log(LogLevel.Info, "District Controller RX", + "account={0} packetId={1} seq={2} field={3} beginBit={4} endBit={5} remainingBits={6} note={7}", + account.GetId(), packet.PacketId, bunch.ChannelSequence, field.FieldIndex, field.BeginBit, field.EndBit, + bunch.DataBitCount - Math.Min(bunch.DataBitCount, field.EndBit), + decodeError.Length == 0 ? "unknown parameter layout" : decodeError); + } + + continue; + } + + // ServerUpdateLevelVisibility: track the visible-level barrier. + string packageName = field.PackageName.ToLowerInvariant(); + + int visibleExpectedCount = 0; + int expectedCount = 0; + bool newlyCompleted = false; + + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + lock (_feedbackGate) + { + state.LastPacketId = packet.PacketId; + state.LastChannelSequence = bunch.ChannelSequence; + + if (field.IsVisible) + { + state.VisiblePackages.Add(packageName); + state.HiddenPackages.Remove(packageName); + } + else + { + state.HiddenPackages.Add(packageName); + state.VisiblePackages.Remove(packageName); + } + + expectedCount = LevelStreamingService.ExpectedVisibleStreamingPackages.Length; + foreach (string wanted in LevelStreamingService.ExpectedVisibleStreamingPackages) + { + if (state.VisiblePackages.Contains(wanted)) + ++visibleExpectedCount; + } + + bool barrierComplete = visibleExpectedCount == expectedCount; + if (barrierComplete && !state.BarrierLogged) + { + state.BarrierLogged = true; + state.BarrierCompletedTick = Environment.TickCount64; + newlyCompleted = true; + } + } + + DistrictLogger.Log(LogLevel.Info, "District Stream Feedback", + "account={0} packetId={1} seq={2} field={3} package={4} visible={5} progress={6}/{7} bits={8}..{9}", + account.GetId(), packet.PacketId, bunch.ChannelSequence, field.FieldIndex, packageName, + field.IsVisible ? 1 : 0, visibleExpectedCount, expectedCount, field.BeginBit, field.EndBit); + + if (newlyCompleted) + { + DistrictLogger.Log(LogLevel.Success, "District Startup Barrier", + "Account {0} acknowledged all {1} expected visible Social district streaming levels. MapSelect was armed before streaming; waiting for startup feedback.", + account.GetId(), expectedCount); + } + } + + // Missing-startup-feedback warning (13/13 completed but no 372). + bool logMissingStartupFeedback = false; + long barrierAgeMilliseconds = 0; + + ControllerStreamingFeedbackState feedbackState = GetOrCreateState(account.GetId()); + lock (_feedbackGate) + { + if (feedbackState.BarrierLogged && + !feedbackState.ServerNotifyClientLoadedSeen && + !feedbackState.MissingStartupFeedbackWarningLogged && + feedbackState.BarrierCompletedTick != 0) + { + barrierAgeMilliseconds = Environment.TickCount64 - feedbackState.BarrierCompletedTick; + int warningDelayMilliseconds = DistrictConfig.ReadInt("APB_STARTUP_FEEDBACK_WARNING_MS", "StartupFeedbackWarningMilliseconds", 3000, 500, 30000); + + if (barrierAgeMilliseconds >= warningDelayMilliseconds) + { + feedbackState.MissingStartupFeedbackWarningLogged = true; + logMissingStartupFeedback = true; + } + } + } + + if (logMissingStartupFeedback) + { + DistrictLogger.Log(LogLevel.Error, "District Startup Feedback", + "13/13 completed {0} ms ago but the configured ServerNotifyClientLoaded candidate field={1} has not arrived. Confirm the lifecycle in Launch.log; the wire field is not yet independently proven.", + barrierAgeMilliseconds, DistrictConfig.ServerNotifyClientLoadedWireField()); + } + } + } + + // ------------------------------------------------------------------ + // ServerNotifyClientLoaded (field 372) → spawn-zone markers + // ------------------------------------------------------------------ + + private void HandleServerNotifyClientLoaded(IPEndPoint endpoint, Account account, Packet packet, Bunch bunch, ControllerActorField field) + { + bool firstReceipt; + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + lock (_feedbackGate) + { + firstReceipt = !state.ServerNotifyClientLoadedSeen; + state.ServerNotifyClientLoadedSeen = true; + } + + DistrictLogger.Log(firstReceipt ? LogLevel.Success : LogLevel.Info, "District Startup Feedback", + "account={0} received ServerNotifyClientLoaded field={1} packetId={2} seq={3} bits={4}..{5}{6}", + account.GetId(), field.FieldIndex, packet.PacketId, bunch.ChannelSequence, field.BeginBit, field.EndBit, + firstReceipt ? " (first receipt)" : " (duplicate)"); + + if (!firstReceipt) + return; + + DistrictLogger.Log(LogLevel.Success, "District Enter Lifecycle", + "Client startup barrier is complete. Sending the derived ClientReplicateHUDMarker field={0} now.", + DistrictConfig.ClientReplicateHudMarkerWireField()); + + DistrictMap activeDistrictMap = DistrictConfig.GetConfiguredDistrictMap(); + bool actionDistrict = DistrictConfig.IsActionDistrict(activeDistrictMap); + int markerDelayMilliseconds = actionDistrict ? 0 : + DistrictConfig.ReadInt("APB_SPAWN_ZONE_HUD_MARKER_DELAY_MS", "SpawnZoneHUDMarkerDelayMilliseconds", 3000, 0, 15000); + + if (markerDelayMilliseconds > 0) + { + DistrictLogger.Log(LogLevel.Info, "District Enter Lifecycle", + "Waiting {0} ms for DistrictMap_EntryMode_001 to finish opening before sending field={1}.", + markerDelayMilliseconds, DistrictConfig.ClientReplicateHudMarkerWireField()); + Thread.Sleep(markerDelayMilliseconds); + } + + bool markerSent = actionDistrict || _spawnZone.SendSpawnZoneHudMarker(endpoint, account); + + bool autoPossess = false; + bool autoPossessUsesLegacyDelay = false; + bool waitingForInitialCustomisation = false; + + lock (_feedbackGate) + { + state.SpawnZoneMarkerSent = markerSent; + + bool possessAfterInitialCompletion = !actionDistrict && DistrictConfig.ReadBool( + "APB_AUTO_POSSESS_AFTER_INITIAL_CUSTOMISATION_COMPLETE", "AutoPossessAfterInitialCustomisationComplete", false); + + if (markerSent && possessAfterInitialCompletion && state.CustomisationTransferGeneration == 0 && + state.CustomisationTransferCompleted && !state.PossessionSent) + { + state.PossessionSent = true; + autoPossess = true; + } + else if (markerSent && possessAfterInitialCompletion && state.CustomisationTransferGeneration == 0 && + !state.CustomisationTransferCompleted && !state.PossessionSent) + { + waitingForInitialCustomisation = true; + } + else if (markerSent && !actionDistrict && DistrictConfig.ReadBool( + "APB_AUTO_POSSESS_AFTER_CLIENT_LOADED", "AutoPossessAfterClientLoaded", false) && !state.PossessionSent) + { + state.PossessionSent = true; + autoPossess = true; + autoPossessUsesLegacyDelay = true; + } + } + + if (waitingForInitialCustomisation) + { + DistrictLogger.Log(LogLevel.Success, "District Character Customisation", + "Spawn-zone markers are ready. Deferring pawn creation until generation-0 ServerNotifyOperationComplete is received."); + } + + if (autoPossess) + { + if (autoPossessUsesLegacyDelay) + { + int delayMilliseconds = DistrictConfig.ReadInt("APB_AUTO_POSSESS_DELAY_MS", "AutoPossessDelayMilliseconds", 500, 0, 10000); + if (delayMilliseconds > 0) + Thread.Sleep(delayMilliseconds); + } + + _pawnLifecycle.SendPawnAndPossess(endpoint, account); + } + } + + // ------------------------------------------------------------------ + // ServerSelectSpawnZone (field 371) → possession + // ------------------------------------------------------------------ + + private void HandleServerSelectSpawnZone(IPEndPoint endpoint, Account account, Packet packet, Bunch bunch, ControllerActorField field) + { + bool firstReceipt = false; + bool shouldPossess = false; + bool startFinancialCustomisation = false; + + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + lock (_feedbackGate) + { + if (!state.ServerSelectSpawnZoneSeen) + { + state.ServerSelectSpawnZoneSeen = true; + firstReceipt = true; + } + + shouldPossess = firstReceipt && + DistrictConfig.ReadBool("APB_ENABLE_POSSESSION", "EnablePossession", false) && + !state.PossessionSent; + + if (shouldPossess && DistrictConfig.IsActionDistrict(DistrictConfig.GetConfiguredDistrictMap()) && + DistrictConfig.ReadBool("APB_SEND_CHARACTER_CUSTOMISATION_TRANSFER", "SendCharacterCustomisationTransfer", true) && + !state.CustomisationTransferStarted) + { + state.CustomisationTransferStarted = true; + state.CustomisationTransferGeneration = 0; + state.CustomisationActiveReplicatorChannel = CustomisationReplicatorChannelForGeneration(0); + state.CustomisationRequestBaseOffset = 0; + state.CustomisationTransferSent = false; + state.CustomisationCompletionSent = false; + state.CustomisationTransferCompleted = false; + state.CustomisationLastRequestedBaseIndex = -1; + state.CustomisationLastSentBaseIndex = -1; + startFinancialCustomisation = true; + shouldPossess = false; + } + + if (shouldPossess) + state.PossessionSent = true; + } + + DistrictLogger.Log(firstReceipt ? LogLevel.Success : LogLevel.Info, "District Spawn Zone Selection", + "account={0} received ServerSelectSpawnZone field={1} reference={2}:{3} packetId={4} seq={5} bits={6}..{7}{8}", + account.GetId(), field.FieldIndex, + field.ObjectReferenceByChannel ? "channel" : "netindex", field.ObjectReferenceValue, + packet.PacketId, bunch.ChannelSequence, field.BeginBit, field.EndBit, + firstReceipt ? " (first receipt)" : " (duplicate)"); + + // The spawn-location latch is NOT action-district gated in the oracle + // (DistrictServer.cpp:12474-12506). Action-district clients reference + // the bridge channel (SpawnZoneActorChannel + zone index); the social + // client references the static spawn-zone template NetIndex (72913 + // Enforcer / 72914 Criminal) instead. Gating the whole latch on + // IsActionDistrict, and handling only the by-channel form, meant + // Social never latched a spawn location at all, so the pawn ignored + // the selected zone. + if (firstReceipt) + { + int zoneIndex = -1; + if (field.ObjectReferenceByChannel && + field.ObjectReferenceValue >= DistrictConfig.SpawnZoneActorChannel && + field.ObjectReferenceValue < DistrictConfig.SpawnZoneActorChannel + 12u) + { + zoneIndex = (int)(field.ObjectReferenceValue - DistrictConfig.SpawnZoneActorChannel); + } + else if (!field.ObjectReferenceByChannel && + field.ObjectReferenceValue >= 72913u && + field.ObjectReferenceValue <= 72914u) + { + zoneIndex = (int)(field.ObjectReferenceValue - 72913u); + } + + if (zoneIndex >= 0 && + DistrictConfig.TryGetSpawnZoneLocation(DistrictConfig.GetConfiguredDistrictMap(), + zoneIndex, out float x, out float y, out float z)) + { + SelectedSpawnLocations.Set(account.GetId(), x, y, z + 100.0f); + } + } + + if (firstReceipt && DistrictConfig.IsActionDistrict(DistrictConfig.GetConfiguredDistrictMap())) + { + _reliableQueue.CancelPendingReliablesByLabelPrefix(account.GetId(), + new[] { "SPAWN-ZONE-ACTOR-BRIDGE-OPEN-", "CLIENT-REPLICATE-HUD-MARKER-" }); + + // Marker actors are selection-screen scaffolding; close them all. + for (ushort channel = DistrictConfig.SpawnZoneActorChannel; channel < DistrictConfig.SpawnZoneActorChannel + 12u; ++channel) + { + uint closePacketId = account.AllocateServerPacketId(); + _reliableQueue.SendTrackedReliablePacket(endpoint, account, closePacketId, + PacketBuilders.BuildActorClosePacket(closePacketId, channel, ChannelSequenceAllocator.Allocate(endpoint, channel)), + "SPAWN-ZONE-ACTOR-CLOSE"); + } + } + + if (shouldPossess) + { + _pawnLifecycle.SendPawnAndPossess(endpoint, account); + } + else if (startFinancialCustomisation) + { + bool sent = SendCharacterCustomisationTransfer(endpoint, account, CustomisationReplicatorChannelForGeneration(0), 0); + lock (_feedbackGate) + { + state.CustomisationTransferSent = sent; + if (!sent) + state.PossessionSent = true; + } + + if (!sent) + _pawnLifecycle.SendPawnAndPossess(endpoint, account); + } + else if (firstReceipt) + { + DistrictLogger.Log(LogLevel.Info, "District Spawn Zone Selection", + "Selection decoded successfully. Set EnablePossession=1 to spawn and ClientRestart only after this field arrives."); + } + } + + // ------------------------------------------------------------------ + // Movement RPCs + // ------------------------------------------------------------------ + + private bool ProcessControllerMovementRpc(IPEndPoint endpoint, Account account, Packet packet, Bunch bunch) + { + if (account == null) + return false; + + var movement = new ControllerMovementRpc(); + if (!FieldDecoders.DecodeControllerMovementRpc(bunch, DistrictConfig.PlayerControllerFieldMax, + DistrictConfig.FieldDualServerMove, DistrictConfig.FieldOldServerMove, DistrictConfig.FieldServerMove, + movement, out string decodeError)) + { + return false; + } + + if (!movement.Matched) + return false; + + if (!movement.HasTimeStamp) + return true; + + bool movementAckEnabled = DistrictConfig.ReadBool("APB_ENABLE_MOVEMENT_ACK", "EnableMovementAck", true); + bool shouldAck = movementAckEnabled && movement.HasTimeStamp; + + bool ackSent = false; + if (shouldAck) + { + ackSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildUnreliableActorFloatFieldPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + DistrictConfig.FieldClientAckGoodMove, DistrictConfig.PlayerControllerFieldMax, movement.TimeStamp), + "CLIENT-ACK-GOOD-MOVE"); + } + + ulong receivedCount = 0; + ulong ackedCount = 0; + + ControllerMovementFeedbackState state; + lock (_movementGate) + { + if (!_movementStates.TryGetValue(account.GetId(), out state!)) + { + state = new ControllerMovementFeedbackState(); + _movementStates[account.GetId()] = state; + } + + ++state.Received; + if (ackSent) + ++state.Acked; + + state.Endpoint = endpoint; + + bool hardLandingRecoveryEnabled = DistrictConfig.ReadBool("APB_ENABLE_HARD_LANDING_WINDED_TIMER", "EnableHardLandingWindedTimer", true); + + if (hardLandingRecoveryEnabled && movement.HasTimeStamp && movement.ClientLocationPresent) + { + int currentZ = movement.ClientLocationZ; + + if (state.HasMotionSample) + { + float deltaTime = movement.TimeStamp - state.LastTimeStamp; + + if (deltaTime > 0.001f && deltaTime < 0.500f) + { + int deltaZ = currentZ - state.LastLocationZ; + float estimatedVelocityZ = deltaZ / deltaTime; + state.LastEstimatedVelocityZ = estimatedVelocityZ; + + int windedSpeedThreshold = DistrictConfig.ReadInt("APB_WINDED_FALL_SPEED_THRESHOLD", "WindedFallSpeedThreshold", 1050, 100, 5000); + int landingVelocityTolerance = DistrictConfig.ReadInt("APB_WINDED_LANDING_VELOCITY_TOLERANCE", "WindedLandingVelocityTolerance", 120, 0, 1000); + int landingDeltaTolerance = DistrictConfig.ReadInt("APB_WINDED_LANDING_DELTA_TOLERANCE", "WindedLandingDeltaTolerance", 3, 0, 100); + int requiredStableSamples = DistrictConfig.ReadInt("APB_WINDED_LANDING_STABLE_SAMPLES", "WindedLandingStableSamples", 2, 1, 20); + + int absoluteDeltaZ = deltaZ < 0 ? -deltaZ : deltaZ; + + if (estimatedVelocityZ <= -windedSpeedThreshold) + { + if (!state.HardFallArmed) + { + state.HardFallArmed = true; + state.MinimumEstimatedVelocityZ = estimatedVelocityZ; + state.StableLandingSamples = 0; + + DistrictLogger.Log(LogLevel.Info, "District Winded", + "Hard fall armed account={0} timestamp={1:F6} locationZ={2} estimatedVelocityZ={3:F3} threshold=-{4}.", + account.GetId(), movement.TimeStamp, currentZ, estimatedVelocityZ, windedSpeedThreshold); + } + else if (estimatedVelocityZ < state.MinimumEstimatedVelocityZ) + { + state.MinimumEstimatedVelocityZ = estimatedVelocityZ; + } + + state.StableLandingSamples = 0; + } + else if (state.HardFallArmed) + { + bool stableLocation = absoluteDeltaZ <= landingDeltaTolerance; + bool nearGroundVelocity = estimatedVelocityZ >= -landingVelocityTolerance; + + if (stableLocation && nearGroundVelocity) + ++state.StableLandingSamples; + else + state.StableLandingSamples = 0; + + if (state.StableLandingSamples >= requiredStableSamples) + { + int recoveryDelayMilliseconds = DistrictConfig.ReadInt("APB_WINDED_RECOVERY_DELAY_MS", "WindedRecoveryDelayMilliseconds", 500, 0, 10000); + int recoveryAttempts = DistrictConfig.ReadInt("APB_WINDED_RECOVERY_ATTEMPTS", "WindedRecoveryAttempts", 2, 1, 8); + int recoveryRetryMilliseconds = DistrictConfig.ReadInt("APB_WINDED_RECOVERY_RETRY_MS", "WindedRecoveryRetryMilliseconds", 100, 25, 2000); + + ++state.HardLandingSequence; + state.LastHardLandingVelocityZ = state.MinimumEstimatedVelocityZ; + state.LastHardLandingZ = currentZ; + + state.WindedClearPending = true; + state.WindedClearTick = Environment.TickCount64 + recoveryDelayMilliseconds; + state.WindedClearAttempt = 0; + state.WindedClearTotalAttempts = recoveryAttempts; + state.WindedClearRetryMilliseconds = recoveryRetryMilliseconds; + + DistrictLogger.Log(LogLevel.Success, "District Winded", + "Hard landing detected account={0} sequence={1} timestamp={2:F6} landingZ={3} minimumEstimatedVelocityZ={4:F3} recoveryDelayMs={5} attempts={6} retryMs={7}.", + account.GetId(), state.HardLandingSequence, movement.TimeStamp, currentZ, + state.MinimumEstimatedVelocityZ, recoveryDelayMilliseconds, recoveryAttempts, recoveryRetryMilliseconds); + + state.HardFallArmed = false; + state.StableLandingSamples = 0; + state.MinimumEstimatedVelocityZ = 0.0f; + } + } + } + else if (deltaTime <= 0.0f || deltaTime >= 0.500f) + { + // Start a clean velocity baseline after a timestamp reset + // or a long packet gap. Zero the replicated velocity too: + // otherwise the last pre-gap estimate keeps being pushed + // while the owner walks at normal speed, which reads as + // "walking = running animation" after a server-lag gap. + state.HardFallArmed = false; + state.StableLandingSamples = 0; + state.MinimumEstimatedVelocityZ = 0.0f; + state.VelocityX = 0.0f; + state.VelocityY = 0.0f; + state.VelocityZ = 0.0f; + } + } + + // Per-axis velocity for remote-pawn replication: the delta + // between this sample and the previous one divided by the + // timestamp delta (same estimation the winded logic uses). + float sampleDeltaTime = movement.TimeStamp - state.LastTimeStamp; + if (state.HasMotionSample && sampleDeltaTime > 0.001f && sampleDeltaTime < 0.500f) + { + state.PrevLocationX = state.LastLocationX; + state.PrevLocationY = state.LastLocationY; + state.PrevLocationZ = state.LastLocationZ; + + // EMA-smooth the raw per-sample estimate so timestamp + // quantization noise and server-backlog bursts do not spike + // the replicated velocity into the run-blend territory. + const float kVelocityBlend = 0.5f; + float rawVelocityX = (movement.ClientLocationX - state.PrevLocationX) / sampleDeltaTime; + float rawVelocityY = (movement.ClientLocationY - state.PrevLocationY) / sampleDeltaTime; + float rawVelocityZ = (currentZ - state.PrevLocationZ) / sampleDeltaTime; + + state.VelocityX += (rawVelocityX - state.VelocityX) * kVelocityBlend; + state.VelocityY += (rawVelocityY - state.VelocityY) * kVelocityBlend; + state.VelocityZ += (rawVelocityZ - state.VelocityZ) * kVelocityBlend; + + // Clamp the horizontal speed: APB sprint is ~850 u/s; + // anything far beyond that is a spike, not real motion. + const float kMaxHorizontalSpeed = 1500.0f; + float horizontalSpeed = MathF.Sqrt(state.VelocityX * state.VelocityX + state.VelocityY * state.VelocityY); + if (horizontalSpeed > kMaxHorizontalSpeed) + { + float scale = kMaxHorizontalSpeed / horizontalSpeed; + state.VelocityX *= scale; + state.VelocityY *= scale; + } + + const float kMaxVerticalSpeed = 2500.0f; + state.VelocityZ = MathF.Max(-kMaxVerticalSpeed, MathF.Min(kMaxVerticalSpeed, state.VelocityZ)); + + float horizontalSpeedSquared = state.VelocityX * state.VelocityX + state.VelocityY * state.VelocityY; + if (horizontalSpeedSquared > 25.0f) + { + double heading = Math.Atan2(state.VelocityY, state.VelocityX); + state.LastYaw = (int)(heading * 65536.0 / 6.283185307179586); + } + } + + // Client's actual facing (View = packed pitch<<16 | yaw in + // 16-bit rotation units). Preferred over the velocity heading + // for the remote-pawn rotation push. + if (movement.ViewPresent) + { + state.HasViewYaw = true; + state.LastViewYaw = (int)(movement.View & 0xFFFFu); + } + + state.HasMotionSample = true; + state.LastLocationX = movement.ClientLocationX; + state.LastLocationY = movement.ClientLocationY; + state.LastLocationZ = currentZ; + state.LastTimeStamp = movement.TimeStamp; + state.LastMotionSampleTick = Environment.TickCount64; + } + else if (movement.HasTimeStamp) + { + state.LastTimeStamp = movement.TimeStamp; + } + + receivedCount = state.Received; + ackedCount = state.Acked; + } + + // Publish the source's new position to its in-world viewers (throttled + // per remote link inside the replication service). + if (movement.ClientLocationPresent) + OnMotionSample?.Invoke(account); + + bool logSummary = DistrictConfig.ReadBool("APB_LOG_MOVEMENT_SUMMARY", "LogMovementSummary", true); + bool interestingFlags = movement.MoveFlagsPresent || movement.OldMoveFlagsPresent; + + if (logSummary && (receivedCount == 1 || receivedCount % 30 == 0 || (ackSent && ackedCount == 1) || interestingFlags)) + { + DistrictLogger.Log(LogLevel.Info, "District Movement RX", + "account={0} packetId={1} rpcs={2} old={3} server={4} dual={5} consumed={6} trailing={7} ackTimestamp={8}{9:F6} ackSent={10} accel={11}({12},{13},{14}) loc={15}({16},{17},{18}) flags={19}0x{20:X2} oldFlags={21}0x{22:X2} roll={23}0x{24:X2} view={25}0x{26:X8} received={27} acked={28}", + account.GetId(), packet.PacketId, movement.RpcCount, movement.OldServerMoveCount, movement.ServerMoveCount, + movement.DualServerMoveCount, movement.ConsumedBits, movement.TrailingBits, + movement.HasTimeStamp ? "" : "", movement.HasTimeStamp ? movement.TimeStamp : 0.0f, ackSent ? 1 : 0, + movement.AccelerationPresent ? "" : "", movement.AccelerationX, movement.AccelerationY, movement.AccelerationZ, + movement.ClientLocationPresent ? "" : "", movement.ClientLocationX, movement.ClientLocationY, movement.ClientLocationZ, + movement.MoveFlagsPresent ? "" : "", movement.MoveFlags, + movement.OldMoveFlagsPresent ? "" : "", movement.OldMoveFlags, + movement.ClientRollPresent ? "" : "", movement.ClientRoll, + movement.ViewPresent ? "" : "", movement.View, + receivedCount, ackedCount); + } + + return true; + } + + /// Clears the pawn's winded flag after a detected hard landing (time-based). + public void MaybeSendHardLandingWindedRecovery(Account? account) + { + if (account == null) + return; + + ControllerMovementFeedbackState state; + bool due; + bool finalAttempt; + + lock (_movementGate) + { + if (!_movementStates.TryGetValue(account.GetId(), out state!) || !state.WindedClearPending) + return; + + if (Environment.TickCount64 < state.WindedClearTick) + return; + + due = true; + ++state.WindedClearAttempt; + finalAttempt = state.WindedClearAttempt >= state.WindedClearTotalAttempts; + + if (finalAttempt) + state.WindedClearPending = false; + else + state.WindedClearTick = Environment.TickCount64 + state.WindedClearRetryMilliseconds; + } + + if (!due) + return; + + bool sent = _handshake.SendProtectedPacket(state.Endpoint, account, + PacketBuilders.BuildActorBoolFieldPacket(account.AllocateServerPacketId(), DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(state.Endpoint, DistrictConfig.PawnChannel), + DistrictConfig.PawnIsWindedWireField(), DistrictConfig.PawnFieldMax, false), + "WINDED-CLEAR-RECOVERY"); + + DistrictLogger.Log(sent ? LogLevel.Success : LogLevel.Warn, "District Winded", + "Winded-clear recovery account={0} attempt={1}/{2} field={3} sent={4}.", + account.GetId(), state.WindedClearAttempt, state.WindedClearTotalAttempts, DistrictConfig.PawnIsWindedWireField(), sent ? 1 : 0); + } + + // ------------------------------------------------------------------ + // Character-data request answers + // ------------------------------------------------------------------ + + private void SendClientReceiveCharacterData(IPEndPoint endpoint, Account account, int requestedCharacterUid, uint packetId, ushort channelSequence) + { + var data = new CharacterDataPayload(); + data.CharacterFnMods[0] = 0; + data.WeaponPrimary = 0; + data.WeaponSecondary = 0; + data.WeaponGrenade = 0; + + bool sent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildClientReceiveCharacterDataPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.ClientReceiveCharacterDataWireField(), DistrictConfig.PlayerControllerFieldMax, data, false), + "CLIENT-RECEIVE-CHARACTER-DATA"); + + DistrictLogger.Log(sent ? LogLevel.Success : LogLevel.Error, "District Character Data", + "Answered ServerRequestCharacterData account={0} uid={1} requestPacketId={2} requestSeq={3} sent={4}.", + account.GetId(), requestedCharacterUid, packetId, channelSequence, sent ? 1 : 0); + } + + /// + /// Ported from IsAcceptedLocalCharacterRequest in DistrictServer.cpp: + /// rejects requests for another player's character unless + /// APB_ALLOW_OTHER_PLAYER_INFO_REQUESTS is enabled. Applied to the + /// stats/roles data senders exactly as the C++ does. + /// + private bool IsAcceptedLocalCharacterRequest(Account account, int requestedCharacterUid, string requestName, uint clientPacketId, ushort clientChannelSequence) + { + if (account == null) + return false; + + int localCharacterUid = (int)account.GetCharacterId(); + bool allowOtherCharacter = DistrictConfig.ReadBool("APB_ALLOW_OTHER_PLAYER_INFO_REQUESTS", "AllowOtherPlayerInfoRequests", false); + + if (!allowOtherCharacter && requestedCharacterUid != 0 && localCharacterUid != 0 && requestedCharacterUid != localCharacterUid) + { + DistrictLogger.Log(LogLevel.Warn, "District Player Info", + "Rejecting {0} account={1} requestedCharacterUID={2} localCharacterUID={3} clientPacketId={4} clientSeq={5}.", + requestName, account.GetId(), requestedCharacterUid, localCharacterUid, clientPacketId, clientChannelSequence); + return false; + } + + return true; + } + + private void SendClientReceiveCharacterStats(IPEndPoint endpoint, Account account, int requestedCharacterUid, uint packetId, ushort channelSequence) + { + if (!IsAcceptedLocalCharacterRequest(account, requestedCharacterUid, "ServerRequestCharacterStats", packetId, channelSequence)) + return; + + var stats = new CharacterStatsPayload(); + + bool sent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildClientReceiveCharacterStatsPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.ClientReceiveCharacterStatsWireField(), DistrictConfig.PlayerControllerFieldMax, stats, false), + "CLIENT-RECEIVE-CHARACTER-STATS"); + + DistrictLogger.Log(sent ? LogLevel.Success : LogLevel.Error, "District Character Stats", + "Answered ServerRequestCharacterStats account={0} uid={1} requestPacketId={2} requestSeq={3} sent={4}.", + account.GetId(), requestedCharacterUid, packetId, channelSequence, sent ? 1 : 0); + } + + private void SendClientReceiveCharacterRolesData(IPEndPoint endpoint, Account account, int requestedCharacterUid, uint packetId, ushort channelSequence) + { + if (!IsAcceptedLocalCharacterRequest(account, requestedCharacterUid, "ServerRequestCharacterRolesData", packetId, channelSequence)) + return; + + var roles = new CharacterRolesDataPayload(); + + bool sent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildClientReceiveCharacterRolesDataPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.ClientReceiveCharacterRolesDataWireField(), DistrictConfig.PlayerControllerFieldMax, roles, false), + "CLIENT-RECEIVE-CHARACTER-ROLES-DATA"); + + DistrictLogger.Log(sent ? LogLevel.Success : LogLevel.Error, "District Character Roles", + "Answered ServerRequestCharacterRolesData account={0} uid={1} requestPacketId={2} requestSeq={3} sent={4}.", + account.GetId(), requestedCharacterUid, packetId, channelSequence, sent ? 1 : 0); + } + + private void SendPawnCrouchState(IPEndPoint endpoint, Account account, bool crouched, uint packetId, ushort channelSequence, uint toggleCount) + { + // TODO(next milestone): the C++ mirrors the crouch booleans to the pawn + // (PawnCrouchState) with a dedicated packet builder. Log the toggle for + // now; the pawn property mirroring is a small follow-up. + DistrictLogger.Log(LogLevel.Info, "District Crouch", + "Crouch toggle mirrored account={0} crouched={1} toggle={2} sourcePacketId={3} (pawn property mirror is a follow-up).", + account.GetId(), crouched ? 1 : 0, toggleCount, packetId); + } + + // ------------------------------------------------------------------ + // Customisation replicator channel (7) request dispatch + // ------------------------------------------------------------------ + + /// + /// Decodes the dedicated cCustomisationReplicator actor channel before + /// controller traffic, ported from ProcessCustomisationReplicatorFeedback + /// in DistrictServer.cpp. The client drives the descriptor transfer with + /// ServerSendData(nBaseIndex) requests (field 21) and signals completion + /// with ServerNotifyOperationComplete (field 24); the server answers each + /// request with a ClientReceiveData chunk (field 22) and finally + /// ClientNotifyTransferComplete (field 23) when the client asks for the + /// descriptor end offset. + /// + public void ProcessCustomisationReplicatorFeedback(IPEndPoint endpoint, Account? account, Packet packet) + { + if (account == null) + return; + + ushort activeReplicatorChannel = DistrictConfig.CustomisationReplicatorChannel; + lock (_feedbackGate) + { + activeReplicatorChannel = GetOrCreateState(account.GetId()).CustomisationActiveReplicatorChannel; + } + + foreach (Bunch bunch in packet.Bunches) + { + if (bunch.Kind != BunchKind.Data || + bunch.ChannelIndex != activeReplicatorChannel || + bunch.DataBitCount == 0) + { + continue; + } + + if (!FieldDecoders.DecodeActorFieldIndex(bunch, DistrictConfig.CustomisationReplicatorFieldMax, + out uint fieldIndex, out int parameterBits, out string decodeError)) + { + DistrictLogger.Log(LogLevel.Warn, "District Character Customisation", + "Could not decode replicator channel={0} packetId={1} seq={2}: {3}", + activeReplicatorChannel, packet.PacketId, bunch.ChannelSequence, decodeError); + continue; + } + + if (fieldIndex == DistrictConfig.FieldReplicatorServerSendData) + { + HandleReplicatorServerSendData(endpoint, account, packet, bunch, activeReplicatorChannel, parameterBits); + continue; + } + + if (fieldIndex == DistrictConfig.FieldReplicatorServerNotifyOperationComplete) + { + HandleReplicatorServerNotifyOperationComplete(endpoint, account, packet, bunch, activeReplicatorChannel, parameterBits); + continue; + } + + DistrictLogger.Log(LogLevel.Info, "District Character Customisation", + "Replicator channel={0} account={1} packetId={2} seq={3} field={4} parameterBits={5}.", + activeReplicatorChannel, account.GetId(), packet.PacketId, bunch.ChannelSequence, fieldIndex, parameterBits); + } + } + + private void HandleReplicatorServerSendData(IPEndPoint endpoint, Account account, Packet packet, Bunch bunch, ushort activeReplicatorChannel, int parameterBits) + { + if (!FieldDecoders.DecodeActorIntRpc(bunch, + DistrictConfig.CustomisationReplicatorFieldMax, + DistrictConfig.FieldReplicatorServerSendData, + out int requestedBaseIndex, out int trailingBits, out string decodeError)) + { + DistrictLogger.Log(LogLevel.Error, "District Character Customisation", + "Failed to decode ServerSendData field={0} account={1} packetId={2} seq={3}: {4}", + DistrictConfig.FieldReplicatorServerSendData, account.GetId(), packet.PacketId, bunch.ChannelSequence, decodeError); + return; + } + + byte[] appearance = account.GetAppearance(); + byte[] transferPayload = LevelStreamingService.BuildCharacterCustomisationTransferPayload(appearance); + if (transferPayload.Length == 0) + { + DistrictLogger.Log(LogLevel.Error, "District Character Customisation", + "Ignoring ServerSendData for account {0} because the transfer payload is empty.", account.GetId()); + return; + } + + bool duplicateRequest; + bool transferStarted; + bool transferCompleted; + int requestBaseOffset; + lock (_feedbackGate) + { + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + transferStarted = state.CustomisationTransferStarted; + transferCompleted = state.CustomisationTransferCompleted; + requestBaseOffset = state.CustomisationRequestBaseOffset; + duplicateRequest = state.CustomisationLastRequestedBaseIndex == requestedBaseIndex; + state.CustomisationLastRequestedBaseIndex = requestedBaseIndex; + } + + DistrictLogger.Log(LogLevel.Success, "District Character Customisation", + "Received ServerSendData field={0} account={1} nBaseIndex={2} descriptorBytes={3} packetId={4} seq={5} parameterBits={6} trailingBits={7}{8}.", + DistrictConfig.FieldReplicatorServerSendData, account.GetId(), requestedBaseIndex, transferPayload.Length, + packet.PacketId, bunch.ChannelSequence, parameterBits, trailingBits, + duplicateRequest ? " (duplicate request; response resent)" : ""); + + if (!transferStarted) + { + DistrictLogger.Log(LogLevel.Warn, "District Character Customisation", + "Ignoring ServerSendData for account {0} because the replicator transfer has not been started.", account.GetId()); + return; + } + + bool allowRepeatedTransfer = DistrictConfig.ReadBool("APB_ALLOW_REPEATED_CUSTOMISATION_TRANSFER", "AllowRepeatedCustomisationTransfer", true); + bool normalizeRepeatedBaseIndices = DistrictConfig.ReadBool("APB_NORMALIZE_REPEATED_CUSTOMISATION_BASE_INDICES", "NormalizeRepeatedCustomisationBaseIndices", true); + int wireRequestedBaseIndex = requestedBaseIndex; + int normalizedBaseIndex = requestedBaseIndex; + + // A fresh request-specific replicator should restart its cursor at + // 256. Normalization remains only as a diagnostic fallback for a + // client that unexpectedly reports a cumulative cursor. + if (normalizeRepeatedBaseIndices && requestBaseOffset > 0 && requestedBaseIndex >= requestBaseOffset) + normalizedBaseIndex = requestedBaseIndex - requestBaseOffset; + + if (normalizedBaseIndex < 0 || (uint)normalizedBaseIndex > (uint)transferPayload.Length) + { + DistrictLogger.Log(LogLevel.Error, "District Character Customisation", + "Ignoring invalid ServerSendData wireBaseIndex={0} normalizedBaseIndex={1} requestBaseOffset={2} descriptorBytes={3} transferCompleted={4} allowRepeated={5} normalizeRepeated={6}.", + wireRequestedBaseIndex, normalizedBaseIndex, requestBaseOffset, transferPayload.Length, + transferCompleted ? 1 : 0, allowRepeatedTransfer ? 1 : 0, normalizeRepeatedBaseIndices ? 1 : 0); + return; + } + + if (wireRequestedBaseIndex != normalizedBaseIndex) + { + DistrictLogger.Log(LogLevel.Success, "District Character Customisation", + "Normalized repeated ServerSendData wireBaseIndex={0} requestBaseOffset={1} to sourceOffset={2} for account={3}.", + wireRequestedBaseIndex, requestBaseOffset, normalizedBaseIndex, account.GetId()); + } + + requestedBaseIndex = normalizedBaseIndex; + + int responseDelayMilliseconds = DistrictConfig.ReadInt("APB_CUSTOMISATION_CHUNK_DELAY_MS", "CustomisationChunkDelayMilliseconds", 0, 0, 1000); + if (responseDelayMilliseconds > 0) + Thread.Sleep(responseDelayMilliseconds); + + bool sent; + if ((uint)requestedBaseIndex == (uint)transferPayload.Length) + { + sent = SendCharacterCustomisationCompletion(endpoint, account, activeReplicatorChannel, "client requested descriptor end offset"); + } + else + { + sent = SendCharacterCustomisationChunk(endpoint, account, activeReplicatorChannel, requestedBaseIndex, + wireRequestedBaseIndex != normalizedBaseIndex ? "normalized repeated ServerSendData request" : "client ServerSendData request"); + } + + if (!sent) + { + DistrictLogger.Log(LogLevel.Error, "District Character Customisation", + "Failed responding to ServerSendData nBaseIndex={0} for account={1}.", requestedBaseIndex, account.GetId()); + } + } + + private void HandleReplicatorServerNotifyOperationComplete(IPEndPoint endpoint, Account account, Packet packet, Bunch bunch, ushort activeReplicatorChannel, int parameterBits) + { + bool firstReceipt = false; + bool shouldPossessAfterInitialCompletion = false; + bool markerReady = false; + uint transferGeneration = 0; + + lock (_feedbackGate) + { + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + transferGeneration = state.CustomisationTransferGeneration; + markerReady = state.SpawnZoneMarkerSent || + (DistrictConfig.IsActionDistrict(DistrictConfig.GetConfiguredDistrictMap()) && state.ServerSelectSpawnZoneSeen); + + if (!state.CustomisationTransferCompleted) + { + state.CustomisationTransferCompleted = true; + firstReceipt = true; + } + + shouldPossessAfterInitialCompletion = + firstReceipt && + transferGeneration == 0 && + markerReady && + (DistrictConfig.ReadBool("APB_AUTO_POSSESS_AFTER_INITIAL_CUSTOMISATION_COMPLETE", "AutoPossessAfterInitialCustomisationComplete", false) || + (DistrictConfig.IsActionDistrict(DistrictConfig.GetConfiguredDistrictMap()) && + state.ServerSelectSpawnZoneSeen && + DistrictConfig.ReadBool("APB_ENABLE_POSSESSION", "EnablePossession", false))) && + !state.PossessionSent; + + if (shouldPossessAfterInitialCompletion) + state.PossessionSent = true; + } + + DistrictLogger.Log(firstReceipt ? LogLevel.Success : LogLevel.Info, "District Character Customisation", + "Received ServerNotifyOperationComplete field={0} on replicator channel={1} account={2} packetId={3} seq={4} parameterBits={5}{6}. Request-driven descriptor transfer completed. generation={7} markerReady={8} possessAfterInitialComplete={9}.", + DistrictConfig.FieldReplicatorServerNotifyOperationComplete, activeReplicatorChannel, account.GetId(), + packet.PacketId, bunch.ChannelSequence, parameterBits, + firstReceipt ? " (first receipt)" : " (duplicate)", + transferGeneration, markerReady ? 1 : 0, shouldPossessAfterInitialCompletion ? 1 : 0); + + if (shouldPossessAfterInitialCompletion) + { + DistrictLogger.Log(LogLevel.Success, "District Character Customisation", + "Initial generation-0 customisation operation is complete and spawn markers are ready. Opening and possessing the pawn now, before spawn selection starts PlayerSpawnWaitOnStreaming."); + _pawnLifecycle.SendPawnAndPossess(endpoint, account); + } + } + + // ------------------------------------------------------------------ + // Customisation transfer (field 277 / action-district spawn select) + // ------------------------------------------------------------------ + + private static ushort CustomisationReplicatorChannelForGeneration(uint generation) + => (ushort)(DistrictConfig.CustomisationReplicatorChannel + (int)generation * DistrictConfig.CustomisationReplicatorChannelStride); + + private void HandleServerRequestCustomisation(IPEndPoint endpoint, Account account, Packet packet, Bunch bunch) + { + bool openFreshReplicator = false; + ushort replicatorChannel = DistrictConfig.CustomisationReplicatorChannel; + uint transferGeneration = 0; + + bool transferEnabled = DistrictConfig.ReadBool("APB_SEND_CHARACTER_CUSTOMISATION_TRANSFER", "SendCharacterCustomisationTransfer", true); + bool allowRepeatedTransfer = DistrictConfig.ReadBool("APB_ALLOW_REPEATED_CUSTOMISATION_TRANSFER", "AllowRepeatedCustomisationTransfer", true); + + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + lock (_feedbackGate) + { + if (transferEnabled && !state.CustomisationTransferStarted) + { + state.CustomisationTransferStarted = true; + state.CustomisationTransferGeneration = 0; + state.CustomisationActiveReplicatorChannel = CustomisationReplicatorChannelForGeneration(0); + state.CustomisationRequestBaseOffset = 0; + state.CustomisationTransferSent = false; + state.CustomisationCompletionSent = false; + state.CustomisationTransferCompleted = false; + state.CustomisationLastRequestedBaseIndex = -1; + state.CustomisationLastSentBaseIndex = -1; + openFreshReplicator = true; + } + else if (transferEnabled && allowRepeatedTransfer && state.CustomisationTransferStarted && state.CustomisationTransferCompleted) + { + ++state.CustomisationTransferGeneration; + state.CustomisationActiveReplicatorChannel = CustomisationReplicatorChannelForGeneration(state.CustomisationTransferGeneration); + state.CustomisationRequestBaseOffset = 0; + state.CustomisationTransferSent = false; + state.CustomisationCompletionSent = false; + state.CustomisationTransferCompleted = false; + state.CustomisationLastRequestedBaseIndex = -1; + state.CustomisationLastSentBaseIndex = -1; + openFreshReplicator = true; + } + + replicatorChannel = state.CustomisationActiveReplicatorChannel; + transferGeneration = state.CustomisationTransferGeneration; + } + + DistrictLogger.Log(openFreshReplicator ? LogLevel.Success : LogLevel.Warn, "District Character Customisation", + "Client sent ServerRequestCustomisation field=277 for account={0} packetId={1} seq={2}. transferAction={3} allowRepeated={4} generation={5} replicatorChannel={6}.", + account.GetId(), packet.PacketId, bunch.ChannelSequence, + openFreshReplicator ? "open-fresh-replicator" : "none", allowRepeatedTransfer ? 1 : 0, transferGeneration, replicatorChannel); + + if (!openFreshReplicator) + return; + + bool sentTransfer = SendCharacterCustomisationTransfer(endpoint, account, replicatorChannel, transferGeneration); + + lock (_feedbackGate) + { + state.CustomisationTransferSent = sentTransfer; + if (!sentTransfer && transferGeneration == 0) + state.CustomisationTransferStarted = false; + } + + DistrictLogger.Log(sentTransfer ? LogLevel.Success : LogLevel.Error, "District Character Customisation", + "Character descriptor transfer account={0} generation={1} channel={2} result={3}.", + account.GetId(), transferGeneration, replicatorChannel, sentTransfer ? "sent" : "failed"); + } + + private bool SendCharacterCustomisationTransfer(IPEndPoint endpoint, Account account, ushort replicatorChannel, uint transferGeneration) + { + if (account == null) + return false; + + byte[] appearance = account.GetAppearance(); + byte[] transferPayload = LevelStreamingService.BuildCharacterCustomisationTransferPayload(appearance); + + if (transferPayload.Length == 0) + { + DistrictLogger.Log(LogLevel.Error, "District Character Customisation", + "Cannot answer ServerRequestCustomisation for account={0}: the WorldServer handoff contains no appearance bytes.", account.GetId()); + return false; + } + + uint archetype = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.CustomisationReplicatorArchetypeObjectIndex); + + DistrictConfig.ReadControllerLocation(out float actorX, out float actorY, out float actorZ); + + uint openPacketId = account.AllocateServerPacketId(); + if (!_handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorOpenPacket(openPacketId, replicatorChannel, + ChannelSequenceAllocator.Allocate(endpoint, replicatorChannel), archetype, actorX, actorY, actorZ), + "CUSTOMISATION-REPLICATOR-OPEN")) + { + return false; + } + + DistrictLogger.Log(LogLevel.Success, "District Character Customisation", + "Opened fresh cCustomisationReplicator channel={0} generation={1} globalNetIndex={2} fieldMax={3} descriptorBytes={4} rawAppearanceBytes={5} descriptorPreview={6}.", + replicatorChannel, transferGeneration, archetype, DistrictConfig.CustomisationReplicatorFieldMax, + transferPayload.Length, appearance.Length, Diagnostics.Hex(transferPayload, 32)); + + int replicatorSetupDelayMilliseconds = DistrictConfig.ReadInt("APB_CUSTOMISATION_REPLICATOR_SETUP_DELAY_MS", "CustomisationReplicatorSetupDelayMilliseconds", 50, 0, 2000); + if (replicatorSetupDelayMilliseconds > 0) + Thread.Sleep(replicatorSetupDelayMilliseconds); + + if (DistrictConfig.ReadBool("APB_CUSTOMISATION_REPLICATOR_SET_OWNER", "CustomisationReplicatorSetOwner", true)) + { + if (!_handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorObjectFieldPacket(account.AllocateServerPacketId(), replicatorChannel, + ChannelSequenceAllocator.Allocate(endpoint, replicatorChannel), + DistrictConfig.FieldReplicatorOwner, DistrictConfig.CustomisationReplicatorFieldMax, DistrictConfig.ControllerChannel), + "CUSTOMISATION-REPLICATOR-OWNER")) + { + return false; + } + + if (replicatorSetupDelayMilliseconds > 0) + Thread.Sleep(replicatorSetupDelayMilliseconds); + } + + if (DistrictConfig.ReadBool("APB_CUSTOMISATION_REPLICATOR_SET_NET_OWNER", "CustomisationReplicatorSetNetOwner", true)) + { + if (!_handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorBoolFieldPacket(account.AllocateServerPacketId(), replicatorChannel, + ChannelSequenceAllocator.Allocate(endpoint, replicatorChannel), + DistrictConfig.FieldReplicatorNetOwner, DistrictConfig.CustomisationReplicatorFieldMax, true), + "CUSTOMISATION-REPLICATOR-NET-OWNER")) + { + return false; + } + + if (replicatorSetupDelayMilliseconds > 0) + Thread.Sleep(replicatorSetupDelayMilliseconds); + } + + DistrictLogger.Log(LogLevel.Info, "District Character Customisation", + "Starting native request-driven transfer: send offset 0 now, then wait for ServerSendData(nBaseIndex) requests at 256, 512, 768 and {0} before completion.", + transferPayload.Length); + + return SendCharacterCustomisationChunk(endpoint, account, replicatorChannel, 0, + transferGeneration == 0 ? "initial ServerSendData(0) equivalent" : "fresh repeated replicator initial offset 0"); + } + + private static string CustomisationByteArrayWireModeName(FixedByteArrayWireMode mode) + { + switch (mode) + { + case FixedByteArrayWireMode.PerElementDelta: + return "per-element-delta"; + case FixedByteArrayWireMode.SinglePresenceRaw: + return "single-presence-raw"; + case FixedByteArrayWireMode.Raw: + return "raw"; + default: + return "unknown"; + } + } + + /// + /// Sends ClientNotifyTransferComplete (field 23) on the replicator + /// channel, ported from SendCharacterCustomisationCompletion in + /// DistrictServer.cpp. Sent only when the client requests an nBaseIndex + /// equal to the descriptor size, signalling the transfer is done. + /// + private bool SendCharacterCustomisationCompletion(IPEndPoint endpoint, Account account, ushort replicatorChannel, string reason) + { + if (account == null) + return false; + + uint completePacketId = account.AllocateServerPacketId(); + byte[] completePacket = PacketBuilders.BuildActorObjectRpcPacket( + completePacketId, + replicatorChannel, + ChannelSequenceAllocator.Allocate(endpoint, replicatorChannel), + DistrictConfig.FieldReplicatorClientNotifyTransferComplete, + DistrictConfig.CustomisationReplicatorFieldMax, + DistrictConfig.ControllerChannel); + + if (!_handshake.SendProtectedPacket(endpoint, account, completePacket, "CUSTOMISATION-NOTIFY-TRANSFER-COMPLETE")) + return false; + + lock (_feedbackGate) + { + GetOrCreateState(account.GetId()).CustomisationCompletionSent = true; + } + + DistrictLogger.Log(LogLevel.Success, "District Character Customisation", + "Sent ClientNotifyTransferComplete channel={0} field={1} ownerChannel={2} serverPacketId={3} reason={4}. Completion is sent only after the client requests nBaseIndex equal to the descriptor size.", + replicatorChannel, DistrictConfig.FieldReplicatorClientNotifyTransferComplete, DistrictConfig.ControllerChannel, + completePacketId, reason); + + return true; + } + + private bool SendCharacterCustomisationChunk(IPEndPoint endpoint, Account account, ushort replicatorChannel, int baseIndex, string reason) + { + byte[] appearance = account.GetAppearance(); + byte[] transferPayload = LevelStreamingService.BuildCharacterCustomisationTransferPayload(appearance); + + if (transferPayload.Length == 0) + { + DistrictLogger.Log(LogLevel.Error, "District Character Customisation", + "Cannot send customisation chunk for account={0}: appearance data is empty.", account.GetId()); + return false; + } + + if (baseIndex >= transferPayload.Length) + { + DistrictLogger.Log(LogLevel.Error, "District Character Customisation", + "Refusing out-of-range customisation chunk for account={0}: baseIndex={1} transferBytes={2} rawAppearanceBytes={3}.", + account.GetId(), baseIndex, transferPayload.Length, appearance.Length); + return false; + } + + int validBytes = Math.Min((int)DistrictConfig.CustomisationDataPacketSize, transferPayload.Length - baseIndex); + var packet = new byte[256]; + Array.Copy(transferPayload, baseIndex, packet, 0, validBytes); + + FixedByteArrayWireMode wireMode = FixedByteArrayWireMode.SinglePresenceRaw; + string modeName = DistrictConfig.ReadSetting("APB_CUSTOMISATION_BYTE_ARRAY_WIRE_MODE", "CustomisationByteArrayWireMode", "single-presence-raw").ToLowerInvariant(); + if (modeName == "per-element-delta" || modeName == "element-delta") + wireMode = FixedByteArrayWireMode.PerElementDelta; + else if (modeName == "raw" || modeName == "raw-no-presence") + wireMode = FixedByteArrayWireMode.Raw; + + uint packetId = account.AllocateServerPacketId(); + + if (!_handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildClientReceiveDataPacket(packetId, replicatorChannel, + ChannelSequenceAllocator.Allocate(endpoint, replicatorChannel), + DistrictConfig.FieldReplicatorClientReceiveData, DistrictConfig.CustomisationReplicatorFieldMax, + validBytes, packet, wireMode), + $"CUSTOMISATION-RECEIVE-DATA-{baseIndex}")) + { + return false; + } + + ControllerStreamingFeedbackState state = GetOrCreateState(account.GetId()); + lock (_feedbackGate) + { + state.CustomisationLastSentBaseIndex = baseIndex; + } + + DistrictLogger.Log(LogLevel.Success, "District Character Customisation", + "Sent request-driven ClientReceiveData channel={0} field={1} sourceOffset={2} nCount={3} nextExpectedBaseIndex={4} transferBytes={5} rawAppearanceBytes={6} wireMode={7} serverPacketId={8} reason={9}.", + replicatorChannel, DistrictConfig.FieldReplicatorClientReceiveData, baseIndex, validBytes, baseIndex + validBytes, + transferPayload.Length, appearance.Length, wireMode, packetId, reason); + + return true; + } +} diff --git a/DistrictServerCSharp/Core/Lifecycle.cs b/DistrictServerCSharp/Core/Lifecycle.cs new file mode 100644 index 0000000..ca42b62 --- /dev/null +++ b/DistrictServerCSharp/Core/Lifecycle.cs @@ -0,0 +1,25 @@ +namespace DistrictServerCSharp.Core; + +/// +/// Per-account lifecycle reset hooks, called from every world handoff exactly +/// where the C++ DistrictServer.cpp calls ResetGriStartupState / +/// ResetPawnAckGatedSequenceState. The UdpListener wires these delegates to +/// the live GRI, pawn and controller services at startup. +/// +public static class Lifecycle +{ + /// Clears GRI startup sets (opened/acked/matchStartSent/ackTicks). + public static Action? ResetGriStartupAction; + + /// Clears the ACK-gated pawn sequence state machine. + public static Action? ResetPawnAckGatedAction; + + /// Clears controller feedback + movement state (JOIN reconnect). + public static Action? ResetControllerFeedbackAction; + + public static void ResetGriStartup(uint accountId) => ResetGriStartupAction?.Invoke(accountId); + + public static void ResetPawnAckGated(uint accountId) => ResetPawnAckGatedAction?.Invoke(accountId); + + public static void ResetControllerFeedback(uint accountId) => ResetControllerFeedbackAction?.Invoke(accountId); +} diff --git a/DistrictServerCSharp/Core/PacketDiagnostics.cs b/DistrictServerCSharp/Core/PacketDiagnostics.cs new file mode 100644 index 0000000..4d90c5b --- /dev/null +++ b/DistrictServerCSharp/Core/PacketDiagnostics.cs @@ -0,0 +1,101 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Crypto; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Core; + +/// +/// Off-hot-path diagnostics ported from DistrictServer.cpp: per-packet binary +/// capture (SaveCapture) and the XTEA-mode forensic probe +/// (DiagnoseEncryptedPacket) used when a post-AUTH datagram fails to parse. +/// Both are best-effort and never block or break the UDP loop. +/// +public static class PacketDiagnostics +{ + private static int _captureIndex; + + /// + /// Writes one raw .bin per packet to the Packets\ folder when + /// APB_CAPTURE_PACKETS=1. Ported from SaveCapture() in + /// DistrictServer.cpp (filename DistrictUdp_%06u_%s_%u.bin, direction + + /// remote port). + /// + public static void SaveCapture(string direction, IPEndPoint endpoint, byte[] data) + { + if (!DistrictConfig.PacketCaptureEnabled()) + return; + + if (string.IsNullOrEmpty(direction) || data == null || data.Length == 0 || endpoint == null) + return; + + int captureId = Interlocked.Increment(ref _captureIndex); + string filename = Path.Combine("Packets", $"DistrictUdp_{captureId:D6}_{direction}_{endpoint.Port}.bin"); + + try + { + Directory.CreateDirectory("Packets"); + File.WriteAllBytes(filename, data); + } + catch (IOException) + { + // Capture is best-effort forensics; never break the UDP loop. + } + catch (UnauthorizedAccessException) + { + } + } + + /// + /// When the plaintext parse fails on a keyed endpoint, try every XTEA + /// mode (ECB/CBC x little/big endian) and report which one produces valid + /// UE3 framing. Ported from DiagnoseEncryptedPacket() in + /// DistrictServer.cpp; limited to the first 4 unparsed packets per + /// connection so a sustained mismatch cannot spam the log. + /// + public static void DiagnoseEncryptedPacket(byte[] data, Account? account, IPEndPoint endpoint) + { + if (account == null) + { + DistrictLogger.Log(LogLevel.Warn, "District UDP", + "Unparsed packet from unassociated endpoint {0}", HandshakeService.EndpointText(endpoint)); + return; + } + + uint receiveNumber = account.IncrementUdpReceiveCount(); + if (receiveNumber > 4) + return; + + foreach (XteaEndian endian in new[] { XteaEndian.Little, XteaEndian.Big }) + { + foreach (bool cbc in new[] { false, true }) + { + byte[] plaintext = (byte[])data.Clone(); + bool decrypted; + if (cbc) + decrypted = Xtea.DecryptCbc(plaintext, account.GetEncryptionKey(), endian, new byte[8]); + else + decrypted = Xtea.DecryptEcb(plaintext, account.GetEncryptionKey(), endian); + + string mode = (cbc ? "xtea-cbc-zero-" : "xtea-ecb-") + (endian == XteaEndian.Little ? "le" : "be"); + + var candidate = new Packet(); + if (decrypted && PacketParser.ParsePacket(plaintext, plaintext.Length, candidate)) + { + DistrictLogger.Log(LogLevel.Success, "XTEA diagnostic", + "{0} produced valid UE3 framing for account {1}: {2} | plaintext={3}", + mode, account.GetId(), Diagnostics.DescribePacket(candidate), Diagnostics.Hex(plaintext, 96)); + return; + } + + DistrictLogger.Log(LogLevel.Debug, "XTEA diagnostic", + "{0} did not produce valid UE3 framing for account {1}; first bytes={2}", + mode, account.GetId(), + decrypted ? Diagnostics.Hex(plaintext, 32) : ""); + } + } + } +} diff --git a/DistrictServerCSharp/Core/ReliableQueue.cs b/DistrictServerCSharp/Core/ReliableQueue.cs new file mode 100644 index 0000000..87f86fb --- /dev/null +++ b/DistrictServerCSharp/Core/ReliableQueue.cs @@ -0,0 +1,191 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Core; + +/// One server→client reliable packet awaiting the client's ACK. +public sealed class PendingServerReliable +{ + public IPEndPoint Endpoint = null!; + public byte[] ClearPacket = Array.Empty(); + public string Label = ""; + public long LastSendTick; + public int SendCount; +} + +/// +/// Reliable server→client packet tracking with retry, ported from the +/// g_pendingServerReliables machinery in DistrictServer.cpp. Every reliable +/// startup packet is tracked until the client ACKs it; un-ACKed packets are +/// retried on a timer and dropped (with a loud log) once their send budget is +/// exhausted. Spawn-zone bridge opens and HUD markers get a gentler retry +/// budget because this client build accepts them without producing ACKs. +/// +public sealed class ReliableQueue +{ + private readonly HandshakeService _handshake; + private readonly object _gate = new(); + private readonly Dictionary<(uint AccountId, uint ServerPacketId), PendingServerReliable> _pending = new(); + + /// Raised when the client ACKs a tracked reliable packet. + public Action? OnAcknowledged; + + public ReliableQueue(HandshakeService handshake) => _handshake = handshake; + + public bool SendTrackedReliablePacket(IPEndPoint endpoint, Account account, uint serverPacketId, byte[] clearPacket, string label) + { + if (account == null) + return false; + + if (!_handshake.SendProtectedPacket(endpoint, account, clearPacket, label)) + return false; + + var pending = new PendingServerReliable + { + Endpoint = endpoint, + ClearPacket = clearPacket, + Label = label, + LastSendTick = Environment.TickCount64, + SendCount = 1 + }; + + lock (_gate) + { + _pending[(account.GetId(), serverPacketId)] = pending; + } + + DistrictLogger.Log(LogLevel.Info, "District Reliable TX", + "Tracking account={0} serverPacketId={1} label={2} until client ACK.", account.GetId(), serverPacketId, label); + + return true; + } + + /// + /// Erases tracked reliables the client ACKed and reports each one through + /// so the GRI and pawn ACK-gated state + /// machines can advance. + /// + public void ProcessServerPacketAcknowledgements(Account? account, Packet packet) + { + if (account == null) + return; + + foreach (Bunch bunch in packet.Bunches) + { + if (bunch.Kind != BunchKind.Ack) + continue; + + PendingServerReliable? acknowledged = null; + lock (_gate) + { + if (_pending.Remove((account.GetId(), bunch.AckPacketId), out PendingServerReliable? pending)) + acknowledged = pending; + } + + if (acknowledged == null) + continue; + + DistrictLogger.Log(LogLevel.Success, "District Reliable TX", + "Client ACKed account={0} serverPacketId={1} label={2} sends={3}.", + account.GetId(), bunch.AckPacketId, acknowledged.Label, acknowledged.SendCount); + + OnAcknowledged?.Invoke(account.GetId(), bunch.AckPacketId, acknowledged); + } + } + + private static bool IsSpawnZonePacket(string label) + => label.StartsWith("SPAWN-ZONE-ACTOR-BRIDGE-OPEN-", StringComparison.Ordinal) || + label.StartsWith("CLIENT-REPLICATE-HUD-MARKER-", StringComparison.Ordinal); + + /// Retries due un-ACKed reliables for the account, dropping exhausted ones. + public void RetryPendingServerReliables(Account? account) + { + if (account == null) + return; + + int retryMilliseconds = DistrictConfig.ReadInt("APB_SERVER_RELIABLE_RETRY_MS", "ServerReliableRetryMilliseconds", 750, 100, 10000); + int maxSends = DistrictConfig.ReadInt("APB_SERVER_RELIABLE_MAX_SENDS", "ServerReliableMaxSends", 8, 1, 50); + int spawnZoneMaxSends = DistrictConfig.ReadInt("APB_SPAWN_ZONE_RELIABLE_MAX_SENDS", "SpawnZoneReliableMaxSends", 2, 1, 8); + + var retries = new List<(uint PacketId, IPEndPoint Endpoint, byte[] ClearPacket, string Label, int SendCount)>(); + var exhausted = new List<(uint PacketId, string Label)>(); + + long now = Environment.TickCount64; + + lock (_gate) + { + foreach (KeyValuePair<(uint, uint), PendingServerReliable> entry in _pending.ToArray()) + { + if (entry.Key.Item1 != account.GetId()) + continue; + + PendingServerReliable pending = entry.Value; + + if (now - pending.LastSendTick < retryMilliseconds) + continue; + + int effectiveMaxSends = IsSpawnZonePacket(pending.Label) ? spawnZoneMaxSends : maxSends; + + if (pending.SendCount >= effectiveMaxSends) + { + exhausted.Add((entry.Key.Item2, pending.Label)); + _pending.Remove(entry.Key); + continue; + } + + ++pending.SendCount; + pending.LastSendTick = now; + + retries.Add((entry.Key.Item2, pending.Endpoint, pending.ClearPacket, pending.Label, pending.SendCount)); + } + } + + foreach ((uint packetId, string label) in exhausted) + { + DistrictLogger.Log(LogLevel.Error, "District Reliable TX", + "No ACK after maximum sends: account={0} serverPacketId={1} label={2}. The startup sequence cannot be trusted past this point.", + account.GetId(), packetId, label); + } + + foreach ((uint packetId, IPEndPoint endpoint, byte[] clearPacket, string label, int sendCount) in retries) + { + string retryLabel = $"{label}-RETRY-{sendCount}"; + + bool sent = _handshake.SendProtectedPacket(endpoint, account, clearPacket, retryLabel); + + DistrictLogger.Log(sent ? LogLevel.Warn : LogLevel.Error, "District Reliable TX", + "Retransmit account={0} serverPacketId={1} label={2} send={3}/{4} sent={5}.", + account.GetId(), packetId, label, sendCount, maxSends, sent ? 1 : 0); + } + } + + /// Drops tracked reliables whose labels start with any of the prefixes. + public void CancelPendingReliablesByLabelPrefix(uint accountId, IReadOnlyList prefixes) + { + lock (_gate) + { + foreach (KeyValuePair<(uint, uint), PendingServerReliable> entry in _pending.ToArray()) + { + if (entry.Key.Item1 != accountId) + continue; + + bool cancel = false; + foreach (string prefix in prefixes) + { + if (entry.Value.Label.StartsWith(prefix, StringComparison.Ordinal)) + { + cancel = true; + break; + } + } + + if (cancel) + _pending.Remove(entry.Key); + } + } + } +} diff --git a/DistrictServerCSharp/Core/SelectedSpawnLocations.cs b/DistrictServerCSharp/Core/SelectedSpawnLocations.cs new file mode 100644 index 0000000..3fcc02b --- /dev/null +++ b/DistrictServerCSharp/Core/SelectedSpawnLocations.cs @@ -0,0 +1,40 @@ +namespace DistrictServerCSharp.Core; + +/// +/// Per-account selected spawn locations, ported from g_selectedSpawnLocations +/// in DistrictServer.cpp. Written when the client picks a spawn zone (field +/// 371) and read by SendPawnAndPossess so the pawn spawns where the player +/// chose. +/// +public static class SelectedSpawnLocations +{ + private static readonly object Gate = new(); + private static readonly Dictionary Locations = new(); + + public static void Set(uint accountId, float x, float y, float z) + { + lock (Gate) + { + Locations[accountId] = (x, y, z); + } + } + + public static bool TryGet(uint accountId, out float x, out float y, out float z) + { + lock (Gate) + { + if (Locations.TryGetValue(accountId, out (float X, float Y, float Z) location)) + { + x = location.X; + y = location.Y; + z = location.Z; + return true; + } + } + + x = 0; + y = 0; + z = 0; + return false; + } +} diff --git a/DistrictServerCSharp/Core/TransportState.cs b/DistrictServerCSharp/Core/TransportState.cs new file mode 100644 index 0000000..2826c4b --- /dev/null +++ b/DistrictServerCSharp/Core/TransportState.cs @@ -0,0 +1,137 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Net; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Core; + +/// +/// Per-account client transport state, ported from g_transportStates + +/// RecordClientTransportPacket/RecordClientTransportAck in DistrictServer.cpp. +/// Tracks the endpoint, last client packet id, last receive tick, and last ACK +/// tick so the keep-alive ACK path can throttle to APB_KEEPALIVE_ACK_SECONDS +/// instead of acking every packet. +/// +public sealed class TransportState +{ + private readonly HandshakeService _handshake; + private readonly object _gate = new(); + private readonly Dictionary _states = new(); + + public sealed class ClientTransportState + { + public uint EndpointAddress; + public ushort EndpointPort; + public uint LastClientPacketId; + public long LastReceiveTick; + public long LastAckTick; + } + + public TransportState(HandshakeService handshake) + { + _handshake = handshake; + } + + /// Records a received client packet's endpoint/id/tick. Ported from RecordClientTransportPacket. + public void RecordClientTransportPacket(Account? account, IPEndPoint endpoint, uint packetId) + { + if (account == null) + return; + + lock (_gate) + { + ClientTransportState state = GetOrCreate(account.GetId()); + state.EndpointAddress = EndpointAddress.ToWireValue(endpoint.Address); + state.EndpointPort = (ushort)endpoint.Port; + state.LastClientPacketId = packetId; + state.LastReceiveTick = Environment.TickCount64; + } + } + + /// Records that an ACK was sent to the account. Ported from RecordClientTransportAck. + public void RecordClientTransportAck(Account? account) + { + if (account == null) + return; + + lock (_gate) + { + GetOrCreate(account.GetId()).LastAckTick = Environment.TickCount64; + } + } + + /// + /// ACKs reliable actor-channel traffic immediately (the client retransmits + /// reliable bunches until the containing packet is acknowledged), and sends + /// a keep-alive ACK at most once per APB_KEEPALIVE_ACK_SECONDS otherwise. + /// Ported from MaybeSendTransportAck. + /// + public bool MaybeSendTransportAck(IPEndPoint endpoint, Account? account, Packet packet) + { + if (account == null) + return false; + + RecordClientTransportPacket(account, endpoint, packet.PacketId); + + bool hasReliableActorData = false; + foreach (Bunch bunch in packet.Bunches) + { + if (bunch.Kind != BunchKind.Data || !bunch.Reliable || bunch.ChannelIndex == 0) + continue; + + hasReliableActorData = true; + + if (DistrictConfig.ReadBool("APB_LOG_RELIABLE_ACTOR_TRAFFIC", "LogReliableActorTraffic", true)) + { + DistrictLogger.Log(LogLevel.Info, "District Actor RX", + "account={0} packetId={1} rel=1 ch={2} seq={3} open={4} close={5} bits={6} raw={7}", + account.GetId(), packet.PacketId, bunch.ChannelIndex, bunch.ChannelSequence, + bunch.Open ? 1 : 0, bunch.Close ? 1 : 0, bunch.DataBitCount, + Diagnostics.Hex(bunch.RawData, 96)); + } + } + + if (hasReliableActorData && + DistrictConfig.ReadBool("APB_ACK_RELIABLE_ACTOR_PACKETS", "AckReliableActorPackets", true)) + { + bool sent = _handshake.SendAck(endpoint, account, packet.PacketId, "ACK-RELIABLE-ACTOR"); + if (sent) + RecordClientTransportAck(account); + return sent; + } + + int keepAliveSeconds = DistrictConfig.ReadInt("APB_KEEPALIVE_ACK_SECONDS", "KeepAliveAckSeconds", 5, 1, 60); + + long lastAckTick = 0; + lock (_gate) + { + if (_states.TryGetValue(account.GetId(), out ClientTransportState? state)) + lastAckTick = state.LastAckTick; + } + + long now = Environment.TickCount64; + + if (lastAckTick == 0 || now - lastAckTick >= (long)keepAliveSeconds * 1000) + { + bool sent = _handshake.SendAck(endpoint, account, packet.PacketId, "ACK-KEEPALIVE"); + if (sent) + RecordClientTransportAck(account); + return sent; + } + + return false; + } + + private ClientTransportState GetOrCreate(uint accountId) + { + if (!_states.TryGetValue(accountId, out ClientTransportState? state)) + { + state = new ClientTransportState(); + _states[accountId] = state; + } + return state; + } +} diff --git a/DistrictServerCSharp/Crypto/Xtea.cs b/DistrictServerCSharp/Crypto/Xtea.cs new file mode 100644 index 0000000..4fbd58e --- /dev/null +++ b/DistrictServerCSharp/Crypto/Xtea.cs @@ -0,0 +1,177 @@ +namespace DistrictServerCSharp.Crypto; + +public enum XteaEndian +{ + Little, + Big +} + +/// +/// XTEA block cipher as used by the APB client handshake, ported from +/// Xtea.cpp. Operates on 8-byte blocks with a 16-byte key, optionally chained +/// in ECB or CBC mode, in little- or big-endian word order. +/// +public static class Xtea +{ + private const uint Delta = 0x9E3779B9u; + + private static uint Read32(ReadOnlySpan value, XteaEndian endian) + { + if (endian == XteaEndian.Little) + { + return (uint)value[0] | + ((uint)value[1] << 8) | + ((uint)value[2] << 16) | + ((uint)value[3] << 24); + } + + return ((uint)value[0] << 24) | + ((uint)value[1] << 16) | + ((uint)value[2] << 8) | + (uint)value[3]; + } + + private static void Write32(Span value, uint input, XteaEndian endian) + { + if (endian == XteaEndian.Little) + { + value[0] = (byte)input; + value[1] = (byte)(input >> 8); + value[2] = (byte)(input >> 16); + value[3] = (byte)(input >> 24); + return; + } + + value[0] = (byte)(input >> 24); + value[1] = (byte)(input >> 16); + value[2] = (byte)(input >> 8); + value[3] = (byte)input; + } + + private static void ReadKey(ReadOnlySpan key, XteaEndian endian, Span output) + { + for (int index = 0; index < 4; ++index) + output[index] = Read32(key.Slice(index * 4, 4), endian); + } + + public static void EncryptBlock(Span block, ReadOnlySpan key, XteaEndian endian) + { + Span keys = stackalloc uint[4]; + ReadKey(key, endian, keys); + + uint value0 = Read32(block, endian); + uint value1 = Read32(block.Slice(4, 4), endian); + uint sum = 0; + + for (int round = 0; round < 32; ++round) + { + value0 += (((value1 << 4) ^ (value1 >> 5)) + value1) ^ + (sum + keys[(int)(sum & 3u)]); + sum += Delta; + value1 += (((value0 << 4) ^ (value0 >> 5)) + value0) ^ + (sum + keys[(int)((sum >> 11) & 3u)]); + } + + Write32(block, value0, endian); + Write32(block.Slice(4, 4), value1, endian); + } + + public static void DecryptBlock(Span block, ReadOnlySpan key, XteaEndian endian) + { + Span keys = stackalloc uint[4]; + ReadKey(key, endian, keys); + + uint value0 = Read32(block, endian); + uint value1 = Read32(block.Slice(4, 4), endian); + // Delta * 32 overflows uint at compile time in checked mode; the C++ + // original wraps at runtime, so do the same explicitly. + uint sum = unchecked(Delta * 32u); + + for (int round = 0; round < 32; ++round) + { + value1 -= (((value0 << 4) ^ (value0 >> 5)) + value0) ^ + (sum + keys[(int)((sum >> 11) & 3u)]); + sum -= Delta; + value0 -= (((value1 << 4) ^ (value1 >> 5)) + value1) ^ + (sum + keys[(int)(sum & 3u)]); + } + + Write32(block, value0, endian); + Write32(block.Slice(4, 4), value1, endian); + } + + public static bool EncryptEcb(Span data, ReadOnlySpan key, XteaEndian endian) + { + if (data.Length == 0 || data.Length % 8 != 0) + return false; + + for (int offset = 0; offset < data.Length; offset += 8) + EncryptBlock(data.Slice(offset, 8), key, endian); + + return true; + } + + public static bool DecryptEcb(Span data, ReadOnlySpan key, XteaEndian endian) + { + if (data.Length == 0 || data.Length % 8 != 0) + return false; + + for (int offset = 0; offset < data.Length; offset += 8) + DecryptBlock(data.Slice(offset, 8), key, endian); + + return true; + } + + public static bool EncryptCbc(Span data, ReadOnlySpan key, XteaEndian endian, ReadOnlySpan iv) + { + if (data.Length == 0 || data.Length % 8 != 0) + return false; + + Span previous = stackalloc byte[8]; + iv.CopyTo(previous); + + for (int offset = 0; offset < data.Length; offset += 8) + { + Span block = data.Slice(offset, 8); + for (int index = 0; index < 8; ++index) + block[index] ^= previous[index]; + + EncryptBlock(block, key, endian); + block.CopyTo(previous); + } + + return true; + } + + public static bool DecryptCbc(Span data, ReadOnlySpan key, XteaEndian endian, ReadOnlySpan iv) + { + if (data.Length == 0 || data.Length % 8 != 0) + return false; + + Span previous = stackalloc byte[8]; + iv.CopyTo(previous); + + Span ciphertext = stackalloc byte[8]; + for (int offset = 0; offset < data.Length; offset += 8) + { + Span block = data.Slice(offset, 8); + block.CopyTo(ciphertext); + + DecryptBlock(block, key, endian); + + for (int index = 0; index < 8; ++index) + block[index] ^= previous[index]; + + ciphertext.CopyTo(previous); + } + + return true; + } + + public static string Name(XteaEndian endian) + { + return endian == XteaEndian.Little + ? "xtea-ecb-le" + : "xtea-ecb-be"; + } +} diff --git a/DistrictServerCSharp/DistrictServerCSharp.csproj b/DistrictServerCSharp/DistrictServerCSharp.csproj new file mode 100644 index 0000000..5b189d7 --- /dev/null +++ b/DistrictServerCSharp/DistrictServerCSharp.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + latest + DistrictServerCSharp + DistrictServer + DistrictServer (C# port) + C# port of the APB-EMU DistrictServer: UE3 wire protocol, handshake, replication, spawn/pawn lifecycle. + + + diff --git a/DistrictServerCSharp/Drive/DriveHarness.cs b/DistrictServerCSharp/Drive/DriveHarness.cs new file mode 100644 index 0000000..7b11269 --- /dev/null +++ b/DistrictServerCSharp/Drive/DriveHarness.cs @@ -0,0 +1,526 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Net; +using DistrictServerCSharp.Protocol; +using DistrictServerCSharp.Streaming; + +namespace DistrictServerCSharp.Drive; + +/// +/// --drive: an in-process end-to-end verification harness for the C# port. +/// +/// Stands up a fake WorldServer (TCP, scratch port) that registers the +/// district and hands off account 1, then runs a fake client (UDP, scratch +/// port) through the full join path: +/// +/// AUTH (plaintext) → USES + WELCOME → JOIN (encrypted) → controller +/// actor-open → ServerSelectSpawnZone (371) → district-enter answer → +/// pawn-open → all eight ACK-gated possession stages. +/// +/// The fake client ACKs every reliable actor-channel packet it receives so the +/// DS's ACK-gated pawn sequence advances. Completion is verified against the +/// DS's own log lines ("Sequence complete for account=1." etc.). +/// +/// Uses scratch ports 21999/16999 only. The live stack (world 2108, districts +/// 6969/6970/6971) is never touched. Run: dotnet run -c Release -- --drive +/// +public static class DriveHarness +{ + private const int WorldTcpPort = 21999; + private const int DistrictUdpPort = 16999; + + // The two players the drive hands off and logs in. Distinct factions/genders + // so the remote-pawn identity fields are distinguishable on the wire. + private sealed record PlayerSpec(uint AccountId, uint CharacterId, byte Faction, byte Gender, string CharacterName); + + private static readonly PlayerSpec[] Players = + { + new(1, 1, 1, 2, "Eax"), // Enforcer + new(2, 2, 2, 1, "Nox") // Criminal + }; + + // 20 zero bytes: ProcessAuthPacket skips the AUTHKEY comparison when the + // handed-off token is all zeros, so the fake client may send any key. + private static readonly byte[] AuthToken = new byte[20]; + + // Deterministic 16-byte session key shared by the world handoff and the + // fake client's outbound encryption. + private static readonly byte[] EncryptionKey = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F }; + + public static int Run() + { + // Scratch configuration (environment variables override the INI). + Environment.SetEnvironmentVariable("APB_WORLD_SERVER_ADDRESS", "127.0.0.1"); + Environment.SetEnvironmentVariable("APB_WORLD_SERVER_PORT", WorldTcpPort.ToString()); + Environment.SetEnvironmentVariable("APB_DISTRICT_UDP_PORT", DistrictUdpPort.ToString()); + Environment.SetEnvironmentVariable("APB_DISTRICT_MAP", "social"); + Environment.SetEnvironmentVariable("APB_CHALLENGE_MODE", "welcome-direct"); + Environment.SetEnvironmentVariable("APB_ENABLE_POSSESSION", "1"); + Environment.SetEnvironmentVariable("APB_ENABLE_LEVEL_STREAMING", "0"); + Environment.SetEnvironmentVariable("APB_ENABLE_GRI_STARTUP", "1"); + Environment.SetEnvironmentVariable("APB_LOG_RELIABLE_ACTOR_TRAFFIC", "0"); + + // Deterministic net-index overrides: the drive has no client root, so + // the cooked-package resolver must not be consulted. These match the + // values the resolver produces for the known-good build (holdable + // 15985, storage 25478; spawn zones = design base + local 182/183), so + // the wire stays byte-identical to the resolver-driven production path. + Environment.SetEnvironmentVariable("APB_HOLDABLE_ITEM_MANAGER_LOCAL_NET_INDEX", "15985"); + Environment.SetEnvironmentVariable("APB_STORAGE_INVENTORY_LOCAL_NET_INDEX", "25478"); + uint designBase = DistrictConfig.PackageFirstNetIndex("rworldsocialdistrict_design"); + Environment.SetEnvironmentVariable("APB_ENFORCER_SPAWN_ZONE_LINKED_ACTOR_NET_INDEX", + (designBase + DistrictConfig.EnforcerSpawnZoneLocalNetIndex).ToString()); + Environment.SetEnvironmentVariable("APB_CRIMINAL_SPAWN_ZONE_LINKED_ACTOR_NET_INDEX", + (designBase + DistrictConfig.CriminalSpawnZoneLocalNetIndex).ToString()); + + // Remote-pawn multiplayer verification: both players must possess, then + // each must see the other's pawn. The 4 s character-build stagger would + // slow the drive; disable it here (it stays on in production). + Environment.SetEnvironmentVariable("APB_REMOTE_PAWN_OPEN_DELAY_MS", "0"); + + // Fake WorldServer: accept the district's registration, register it, + // hand off account 1, then hold the control link open until told to + // close (the production main loop would otherwise see it end). + using var worldDone = new ManualResetEventSlim(false); + var worldReady = new ManualResetEventSlim(false); + var world = new Thread(() => FakeWorld(worldReady, worldDone)) { IsBackground = true }; + world.Start(); + + if (!worldReady.Wait(TimeSpan.FromSeconds(10))) + { + Console.WriteLine("FAIL: fake world never became ready"); + return 1; + } + + using var network = new Network(); + UdpListener? listener = Program.Startup(network); + if (listener == null) + { + Console.WriteLine("FAIL: district startup failed"); + return 1; + } + + // The production main thread reads world-control records (character + // handoffs) off the TCP link. In drive mode that loop must run on a + // background thread so the fake client can drive the join path in + // parallel. + var worldControl = new World.WorldControl(network); + var controlThread = new Thread(() => + { + while (true) + { + byte[]? prefixBuffer = network.Receive(1); + if (prefixBuffer == null) + break; + if (!worldControl.ProcessWorldControlRecord(prefixBuffer[0])) + break; + } + }) { IsBackground = true }; + controlThread.Start(); + + // Two fake clients drive the join path in parallel and ACK their own + // possession stages. When the second finishes possessing, the DS opens + // each player's remote pawn on the other (the multiplayer check). + var clients = new[] + { + new FakeClient(DistrictUdpPort, EncryptionKey, Players[0].AccountId), + new FakeClient(DistrictUdpPort, EncryptionKey, Players[1].AccountId) + }; + using var clientStopped = new ManualResetEventSlim(false); + var clientThreads = new List(); + foreach (FakeClient client in clients) + { + var thread = new Thread(() => client.Run(clientStopped)) { IsBackground = true }; + thread.Start(); + clientThreads.Add(thread); + } + + // Watch the DS's own log for the definitive completion milestones: + // both players possessed and each remote pawn opened on the other. + bool complete = WaitForLogLine("Sequence complete for account=2.", TimeSpan.FromSeconds(30)); + complete &= WaitForLogLine("Opened remote pawn of account=1 on viewer=2", TimeSpan.FromSeconds(10)); + complete &= WaitForLogLine("Opened remote pawn of account=2 on viewer=1", TimeSpan.FromSeconds(10)); + + // The customisation replicator request flow: the fake client drives + // ServerSendData to the descriptor end offset, so the DS must answer + // with ClientNotifyTransferComplete (field 23) instead of refusing the + // out-of-range chunk. + complete &= WaitForLogLine("Sent ClientNotifyTransferComplete", TimeSpan.FromSeconds(10)); + + clientStopped.Set(); + foreach (Thread thread in clientThreads) + thread.Join(TimeSpan.FromSeconds(3)); + + // Close the world control link so the DS main thread sees it end. + worldDone.Set(); + world.Join(TimeSpan.FromSeconds(3)); + + // Give the DS a moment to flush its final log lines. + Thread.Sleep(300); + + string log = ReadLog(); + Console.WriteLine("=== C# DistrictServer --drive log (milestones) ==="); + foreach (string milestone in new[] + { + "Registered at World Server", + "Sent WELCOME", + "Sequence complete for account=1", + "Sequence complete for account=2", + "Opened remote pawn of account=1 on viewer=2", + "Opened remote pawn of account=2 on viewer=1", + "Sent ClientNotifyTransferComplete" + }) + { + bool found = log.Contains(milestone, StringComparison.Ordinal); + Console.WriteLine($"{(found ? "OK " : "MISS")} {milestone}"); + if (!found) + complete = false; + } + + Console.WriteLine(complete ? "DRIVE PASS" : "DRIVE FAIL"); + return complete ? 0 : 1; + } + + // ------------------------------------------------------------------ + // Fake WorldServer + // ------------------------------------------------------------------ + + private static void FakeWorld(ManualResetEventSlim ready, ManualResetEventSlim done) + { + var listener = new TcpListener(IPAddress.Loopback, WorldTcpPort); + listener.Start(); + ready.Set(); + + try + { + using TcpClient connection = listener.AcceptTcpClient(); + using NetworkStream stream = connection.GetStream(); + + // 1. The district's 7-byte registration datagram. + var registration = new byte[7]; + ReadExactly(stream, registration, 7); + + // 2. Registration response "03" = "Registered at World Server". + stream.Write(new[] { (byte)'0', (byte)'3' }); + + // 3. Phase-4 character handoff for every player (prefix 0x34). + foreach (PlayerSpec player in Players) + SendPhase4Handoff(stream, player); + + // 4. Hold the control link open until the drive completes. + done.Wait(); + } + catch (Exception exception) + { + Console.WriteLine($"fake world error: {exception.Message}"); + } + finally + { + listener.Stop(); + } + } + + private static void SendPhase4Handoff(NetworkStream stream, PlayerSpec player) + { + const string clanName = "APB-EMU"; + const int appearanceBytes = 896; + var appearance = new byte[appearanceBytes]; // zeros: descriptor GUIDs are all-zero but present + + var fixedPayload = new byte[55]; + WriteU32(fixedPayload, 0, player.AccountId); + Array.Copy(AuthToken, 0, fixedPayload, 4, 20); + Array.Copy(EncryptionKey, 0, fixedPayload, 24, 16); + WriteU32(fixedPayload, 40, player.CharacterId); + fixedPayload[44] = player.Faction; + fixedPayload[45] = player.Gender; + fixedPayload[46] = 1; // appearanceVersion + WriteU16(fixedPayload, 47, (ushort)player.CharacterName.Length); + WriteU16(fixedPayload, 49, (ushort)clanName.Length); + WriteU32(fixedPayload, 51, (uint)appearanceBytes); + + var variable = new byte[player.CharacterName.Length + clanName.Length + appearanceBytes]; + Encoding.ASCII.GetBytes(player.CharacterName, 0, player.CharacterName.Length, variable, 0); + Encoding.ASCII.GetBytes(clanName, 0, clanName.Length, variable, player.CharacterName.Length); + Array.Copy(appearance, 0, variable, player.CharacterName.Length + clanName.Length, appearanceBytes); + + stream.WriteByte(0x34); + stream.Write(fixedPayload); + stream.Write(variable); + } + + private static void ReadExactly(NetworkStream stream, byte[] buffer, int count) + { + int total = 0; + while (total < count) + { + int read = stream.Read(buffer, total, count - total); + if (read <= 0) + throw new EndOfStreamException("fake world: connection closed while reading"); + total += read; + } + } + + private static void WriteU32(byte[] buffer, int offset, uint value) + { + buffer[offset] = (byte)value; + buffer[offset + 1] = (byte)(value >> 8); + buffer[offset + 2] = (byte)(value >> 16); + buffer[offset + 3] = (byte)(value >> 24); + } + + private static void WriteU16(byte[] buffer, int offset, ushort value) + { + buffer[offset] = (byte)value; + buffer[offset + 1] = (byte)(value >> 8); + } + + // ------------------------------------------------------------------ + // Log helpers + // ------------------------------------------------------------------ + + private static string ReadLog() + { + // Open with FileShare.ReadWrite so a concurrent File.AppendAllText from + // the logger never hits a sharing violation. File.ReadAllText uses + // FileShare.Read (denies writers) and can race the live log. + for (int attempt = 0; attempt < 5; ++attempt) + { + try + { + using var stream = new FileStream(DistrictLogger.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + catch (IOException) + { + Thread.Sleep(40); + } + } + return string.Empty; + } + + private static bool WaitForLogLine(string needle, TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (ReadLog().Contains(needle, StringComparison.Ordinal)) + return true; + Thread.Sleep(100); + } + return false; + } + + // ------------------------------------------------------------------ + // Fake client + // ------------------------------------------------------------------ + + /// + /// Speaks just enough UE3 wire protocol to push the DS through its join + /// path: AUTH (plaintext) → JOIN (encrypted text control) → the 371 + /// spawn-zone RPC (encrypted actor bunch), then ACKs every reliable + /// actor-channel packet so the ACK-gated pawn stages advance one by one. + /// + private sealed class FakeClient + { + private readonly int _districtPort; + private readonly byte[] _key; + private readonly uint _accountId; + private readonly UdpClient _udp; + private uint _outgoingPacketId = 1; + private ushort _controlSequence = 1; + private ushort _actorSequence = 1; + private ushort _replicatorSequence = 1; + + // Transfer payload produced by the DS for the all-zero 896-byte + // drive appearance (no cCompressedAssetCustomisation prefix is + // stripped), used to request the descriptor end offset so the DS must + // answer with ClientNotifyTransferComplete rather than refuse the + // out-of-range chunk. + private readonly int _transferPayloadLength = + LevelStreamingService.BuildCharacterCustomisationTransferPayload(new byte[896]).Length; + + public FakeClient(int districtPort, byte[] key, uint accountId) + { + _districtPort = districtPort; + _key = key; + _accountId = accountId; + _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + _udp.Connect(IPAddress.Loopback, districtPort); + } + + public void Run(ManualResetEventSlim stopped) + { + bool sawWelcome = false; + bool sawControllerOpen = false; + bool joinSent = false; + bool spawnZoneSent = false; + bool customisationRequested = false; + bool customisationRequestsSent = false; + bool sawReplicatorData = false; + int ackedReliable = 0; + + try + { + // The AUTH is reliable on the real client and is retransmitted + // until the handshake completes; the account handoff from the + // fake world can race the first AUTH, so re-send until WELCOME. + var lastAuthTick = 0L; + + var receiveBuffer = new byte[65535]; + var remote = (EndPoint)new IPEndPoint(IPAddress.Any, 0); + + while (!stopped.IsSet) + { + if (!sawWelcome && Environment.TickCount64 - lastAuthTick > 400) + { + SendPlain(TextControl($"AUTH ACCID={_accountId:D10} AUTHKEY=0000000000000000000000000000000000000000")); + lastAuthTick = Environment.TickCount64; + } + + if (_udp.Client.Poll(200_000, SelectMode.SelectRead) == false) + continue; + + int received = _udp.Client.ReceiveFrom(receiveBuffer, ref remote); + var packet = new Packet(); + if (!TryDecryptAndParse(receiveBuffer, received, packet)) + continue; + + foreach (Bunch bunch in packet.Bunches) + { + // Handshake milestone detection. + foreach (string text in bunch.ControlStrings) + { + if (text.Contains("WELCOME", StringComparison.Ordinal)) + sawWelcome = true; + } + + if (bunch.Kind == BunchKind.Data) + { + if (bunch.Open && bunch.ChannelIndex == DistrictConfig.ControllerChannel) + sawControllerOpen = true; + + // The DS opens the cCustomisationReplicator and + // sends the initial ClientReceiveData chunk in + // answer to the client's ServerRequestCustomisation + // (field 277). Any data on the replicator channel + // means the transfer is running, so the request + // sequence can start. + if (bunch.ChannelIndex == DistrictConfig.CustomisationReplicatorChannel) + sawReplicatorData = true; + + // ACK every reliable actor-channel packet. ACKs for + // untracked packets are ignored by the DS; the + // tracked ones (GRI open, the eight pawn stages) + // advance the sequence. + if (bunch.Reliable && bunch.ChannelIndex != 0) + { + SendEncrypted(BuildAck(packet.PacketId)); + ++ackedReliable; + } + } + } + + if (sawWelcome && !joinSent) + { + SendEncrypted(TextControl("JOIN")); + joinSent = true; + } + + if (sawControllerOpen && !spawnZoneSent) + { + SendEncrypted(BuildSpawnZoneSelect()); + spawnZoneSent = true; + } + + // Ask the DS to open the customisation replicator and start + // the descriptor transfer (field 277, no parameters). + if (spawnZoneSent && !customisationRequested) + { + SendEncrypted(BuildServerRequestCustomisation()); + customisationRequested = true; + } + + // Once the replicator is live, drive the request-driven + // transfer to its end offset: chunks at 256/512/768 then + // the descriptor end (896) which must yield + // ClientNotifyTransferComplete (field 23) instead of an + // out-of-range refusal. + if (sawReplicatorData && !customisationRequestsSent) + { + SendEncrypted(BuildServerSendData(256)); + SendEncrypted(BuildServerSendData(512)); + SendEncrypted(BuildServerSendData(768)); + SendEncrypted(BuildServerSendData(_transferPayloadLength)); + customisationRequestsSent = true; + } + } + } + catch (Exception exception) + { + Console.WriteLine($"fake client error: {exception.Message}"); + } + finally + { + _udp.Close(); + } + } + + private byte[] TextControl(string text) + => PacketBuilders.BuildTextControlPacket(0, _outgoingPacketId++, _controlSequence++, text); + + private byte[] BuildAck(uint acknowledgedPacketId) + => PacketBuilders.BuildAckPacket(0, _outgoingPacketId++, acknowledgedPacketId); + + private byte[] BuildSpawnZoneSelect() + => PacketBuilders.BuildActorObjectRpcPacket(_outgoingPacketId++, DistrictConfig.ControllerChannel, _actorSequence++, + DistrictConfig.ServerSelectSpawnZoneWireField(), DistrictConfig.PlayerControllerFieldMax, 5); + + private byte[] BuildServerRequestCustomisation() + => PacketBuilders.BuildActorDefaultRpcPacket(_outgoingPacketId++, DistrictConfig.ControllerChannel, _actorSequence++, + DistrictConfig.FieldServerRequestCustomisation, DistrictConfig.PlayerControllerFieldMax, 0); + + private byte[] BuildServerSendData(int baseIndex) + => PacketBuilders.BuildActorIntRpcPacket(_outgoingPacketId++, DistrictConfig.CustomisationReplicatorChannel, _replicatorSequence++, + DistrictConfig.FieldReplicatorServerSendData, DistrictConfig.CustomisationReplicatorFieldMax, baseIndex); + + /// + /// The DS encrypts every outbound packet with the account's session key + /// (padded to a 32-bit word boundary), so a datagram must be decrypted + /// before it can be parsed. The AUTH ACK is the only plaintext reply. + /// + private bool TryDecryptAndParse(byte[] data, int size, Packet packet) + { + if (size >= 8 && size % 4 == 0) + { + var decoded = new byte[size]; + Array.Copy(data, decoded, size); + if (DistrictCrypto.Decrypt(decoded, _key) && PacketParser.ParsePacket(decoded, size, packet)) + return true; + } + + return PacketParser.ParsePacket(data, size, packet); + } + + private void SendPlain(byte[] packet) + => _udp.Send(packet, packet.Length); + + private void SendEncrypted(byte[] packet) + { + int paddedLength = Math.Max(8, (packet.Length + 3) & ~3); + var padded = packet; + if (paddedLength != packet.Length) + { + padded = new byte[paddedLength]; + Array.Copy(packet, padded, packet.Length); + } + + DistrictCrypto.Encrypt(padded, _key); + _udp.Send(padded, padded.Length); + } + } +} diff --git a/DistrictServerCSharp/Gri/GriStartupService.cs b/DistrictServerCSharp/Gri/GriStartupService.cs new file mode 100644 index 0000000..dd8c3ab --- /dev/null +++ b/DistrictServerCSharp/Gri/GriStartupService.cs @@ -0,0 +1,189 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Gri; + +/// +/// GRI match-start replication, ported from the g_gri* state in +/// DistrictServer.cpp. The GRI actor channel is opened early (right after +/// JOIN) and tracked as a reliable packet; bMatchHasBegun=true is replicated +/// only after the client ACKs that open, so WorldInfo.GRI exists before the +/// repnotify property delta is delivered. +/// +public sealed class GriStartupService +{ + private readonly HandshakeService _handshake; + private readonly ReliableQueue _reliableQueue; + private readonly object _gate = new(); + private readonly HashSet _openedAccounts = new(); + private readonly HashSet _openAckedAccounts = new(); + private readonly HashSet _matchStartSentAccounts = new(); + private readonly Dictionary _openAckTicks = new(); + + public GriStartupService(HandshakeService handshake, ReliableQueue reliableQueue) + { + _handshake = handshake; + _reliableQueue = reliableQueue; + _reliableQueue.OnAcknowledged += OnReliableAcknowledged; + } + + public void ResetGriStartupState(uint accountId) + { + lock (_gate) + { + _openedAccounts.Remove(accountId); + _openAckedAccounts.Remove(accountId); + _matchStartSentAccounts.Remove(accountId); + _openAckTicks.Remove(accountId); + } + } + + private void OnReliableAcknowledged(uint accountId, uint serverPacketId, PendingServerReliable acknowledged) + { + if (acknowledged.Label != "GRI-ACTOR-OPEN-EARLY") + return; + + lock (_gate) + { + _openAckedAccounts.Add(accountId); + _openAckTicks[accountId] = Environment.TickCount64; + } + + DistrictLogger.Log(LogLevel.Success, "District GRI Match Start", + "GRI actor-open ACK accepted for account {0}; bMatchHasBegun replication is now eligible.", accountId); + } + + /// + /// Opens the GRI actor channel (index 3) with Default__cAPBGameReplicationInfo + /// and tracks the open as a reliable packet. Idempotent per account. + /// + public bool OpenGriBeforeStreaming(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + if (!DistrictConfig.ReadBool("APB_ENABLE_GRI_STARTUP", "EnableGriStartup", true)) + { + DistrictLogger.Log(LogLevel.Info, "District GRI Startup", "Early GRI startup disabled by configuration."); + return false; + } + + uint accountId = account.GetId(); + + lock (_gate) + { + if (_openedAccounts.Contains(accountId)) + { + DistrictLogger.Log(LogLevel.Info, "District GRI Startup", + "GRI actor channel {0} was already opened early for account {1}; reusing it.", + DistrictConfig.GriChannel, accountId); + return true; + } + + // Reserve before sending so concurrent startup paths cannot open + // channel 3 twice. A fresh actor open must also start with fresh + // match-start state. + _openAckedAccounts.Remove(accountId); + _matchStartSentAccounts.Remove(accountId); + _openAckTicks.Remove(accountId); + _openedAccounts.Add(accountId); + } + + uint griArchetype = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.GriArchetypeObjectIndex); + uint griOpenPacketId = account.AllocateServerPacketId(); + + byte[] open = PacketBuilders.BuildActorOpenPacket( + griOpenPacketId, + DistrictConfig.GriChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.GriChannel), + griArchetype, + 0.0f, 0.0f, 0.0f); + + if (!_reliableQueue.SendTrackedReliablePacket(endpoint, account, griOpenPacketId, open, "GRI-ACTOR-OPEN-EARLY")) + { + lock (_gate) + { + _openedAccounts.Remove(accountId); + } + + DistrictLogger.Log(LogLevel.Error, "District GRI Startup", + "Failed to open early GRI actor channel {0} for account {1}.", DistrictConfig.GriChannel, account.GetId()); + return false; + } + + DistrictLogger.Log(LogLevel.Success, "District GRI Startup", + "Opened GRI actor channel {0} early for account {1} with serverPacketId={2} and Default__cAPBGameReplicationInfo (APBGame objectIndex={3} globalNetIndex={4}). Confirm that the client returns ACK({2}).", + DistrictConfig.GriChannel, account.GetId(), griOpenPacketId, DistrictConfig.GriArchetypeObjectIndex, griArchetype); + + return true; + } + + /// + /// Replicates GameReplicationInfo.bMatchHasBegun=true once the GRI open has + /// been ACKed (and the configured post-ACK delay has elapsed). One-shot per + /// account. + /// + public bool MaybeSendGriMatchHasBegun(IPEndPoint endpoint, Account? account) + { + if (account == null) + return false; + + if (!DistrictConfig.ReadBool("APB_ENABLE_GRI_MATCH_START", "EnableGriMatchStartReplication", true)) + return false; + + uint accountId = account.GetId(); + int delayMilliseconds = DistrictConfig.ReadInt("APB_GRI_MATCH_START_DELAY_MS", "GriMatchStartAfterOpenAckMilliseconds", 0, 0, 60000); + + lock (_gate) + { + if (!_openAckedAccounts.Contains(accountId)) + return false; + + if (_matchStartSentAccounts.Contains(accountId)) + return true; + + if (_openAckTicks.TryGetValue(accountId, out long ackTick) && + Environment.TickCount64 - ackTick < delayMilliseconds) + { + return false; + } + + _matchStartSentAccounts.Add(accountId); + } + + uint fieldMax = DistrictConfig.GriFieldMax(); + uint fieldIndex = DistrictConfig.GriMatchHasBegunWireField(); + uint packetId = account.AllocateServerPacketId(); + + byte[] update = PacketBuilders.BuildActorBoolFieldPacket( + packetId, + DistrictConfig.GriChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.GriChannel), + fieldIndex, + fieldMax, + true); + + if (!_reliableQueue.SendTrackedReliablePacket(endpoint, account, packetId, update, "GRI-MATCH-HAS-BEGUN")) + { + lock (_gate) + { + _matchStartSentAccounts.Remove(accountId); + } + + DistrictLogger.Log(LogLevel.Error, "District GRI Match Start", + "Failed to send bMatchHasBegun=true property delta for account {0}.", accountId); + return false; + } + + DistrictLogger.Log(LogLevel.Success, "District GRI Match Start", + "Sent replicated property GameReplicationInfo.bMatchHasBegun=true on GRI channel {0} for account {1}: field={2} fieldMax={3} serverPacketId={4}. Expected client path: ReplicatedEvent -> WorldInfo.NotifyMatchStarted -> LevelStartup Kismet.", + DistrictConfig.GriChannel, accountId, fieldIndex, fieldMax, packetId); + + return true; + } +} diff --git a/DistrictServerCSharp/Handshake/BinaryControlService.cs b/DistrictServerCSharp/Handshake/BinaryControlService.cs new file mode 100644 index 0000000..bb9ddcb --- /dev/null +++ b/DistrictServerCSharp/Handshake/BinaryControlService.cs @@ -0,0 +1,81 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Handshake; + +/// +/// Binary NMT control dispatch, ported from ProcessBinaryControlPacket in +/// DistrictServer.cpp. The APB control protocol is text based, so binary +/// control messages are rare on this build -- but the handshake can be driven +/// over them (NMT_HandshakeStart / NMT_HandshakeResponse), and the client +/// sends them when a binary handshake mode is negotiated. Runs after the text +/// control handler and answers a message if it is one it recognises. +/// +public sealed class BinaryControlService +{ + private readonly HandshakeService _handshake; + + public BinaryControlService(HandshakeService handshake) + { + _handshake = handshake; + } + + /// + /// Reads each data bunch as a binary control message and handles the + /// handshake opcodes: HandshakeStart re-sends the auth-ack + challenge, + /// HandshakeResponse sends HandshakeComplete. Returns true when a message + /// was consumed (so the caller stops the dispatch chain). + /// + public bool ProcessBinaryControlPacket(IPEndPoint endpoint, Account? account, Packet packet) + { + if (account == null) + return false; + + foreach (Bunch bunch in packet.Bunches) + { + if (!FieldDecoders.ReadBinaryControlMessage(bunch, out byte messageType, out byte[] payload)) + continue; + + if (messageType == DistrictConfig.NmtHandshakeStart) + { + uint platform = payload.Length == 0 ? 0u : payload[0]; + + DistrictLogger.Log(LogLevel.Success, "District Handshake", + "Received HandshakeStart from account {0}, platform={1}.", + account.GetId(), platform); + + _handshake.SendAuthAckAndChallenge(endpoint, account, packet.PacketId, packet.Prefix, false); + return true; + } + + if (messageType == DistrictConfig.NmtHandshakeResponse) + { + if (payload.Length < 4) + { + DistrictLogger.Log(LogLevel.Error, "District Handshake", + "HandshakeResponse from account {0} is truncated ({1} bytes).", + account.GetId(), payload.Length); + return true; + } + + uint response = (uint)(payload[0] | (payload[1] << 8) | (payload[2] << 16) | (payload[3] << 24)); + + DistrictLogger.Log(LogLevel.Success, "District Handshake", + "Received HandshakeResponse from account {0}: response=0x{1:X8}, challenge=0x{2:X8}. Phase 3 logs the response but does not yet reject on CRC mismatch.", + account.GetId(), response, account.GetHandshakeChallenge()); + + _handshake.SendHandshakeComplete(endpoint, account, packet.PacketId, packet.Prefix); + return true; + } + + DistrictLogger.Log(LogLevel.Info, "District Handshake", + "Received binary control message {0} from account {1} ({2} payload bytes).", + messageType, account.GetId(), payload.Length); + } + + return false; + } +} diff --git a/DistrictServerCSharp/Handshake/ChannelSequenceAllocator.cs b/DistrictServerCSharp/Handshake/ChannelSequenceAllocator.cs new file mode 100644 index 0000000..2ad4991 --- /dev/null +++ b/DistrictServerCSharp/Handshake/ChannelSequenceAllocator.cs @@ -0,0 +1,32 @@ +using System.Net; +using DistrictServerCSharp.Net; + +namespace DistrictServerCSharp.Handshake; + +/// +/// Reliable channel-sequence allocator, ported from AllocateChannelSequence in +/// DistrictServer.cpp. Keyed by (client endpoint, channel index) so concurrent +/// players on one district never share a counter -- the original global +/// counter handed the second player channel opens stamped with the first +/// player's sequence numbers, which the client dropped as out-of-order. +/// +public static class ChannelSequenceAllocator +{ + private static readonly object Gate = new(); + private static readonly Dictionary<(uint Address, ushort Port, ushort Channel), ushort> Sequences = new(); + + public static ushort Allocate(IPEndPoint endpoint, ushort channelIndex) + { + uint address = EndpointAddress.ToWireValue(endpoint.Address); + ushort port = (ushort)endpoint.Port; + + lock (Gate) + { + var key = (address, port, channelIndex); + Sequences.TryGetValue(key, out ushort current); + ushort next = (ushort)(current + 1); + Sequences[key] = next; + return next; + } + } +} diff --git a/DistrictServerCSharp/Handshake/HandshakeService.cs b/DistrictServerCSharp/Handshake/HandshakeService.cs new file mode 100644 index 0000000..1b994e4 --- /dev/null +++ b/DistrictServerCSharp/Handshake/HandshakeService.cs @@ -0,0 +1,520 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Net; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Handshake; + +/// +/// All district→client UDP sends plus the inbound AUTH and text-control +/// handling, ported from the anonymous-namespace helpers in +/// DistrictServer.cpp. The district's wire behaviour lives here: every +/// outbound packet is BTEA-protected with the account's session key, and +/// reliable channel sequences are allocated per (endpoint, channel) so +/// concurrent players never collide. +/// +public sealed class HandshakeService +{ + public Socket UdpSocket { get; } + + /// + /// Raised after the client sends JOIN and the PlayerController actor has + /// been opened. The listener wires this to the GRI startup so the GRI + /// actor channel opens right after JOIN. + /// + public Action? OnJoin; + + public HandshakeService(Socket udpSocket) => UdpSocket = udpSocket; + + public static string EndpointText(IPEndPoint endpoint) + => $"{endpoint.Address}:{endpoint.Port}"; + + // ------------------------------------------------------------------ + // Raw sends + // ------------------------------------------------------------------ + + public bool SendPacket(IPEndPoint endpoint, byte[] packet, string label) + { + try + { + UdpSocket.SendTo(packet, endpoint); + } + catch (SocketException exception) + { + DistrictLogger.Log(LogLevel.Error, "District UDP", "TX {0} to {1} failed: {2}", label, EndpointText(endpoint), exception.SocketErrorCode); + return false; + } + + PacketDiagnostics.SaveCapture("TX", endpoint, packet); + + DistrictLogger.Log(LogLevel.Success, "District UDP", "TX {0} {1} bytes to {2} | {3}", label, packet.Length, EndpointText(endpoint), Diagnostics.Hex(packet)); + return true; + } + + /// + /// Protects a packet with the account's session key, padding it to a + /// 32-bit word boundary first (real APB datagrams are always + /// word-aligned). Returns null when protection cannot be applied; the + /// caller must not send anything in that case. + /// + private static byte[]? ProtectOutgoingPacket(byte[] packet, Account account) + { + if (account == null) + return null; + + // Legacy escape hatch: only leave a packet in the clear if explicitly + // configured for the old plaintext experiments. A real client rejects + // anything unencrypted, so this should stay off. + if (DistrictConfig.AckModeValue == AckMode.None) + return packet; + + int padded = packet.Length < 8 ? 8 : (packet.Length + 3) & ~3; + + // Copy unconditionally. DistrictCrypto.Encrypt works in place, and the + // C++ oracle takes its packet BY VALUE (DistrictServer.cpp:1412), so + // the caller's buffer is never touched there. Aliasing it here turned + // the caller's plaintext into ciphertext: ReliableQueue keeps the same + // array as PendingServerReliable.ClearPacket and re-sends it on retry, + // which then encrypted already-encrypted bytes. + byte[] buffer = new byte[padded]; + Array.Copy(packet, buffer, packet.Length); + + return DistrictCrypto.Encrypt(buffer, account.GetEncryptionKey()) ? buffer : null; + } + + public bool SendProtectedPacket(IPEndPoint endpoint, Account account, byte[] packet, string label) + { + byte[]? protectedPacket = ProtectOutgoingPacket(packet, account); + if (protectedPacket == null) + { + DistrictLogger.Log(LogLevel.Error, "District UDP", "Could not apply {0} protection to {1}.", DistrictConfig.AckModeName(DistrictConfig.AckModeValue), label); + return false; + } + + return SendPacket(endpoint, protectedPacket, label); + } + + // ------------------------------------------------------------------ + // ACK / challenge / handshake + // ------------------------------------------------------------------ + + public bool SendAck(IPEndPoint endpoint, Account account, uint clientPacketId, string label = "ACK") + { + if (account == null) + return false; + + byte[] ack = PacketBuilders.BuildAckPacket(0, account.AllocateServerPacketId(), clientPacketId); + return SendProtectedPacket(endpoint, account, ack, label); + } + + private static byte[] EncodeUInt32Little(uint value) + => new[] { (byte)value, (byte)(value >> 8), (byte)(value >> 16), (byte)(value >> 24) }; + + public bool SendAuthAckAndChallenge(IPEndPoint endpoint, Account account, uint clientPacketId, ushort prefix, bool retransmission) + { + if (account == null) + return false; + + if (DistrictConfig.ChallengeModeValue == ChallengeMode.AckOnly) + { + if (DistrictConfig.AckModeValue == AckMode.None) + return true; + + byte[] ack = PacketBuilders.BuildAckPacket(prefix, account.AllocateServerPacketId(), clientPacketId); + return SendProtectedPacket(endpoint, account, ack, "AUTH-ACK/" + DistrictConfig.AckModeName(DistrictConfig.AckModeValue)); + } + + uint challenge = account.GetHandshakeChallenge(); + if (challenge == 0) + { + challenge = DistrictConfig.GenerateChallenge(account.GetId()); + account.SetHandshakeChallenge(challenge); + } + + ushort sequence = retransmission ? (ushort)1 : account.AllocateServerReliableSequence(); + + byte[] packet; + string label; + + if (DistrictConfig.ChallengeModeValue == ChallengeMode.TextCombined) + { + string text = $"CHALLENGE VER=3908 CHALLENGE={challenge}"; + packet = PacketBuilders.BuildAckAndTextControlPacket(prefix, account.AllocateServerPacketId(), clientPacketId, sequence, text); + label = "AUTH-ACK+TEXT-CHALLENGE/"; + } + else if (DistrictConfig.ChallengeModeValue == ChallengeMode.BinarySeparate) + { + byte[] ack = PacketBuilders.BuildAckPacket(prefix, account.AllocateServerPacketId(), clientPacketId); + SendProtectedPacket(endpoint, account, ack, "AUTH-ACK/" + DistrictConfig.AckModeName(DistrictConfig.AckModeValue)); + + byte[] encoded = EncodeUInt32Little(challenge); + packet = PacketBuilders.BuildBinaryControlPacket(prefix, account.AllocateServerPacketId(), sequence, DistrictConfig.NmtHandshakeChallenge, encoded); + label = "BINARY-HANDSHAKE-CHALLENGE/"; + } + else + { + byte[] encoded = EncodeUInt32Little(challenge); + + // Send the challenge as a single reliable control bunch with NO ack + // bunch in front of it. The client reads an ack as + // [isAck=1][flag][ReadInt(2^30)=30-bit id], not the 14-bit field the + // ack builders emit, so any prepended ack shifts the bit stream and + // the challenge bunch is read from the wrong offset. Dropping the ack + // keeps the packet correctly framed; the client does not need to be + // acked to process the challenge (its own first AUTH carried no ack), + // and it will keep retransmitting its reliable AUTH harmlessly until + // the handshake advances. + packet = PacketBuilders.BuildBinaryControlPacket(prefix, account.AllocateServerPacketId(), sequence, DistrictConfig.NmtHandshakeChallenge, encoded); + label = "BINARY-HANDSHAKE-CHALLENGE/"; + } + + label += DistrictConfig.AckModeName(DistrictConfig.AckModeValue); + + bool sent = SendProtectedPacket(endpoint, account, packet, label); + if (sent) + { + account.SetHandshakeState(Account.HandshakeState.ChallengeSent); + uint sendCount = account.IncrementChallengeSendCount(); + + DistrictLogger.Log(LogLevel.Success, "District Handshake", + "{0} challenge=0x{1:X8} ({1}), channelSequence={2}, sendCount={3}, mode={4}, protection={5}", + retransmission ? "Retransmitted" : "Sent", + challenge, + sequence, + sendCount, + DistrictConfig.ChallengeModeName(DistrictConfig.ChallengeModeValue), + DistrictConfig.AckModeName(DistrictConfig.AckModeValue)); + } + + return sent; + } + + public bool SendHandshakeComplete(IPEndPoint endpoint, Account account, uint clientPacketId, ushort prefix) + { + if (account == null) + return false; + + byte[] packet = PacketBuilders.BuildAckAndBinaryControlPacket( + prefix, + account.AllocateServerPacketId(), + clientPacketId, + account.AllocateServerReliableSequence(), + DistrictConfig.NmtHandshakeComplete, + Array.Empty()); + + bool sent = SendProtectedPacket(endpoint, account, packet, "ACK+HANDSHAKE-COMPLETE/" + DistrictConfig.AckModeName(DistrictConfig.AckModeValue)); + if (sent) + { + account.SetHandshakeState(Account.HandshakeState.Complete); + DistrictLogger.Log(LogLevel.Success, "District Handshake", "Sent HandshakeComplete to account {0}.", account.GetId()); + } + + return sent; + } + + public bool SendUses(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + foreach (UsesPackage package in DistrictConfig.UsesPackages) + { + string text = $"USES GUID={package.Guid} GEN={package.Generation} FLAGS={package.Flags} PKG={package.Name}"; + + byte[] packet = PacketBuilders.BuildTextControlPacket(0, account.AllocateServerPacketId(), account.AllocateServerReliableSequence(), text); + + if (!SendProtectedPacket(endpoint, account, packet, "USES")) + return false; + + DistrictLogger.Log(LogLevel.Info, "District Handshake", "Sent to account {0}: {1}", account.GetId(), text); + } + + DistrictLogger.Log(LogLevel.Info, "District Handshake", + "Package map: Core@0 Engine@{0} APBGame@{1}; Default__cAPBPlayerController net index = {2}", + DistrictConfig.PackageFirstNetIndex("Engine"), + DistrictConfig.PackageFirstNetIndex("APBGame"), + DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.ControllerArchetypeObjectIndex)); + + return true; + } + + public bool SendWelcome(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + DistrictMap districtMap = DistrictConfig.GetConfiguredDistrictMap(); + string map = DistrictConfig.ReadSetting("APB_WELCOME_LEVEL", "WelcomeLevel", DistrictConfig.DistrictWelcomeLevel(districtMap)); + + string text = $"WELCOME LEVEL={map} CHALLENGE={account.GetHandshakeChallenge()}"; + + byte[] packet = PacketBuilders.BuildTextControlPacket(0, account.AllocateServerPacketId(), account.AllocateServerReliableSequence(), text); + + bool sent = SendProtectedPacket(endpoint, account, packet, "WELCOME"); + if (sent) + { + account.SetHandshakeState(Account.HandshakeState.Complete); + DistrictLogger.Log(LogLevel.Success, "District Handshake", "Sent WELCOME to account {0}: {1}", account.GetId(), text); + } + + return sent; + } + + // ------------------------------------------------------------------ + // Actor channels + // ------------------------------------------------------------------ + + /// + /// Opens the PlayerController actor channel (index 2) with the + /// Default__cAPBPlayerController archetype. The channel sequence must come + /// from the per-(endpoint, channel) allocator: a private counter that also + /// started at 1 would collide with the open bunch's own sequence 1 and the + /// client would drop the first field bunch as a duplicate. + /// + public bool SendPlayerControllerActor(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + uint archetype = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.ControllerArchetypeObjectIndex); + const ushort actorChannelIndex = DistrictConfig.ControllerChannel; + + DistrictConfig.ReadControllerLocation(out float controllerX, out float controllerY, out float controllerZ); + + byte[] packet = PacketBuilders.BuildActorOpenPacket( + account.AllocateServerPacketId(), + actorChannelIndex, + ChannelSequenceAllocator.Allocate(endpoint, actorChannelIndex), + archetype, + controllerX, + controllerY, + controllerZ); + + bool sent = SendProtectedPacket(endpoint, account, packet, "ACTOR-OPEN"); + if (sent) + { + DistrictLogger.Log(LogLevel.Success, "District Handshake", + "Opened actor channel {0} for account {1} with archetype Default__cAPBPlayerController (net index {2}) at ({3:F1}, {4:F1}, {5:F1}).", + actorChannelIndex, + account.GetId(), + archetype, + controllerX, + controllerY, + controllerZ); + } + + return sent; + } + + /// + /// Answers GC2DS_ASK_DISTRICT_ENTER with the DS2GC answer (returnCode=0, + /// districtUid, instanceNo) on the controller channel. + /// + public bool SendDistrictEnterAnswer(IPEndPoint endpoint, Account account, int districtUid, int instanceNo) + { + if (account == null) + return false; + + int[] values = { 0, districtUid, instanceNo }; + + byte[] packet = PacketBuilders.BuildActorIntFieldPacket( + account.AllocateServerPacketId(), + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldAnsDistrictEnter, + DistrictConfig.PlayerControllerFieldMax, + values); + + bool sent = SendProtectedPacket(endpoint, account, packet, "ANS-DISTRICT-ENTER"); + if (sent) + { + DistrictLogger.Log(LogLevel.Success, "District Handshake", + "Answered GC2DS_ASK_DISTRICT_ENTER for account {0} (returnCode=0 districtUID={1} instanceNo={2}).", + account.GetId(), + districtUid, + instanceNo); + } + + return sent; + } + + // ------------------------------------------------------------------ + // Inbound: AUTH + // ------------------------------------------------------------------ + + /// + /// Handles an AUTH control string. Returns true when the packet was + /// consumed by the auth path (it must not be processed further). + /// + public bool ProcessAuthPacket(IPEndPoint endpoint, Packet packet) + { + foreach (Bunch bunch in packet.Bunches) + { + foreach (string text in bunch.ControlStrings) + { + var auth = new AuthCommand(); + if (!PacketParser.ParseAuthCommand(text, auth)) + continue; + + Account? account = AccountManager.Find(auth.AccountId); + if (account == null) + { + DistrictLogger.Log(LogLevel.Error, "District AUTH", + "AUTH from {0} names account {1}, but WorldServer has not handed that account to this district.", + EndpointText(endpoint), + auth.AccountId); + return true; + } + + byte[] expected = account.GetAuthToken(); + bool expectedKnown = expected.Any(value => value != 0); + + if (expectedKnown && !ConstantTimeEquals(expected, auth.AuthKey)) + { + DistrictLogger.Log(LogLevel.Error, "District AUTH", + "AUTHKEY mismatch for account {0} from {1}: expected={2} received={3}", + auth.AccountId, + EndpointText(endpoint), + Diagnostics.Hex(expected, 20), + Diagnostics.Hex(auth.AuthKey, 20)); + return true; + } + + account.BindEndpoint(EndpointAddress.ToWireValue(endpoint.Address), (ushort)endpoint.Port); + account.SetAuthenticated(true); + account.SetLastClientPacketId(packet.PacketId); + account.IncrementUdpReceiveCount(); + + DistrictLogger.Log(LogLevel.Success, "District AUTH", + "Authenticated account {0} from {1}; AUTHKEY={2}, packetId={3}, ACK mode={4}", + auth.AccountId, + EndpointText(endpoint), + auth.AuthKeyText, + packet.PacketId, + DistrictConfig.AckModeName(DistrictConfig.AckModeValue)); + + // The client repeats its reliable AUTH bunch until acked. Once + // the handshake has moved past the challenge, just ack the + // retransmission instead of restarting the handshake. + if (account.GetHandshakeState() == Account.HandshakeState.Complete) + { + SendAck(endpoint, account, packet.PacketId); + return true; + } + + // Retail behaviour: the real district server answers AUTH with + // WELCOME straight away. Decompiling APB's own AUTH handler + // shows it performs no key validation, sends no CHALLENGE and + // negotiates no packages -- it simply copies the master package + // map, marks every entry as present, and replies + // "WELCOME LEVEL=". + if (DistrictConfig.ChallengeModeValue == ChallengeMode.WelcomeDirect) + { + SendAck(endpoint, account, packet.PacketId); + + // Package map first: the client cannot resolve any object + // reference until USES has populated its package list, and + // the order these are sent in fixes every net index base. + SendUses(endpoint, account); + + SendWelcome(endpoint, account); + return true; + } + + SendAuthAckAndChallenge(endpoint, account, packet.PacketId, packet.Prefix, false); + return true; + } + } + + return false; + } + + private static bool ConstantTimeEquals(byte[] left, byte[] right) + { + if (left.Length != right.Length) + return false; + + byte difference = 0; + for (int index = 0; index < left.Length; ++index) + difference |= (byte)(left[index] ^ right[index]); + + return difference == 0; + } + + // ------------------------------------------------------------------ + // Inbound: text control (LOGIN / JOIN / NETSPEED) + // ------------------------------------------------------------------ + + /// + /// Handles text control messages. Returns true when the packet was + /// consumed (LOGIN / JOIN / NETSPEED handled and acked). + /// + public bool ProcessTextControlPacket(IPEndPoint endpoint, Account account, Packet packet) + { + if (account == null) + return false; + + bool sawLogin = false; + + foreach (Bunch bunch in packet.Bunches) + { + foreach (string text in bunch.ControlStrings) + { + if (text.StartsWith("LOGIN ", StringComparison.Ordinal)) + { + sawLogin = true; + DistrictLogger.Log(LogLevel.Success, "District Handshake", "Account {0} accepted the challenge and sent: {1}", account.GetId(), text); + } + else if (text == "JOIN" || text.StartsWith("JOIN ", StringComparison.Ordinal)) + { + // The client asks to be spawned into the level. UE3 answers + // by creating a PlayerController and replicating it over an + // actor channel. + DistrictLogger.Log(LogLevel.Success, "District Handshake", + "Account {0} sent JOIN - it is waiting for the server to spawn and replicate a PlayerController.", + account.GetId()); + + // A reconnect can reuse the same account id inside one + // DistrictServer process. Clear the previous + // startup/marker barrier so field 372 sends a fresh marker + // every JOIN, matching DistrictServer.cpp:13137-13142. + Lifecycle.ResetControllerFeedback(account.GetId()); + + SendAck(endpoint, account, packet.PacketId); + + if (!SendPlayerControllerActor(endpoint, account)) + { + DistrictLogger.Log(LogLevel.Error, "District Handshake", + "Failed to open the PlayerController actor for account {0} after JOIN.", account.GetId()); + return true; + } + + // Open the GRI actor channel right after JOIN (before the + // district-enter ASK) and track it for the client ACK. + OnJoin?.Invoke(endpoint, account); + + return true; + } + else if (text.StartsWith("NETSPEED ", StringComparison.Ordinal)) + { + DistrictLogger.Log(LogLevel.Info, "District Handshake", "Account {0} reported {1}", account.GetId(), text); + } + } + } + + if (!sawLogin) + return false; + + // Ack the login packet so the client stops retransmitting, then + // welcome it. The response value is not validated: the account was + // already authenticated by the world-server handoff. + SendAck(endpoint, account, packet.PacketId); + SendWelcome(endpoint, account); + return true; + } +} diff --git a/DistrictServerCSharp/Logging/DistrictLogger.cs b/DistrictServerCSharp/Logging/DistrictLogger.cs new file mode 100644 index 0000000..8a099f3 --- /dev/null +++ b/DistrictServerCSharp/Logging/DistrictLogger.cs @@ -0,0 +1,204 @@ +using DistrictServerCSharp.Config; + +namespace DistrictServerCSharp.Logging; + +public enum LogLevel +{ + Info = 0, + Warn = 1, + Error = 2, + Success = 3, + Debug = 4 +} + +/// +/// DistrictServer logger, ported from stdafx.cpp. Writes to +/// Logs/DistrictLog.txt and mirrors the message to the console with a +/// per-level colour. On boot the previous run's log is rotated to a +/// timestamped backup so each session's history survives restarts. +/// +/// Mirrors the fixed C++ logger: ONE persistent file handle for the whole +/// process (AutoFlush = the C++ fflush per line) instead of an +/// open/append/close cycle per line, plus a quiet-console mode +/// (APB_LOG_CONSOLE / LogQuietConsole) that skips the slow +/// console writes. The old per-line File.AppendAllText + coloured +/// console write was a file+console I/O storm at the district push rate. +/// +public static class DistrictLogger +{ + private static readonly object Gate = new(); + + // Persistent handle (parity with the C++ g_logFile). Held for the whole + // process; dropped only on rotation and at boot. + private static StreamWriter? _writer; + + private static bool _quietConsoleRead; + private static bool _quietConsole; + + /// Directory (relative to the working directory) holding the log file. + public static string LogDirectory { get; set; } = "Logs"; + + /// Name of the live log file inside . + public static string LogFileName { get; set; } = "DistrictLog.txt"; + + public static string LogPath => Path.Combine(LogDirectory, LogFileName); + + /// + /// True when console mirroring is disabled (APB_LOG_CONSOLE=1). The log + /// file always receives the line; console writes are slow, especially + /// when launched with redirected stdout. + /// + public static bool QuietConsole + { + get + { + if (!_quietConsoleRead) + { + // Same env/INI names as the C++ (DistrictConfig.ReadBool + // checks APB_LOG_CONSOLE first, then HandshakeProbe.ini). + _quietConsole = DistrictConfig.ReadBool("APB_LOG_CONSOLE", "LogQuietConsole", false); + _quietConsoleRead = true; + } + return _quietConsole; + } + } + + private static void EnsureLogDirectory() + { + // Best effort: calls into the directory would otherwise fail silently + // (the original fopen returned null when the parent folder was missing). + Directory.CreateDirectory(LogDirectory); + } + + // FileShare.ReadWrite = "deny none", so tail/head/grep still work while + // the server holds the handle (parity with the C++ _SH_DENYNO). + private static StreamWriter OpenWriter() + { + EnsureLogDirectory(); + var stream = new FileStream( + LogPath, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite); + return new StreamWriter(stream) { AutoFlush = true }; + } + + private static void CloseWriter() + { + if (_writer != null) + { + _writer.Flush(); + _writer.Dispose(); + _writer = null; + } + } + + private static StreamWriter Writer + { + get + { + if (_writer == null) + _writer = OpenWriter(); + return _writer; + } + } + + /// + /// Rotate the previous run's log to a timestamped backup so each session's + /// history survives restarts. Mirrors what the .NET world/lobby logger does + /// (Backup folder), preventing the failure mode where a district restart + /// truncated the evidence of an in-progress session. + /// + public static void RotatePreviousLog() + { + // Drop the persistent handle first so the rotation below can rename + // the file while nothing holds it open (parity with the C++ fix). + CloseWriter(); + + EnsureLogDirectory(); + + string logPath = LogPath; + + if (!File.Exists(logPath)) + return; // no previous run + + string baseName = $"DistrictLog-{DateTime.Now:yyyyMMdd-HHmmss}"; + + // Two boots inside the same second (or a leftover from a previous boot) + // would collide; append a counter instead of losing the old run. + for (int attempt = 0; attempt < 100; ++attempt) + { + string backup = attempt == 0 + ? Path.Combine(LogDirectory, baseName + ".txt") + : Path.Combine(LogDirectory, $"{baseName}-{attempt:000}.txt"); + + try + { + File.Move(logPath, backup); + return; + } + catch (IOException) + { + // Target already exists: try the next suffix. + } + catch (UnauthorizedAccessException) + { + return; // rename failed for a real reason; keep the old log + } + } + } + + /// Rotate the previous run's log and start a fresh one. + public static void Clear() + { + lock (Gate) + { + RotatePreviousLog(); + EnsureLogDirectory(); + // Reopen the handle so DistrictLog.txt exists immediately. + CloseWriter(); + _writer = OpenWriter(); + } + } + + public static void Log(LogLevel level, string caller, string message) + { + lock (Gate) + { + string line = $"[{DateTime.Now:HH:mm:ss}] {caller}: {message}"; + + try + { + Writer.WriteLine(line); + } + catch (IOException) + { + // Best effort: never let logging take the server down. + } + + if (QuietConsole) + return; + + ConsoleColor? color = level switch + { + LogLevel.Info => ConsoleColor.White, + LogLevel.Warn => ConsoleColor.Yellow, + LogLevel.Error => ConsoleColor.Red, + LogLevel.Success => ConsoleColor.Green, + LogLevel.Debug => ConsoleColor.Blue, + _ => null + }; + + Console.Write($"[{DateTime.Now:HH:mm:ss}] "); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write($"{caller}: "); + if (color is { } c) + Console.ForegroundColor = c; + Console.WriteLine(message); + Console.ResetColor(); + } + } + + public static void Log(LogLevel level, string caller, string format, params object[] args) + => Log(level, caller, string.Format(format, args)); +} diff --git a/DistrictServerCSharp/Net/DistrictCrypto.cs b/DistrictServerCSharp/Net/DistrictCrypto.cs new file mode 100644 index 0000000..5dd5ebb --- /dev/null +++ b/DistrictServerCSharp/Net/DistrictCrypto.cs @@ -0,0 +1,134 @@ +namespace DistrictServerCSharp.Net; + +/// +/// APB district channel cipher, ported from DistrictServer.cpp. The retail +/// client does NOT use plain XTEA on district UDP. It uses XXTEA (Corrected +/// Block TEA): the WHOLE datagram is treated as a single array of +/// little-endian 32-bit words and mixed together, with a FIXED round count of +/// 6 (NOT the textbook 6 + 52/n). +/// +/// Recovered from the client's own routine at RVA 0x011C1ED0 and verified +/// byte-exact. The client decrypts inbound and encrypts outbound, so the +/// server must encrypt every packet it sends (challenge included) and decrypt +/// every packet it receives after the plaintext AUTH bunch. +/// +public static class DistrictCrypto +{ + private const uint BteaDelta = 0x9E3779B9u; + private const int BteaRounds = 6; + + private static uint BteaMx(uint z, uint y, uint sum, ReadOnlySpan key, uint e, uint p) + { + return (((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ + ((sum ^ y) + (key[(int)((p & 3u) ^ e)] ^ z)); + } + + private static void BteaLoadKey(ReadOnlySpan key, Span output) + { + for (int i = 0; i < 4; ++i) + { + output[i] = (uint)key[i * 4] | + ((uint)key[i * 4 + 1] << 8) | + ((uint)key[i * 4 + 2] << 16) | + ((uint)key[i * 4 + 3] << 24); + } + } + + private static void LoadWords(ReadOnlySpan data, Span v) + { + for (int i = 0; i < v.Length; ++i) + { + v[i] = (uint)data[i * 4] | + ((uint)data[i * 4 + 1] << 8) | + ((uint)data[i * 4 + 2] << 16) | + ((uint)data[i * 4 + 3] << 24); + } + } + + private static void StoreWords(ReadOnlySpan v, Span data) + { + for (int i = 0; i < v.Length; ++i) + { + data[i * 4] = (byte)v[i]; + data[i * 4 + 1] = (byte)(v[i] >> 8); + data[i * 4 + 2] = (byte)(v[i] >> 16); + data[i * 4 + 3] = (byte)(v[i] >> 24); + } + } + + /// In-place. data must already be a multiple of 4 bytes and >= 8 bytes. + public static bool Encrypt(Span data, ReadOnlySpan key) + { + if (data.Length < 8 || data.Length % 4 != 0) + return false; + + int n = data.Length / 4; + Span v = stackalloc uint[n]; + LoadWords(data, v); + + Span k = stackalloc uint[4]; + BteaLoadKey(key, k); + + uint sum = 0; + uint z = v[n - 1]; + uint y; + + for (int round = 0; round < BteaRounds; ++round) + { + sum += BteaDelta; + uint e = (sum >> 2) & 3u; + + for (int p = 0; p + 1 < n; ++p) + { + y = v[p + 1]; + v[p] += BteaMx(z, y, sum, k, e, (uint)p); + z = v[p]; + } + + y = v[0]; + v[n - 1] += BteaMx(z, y, sum, k, e, (uint)(n - 1)); + z = v[n - 1]; + } + + StoreWords(v, data); + return true; + } + + /// In-place. data must already be a multiple of 4 bytes and >= 8 bytes. + public static bool Decrypt(Span data, ReadOnlySpan key) + { + if (data.Length < 8 || data.Length % 4 != 0) + return false; + + int n = data.Length / 4; + Span v = stackalloc uint[n]; + LoadWords(data, v); + + Span k = stackalloc uint[4]; + BteaLoadKey(key, k); + + uint sum = unchecked((uint)BteaRounds * BteaDelta); + uint y = v[0]; + uint z; + + for (int round = 0; round < BteaRounds; ++round) + { + uint e = (sum >> 2) & 3u; + + for (int p = n - 1; p > 0; --p) + { + z = v[p - 1]; + v[p] -= BteaMx(z, y, sum, k, e, (uint)p); + y = v[p]; + } + + z = v[n - 1]; + v[0] -= BteaMx(z, y, sum, k, e, 0); + y = v[0]; + sum -= BteaDelta; + } + + StoreWords(v, data); + return true; + } +} diff --git a/DistrictServerCSharp/Net/EndpointAddress.cs b/DistrictServerCSharp/Net/EndpointAddress.cs new file mode 100644 index 0000000..b6abd1d --- /dev/null +++ b/DistrictServerCSharp/Net/EndpointAddress.cs @@ -0,0 +1,34 @@ +using System.Net; + +namespace DistrictServerCSharp.Net; + +/// +/// Converts an IPv4 address to a consistent uint32 endpoint key, matching the +/// C++ sin_addr.s_addr value (the four address bytes in network order +/// interpreted as a little-endian uint32). Avoids the obsolete +/// IPAddress.Address property. The exact byte order does not matter for +/// correctness as long as every writer and reader uses the same conversion. +/// +public static class EndpointAddress +{ + public static uint ToWireValue(IPAddress address) + { + byte[] bytes = address.GetAddressBytes(); + return (uint)bytes[0] | + ((uint)bytes[1] << 8) | + ((uint)bytes[2] << 16) | + ((uint)bytes[3] << 24); + } + + /// Inverse of : reconstructs the IPv4 address. + public static IPAddress FromWireValue(uint value) + { + return new IPAddress(new[] + { + (byte)(value & 0xFF), + (byte)((value >> 8) & 0xFF), + (byte)((value >> 16) & 0xFF), + (byte)((value >> 24) & 0xFF) + }); + } +} diff --git a/DistrictServerCSharp/Net/Network.cs b/DistrictServerCSharp/Net/Network.cs new file mode 100644 index 0000000..372e4e8 --- /dev/null +++ b/DistrictServerCSharp/Net/Network.cs @@ -0,0 +1,196 @@ +using System.Net; +using System.Net.Sockets; + +namespace DistrictServerCSharp.Net; + +/// +/// TCP control connection to the WorldServer, ported from Network.cpp. The +/// district registers itself and receives character-handoff data over this +/// link. Synchronous, matching the C++ single-threaded poll model. +/// +public sealed class Network : IDisposable +{ + private Socket? _socket; + private string _address = ""; + private int _port; + + public const int Ok = 0; + + public bool IsConnected => _socket is { Connected: true }; + + public string Address => _address; + public int Port => _port; + + /// Creates the socket and stores the world-server endpoint. + public bool Setup(string address, int port) + { + _address = address; + _port = port; + + try + { + _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true + }; + return true; + } + catch (SocketException) + { + Dispose(); + return false; + } + } + + public bool Connect() + { + if (_socket == null) + return false; + + try + { + _socket.Connect(IPAddress.Parse(_address), _port); + return _socket.Connected; + } + catch (SocketException) + { + Dispose(); + return false; + } + } + + public void Dispose() + { + _socket?.Close(); + _socket = null; + } + + public bool Shutdown() + { + if (_socket == null) + return false; + + try + { + _socket.Shutdown(SocketShutdown.Send); + return true; + } + catch (SocketException) + { + Dispose(); + return false; + } + } + + /// + /// Sends the complete buffer. The C++ version used strlen() on a C string, + /// which silently truncated any payload containing an embedded NUL (the + /// registration's port bytes). Taking an explicit length makes the wire + /// output deterministic. + /// + public bool Send(ReadOnlySpan buffer) + { + if (_socket == null) + return false; + + int total = 0; + + while (total < buffer.Length) + { + int result; + try + { + result = _socket.Send(buffer[total..]); + } + catch (SocketException) + { + Dispose(); + return false; + } + + if (result <= 0) + { + Dispose(); + return false; + } + + total += result; + } + + return true; + } + + /// + /// Reads exactly bytes. TCP recv() may return fewer + /// bytes than requested, so the loop reads until the protocol field size is + /// satisfied. Returns null on error or a closed connection. + /// + public byte[]? Receive(int size) + { + if (size <= 0 || _socket == null) + return null; + + var buffer = new byte[size]; + int total = 0; + + while (total < size) + { + int result; + try + { + result = _socket.Receive(buffer, total, size - total, SocketFlags.None); + } + catch (SocketException) + { + Dispose(); + return null; + } + + if (result > 0) + { + total += result; + continue; + } + + if (result == 0) + { + Logging.DistrictLogger.Log( + Logging.LogLevel.Error, + "Network::Receive()", + "Connection closed while waiting for {0} bytes (received {1})", + size, + total); + } + else + { + Logging.DistrictLogger.Log( + Logging.LogLevel.Error, + "Network::Receive()", + "Receiving failed! Error code: {0}", + _socket.LastError()); + } + + Dispose(); + return null; + } + + return buffer; + } +} + +internal static class SocketExtensions +{ + /// Returns the last error code of the socket without throwing. + public static int LastError(this Socket socket) + { + try + { + object? value = socket.GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Error); + return value is null ? -1 : (int)value; + } + catch (SocketException) + { + return -1; + } + } +} diff --git a/DistrictServerCSharp/Net/UdpListener.cs b/DistrictServerCSharp/Net/UdpListener.cs new file mode 100644 index 0000000..d1a61af --- /dev/null +++ b/DistrictServerCSharp/Net/UdpListener.cs @@ -0,0 +1,370 @@ +using System.Net; +using System.Net.Sockets; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Controller; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Gri; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Pawn; +using DistrictServerCSharp.Protocol; +using DistrictServerCSharp.SpawnZone; +using DistrictServerCSharp.Streaming; + +namespace DistrictServerCSharp.Net; + +/// +/// The district's UDP listener, ported from UdpListenerThread in +/// DistrictServer.cpp. Binds the configured port, receives client datagrams, +/// decrypts them (the first AUTH bunch is plaintext; everything after is +/// BTEA-encrypted with the account's session key), parses the UE3 framing and +/// dispatches through the full startup pipeline: AUTH → USES+WELCOME, reliable +/// ACK processing, GRI match-start, the per-endpoint possession gate that +/// answers the district-enter ASK and drives the streaming plan, then +/// controller actor-field feedback. +/// +public sealed class UdpListener +{ + private readonly int _port; + private readonly HandshakeService _handshake; + private readonly ReliableQueue _reliableQueue; + private readonly GriStartupService _griStartup; + private readonly SpawnZoneService _spawnZone; + private readonly LevelStreamingService _streaming; + private readonly PawnLifecycleService _pawnLifecycle; + private readonly ControllerFeedbackService _controllerFeedback; + private readonly RemotePawnReplication _remotePawn; + private readonly BinaryControlService _binaryControl; + private readonly TransportState _transportState; + + // The possession gate, keyed by the client endpoint (address, port), not + // the account id. The original global account-id set was never cleared, so + // a reconnect of the same account from a new socket had its ASK received + // but never answered -- possessed.insert(id) returned false -- and the + // client parked at "Entering district" forever. A fresh connection gets + // its own entry and re-runs the answer. + private readonly object _possessionLock = new(); + private readonly HashSet<(uint Address, ushort Port)> _possessed = new(); + + // Last time a WSAECONNRESET was logged, to suppress the dead-peer ICMP + // spam to at most one message per 10 seconds. + private long _lastResetLogTick; + + public UdpListener(int port) + { + _port = port; + _handshake = new HandshakeService(new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)); + _reliableQueue = new ReliableQueue(_handshake); + _griStartup = new GriStartupService(_handshake, _reliableQueue); + _spawnZone = new SpawnZoneService(_handshake, _reliableQueue); + _streaming = new LevelStreamingService(_handshake, _griStartup, _spawnZone); + _pawnLifecycle = new PawnLifecycleService(_handshake, _reliableQueue); + _controllerFeedback = new ControllerFeedbackService(_handshake, _reliableQueue, _pawnLifecycle, _spawnZone, _streaming); + + // Remote-pawn multiplayer visibility. RemotePawnReplication depends on + // the pawn lifecycle (compact descriptor), so the reverse reference is + // injected after construction. + _remotePawn = new RemotePawnReplication(_handshake, _controllerFeedback, _pawnLifecycle); + _pawnLifecycle.RemotePawnReplication = _remotePawn; + + _binaryControl = new BinaryControlService(_handshake); + _transportState = new TransportState(_handshake); + + // Wire the cross-service hooks. + _handshake.OnJoin += (endpoint, account) => _griStartup.OpenGriBeforeStreaming(endpoint, account); + + // A fresh ServerMove sample with a client location triggers the + // throttled remote-pawn position push to in-world viewers. + _controllerFeedback.OnMotionSample += account => _remotePawn.MaybePushRemotePawnLocations(account); + + Lifecycle.ResetGriStartupAction = _griStartup.ResetGriStartupState; + Lifecycle.ResetPawnAckGatedAction = _pawnLifecycle.ResetPawnAckGatedSequenceState; + Lifecycle.ResetControllerFeedbackAction = _controllerFeedback.Reset; + } + + public void Run() + { + try + { + _handshake.UdpSocket.Bind(new IPEndPoint(IPAddress.Any, _port)); + } + catch (SocketException exception) + { + DistrictLogger.Log(LogLevel.Error, "District UDP", "bind(0.0.0.0:{0}) failed: {1}", _port, exception.SocketErrorCode); + return; + } + + DistrictLogger.Log(LogLevel.Success, "District UDP", + "Listening on 0.0.0.0:{0}; parser active, APB_ACK_MODE={1}, APB_CHALLENGE_MODE={2}", + _port, + DistrictConfig.AckModeName(DistrictConfig.AckModeValue), + DistrictConfig.ChallengeModeName(DistrictConfig.ChallengeModeValue)); + + var receiveBuffer = new byte[65535]; + var remoteEndpoint = (EndPoint)new IPEndPoint(IPAddress.Any, 0); + + while (true) + { + int received; + try + { + received = _handshake.UdpSocket.ReceiveFrom(receiveBuffer, ref remoteEndpoint); + } + catch (SocketException exception) + { + // WSAECONNRESET is the ICMP "port unreachable" echo from a + // peer whose socket closed (a client that quit or a stale + // endpoint we keep pushing to). Benign for UDP but spams the + // log on every send cycle; attribute it to the last-pushed + // viewers for dead-peer cleanup and log at most once per 10 s. + if (exception.SocketErrorCode == SocketError.ConnectionReset) + { + _remotePawn.AttributeRemotePawnResetErrors(); + + long now = Environment.TickCount64; + if (now - _lastResetLogTick > 10000) + { + _lastResetLogTick = now; + DistrictLogger.Log(LogLevel.Warn, "District UDP", + "recvfrom() WSAECONNRESET (dead-peer ICMP); subsequent resets suppressed for 10 s."); + } + continue; + } + + DistrictLogger.Log(LogLevel.Error, "District UDP", "recvfrom() failed: {0}", exception.SocketErrorCode); + Thread.Sleep(100); + continue; + } + + var endpoint = (IPEndPoint)remoteEndpoint; + byte[] packetBytes = receiveBuffer.AsSpan(0, received).ToArray(); + + PacketDiagnostics.SaveCapture("RX", endpoint, packetBytes); + + DistrictLogger.Log(LogLevel.Info, "District UDP", "RX {0} bytes from {1} | {2}", + received, HandshakeService.EndpointText(endpoint), Diagnostics.Hex(packetBytes, 512)); + + var packet = new Packet(); + bool parsed = TryDecryptAndParse(endpoint, packetBytes, packet); + + if (parsed) + { + DistrictLogger.Log(LogLevel.Info, "District UE3", "{0} from {1}", Diagnostics.DescribePacket(packet), HandshakeService.EndpointText(endpoint)); + + if (_handshake.ProcessAuthPacket(endpoint, packet)) + continue; + + Account? endpointAccount = AccountManager.FindByEndpoint(endpoint); + + // Any traffic from a remote-pawn participant proves it is + // alive: clear its dead-peer counters and lift a suspension + // immediately, so a recovered peer resumes pushing. + if (endpointAccount != null) + _remotePawn.MarkRemotePawnPeerAlive(endpointAccount.GetId()); + + // Retire/retry server reliable startup packets using client ACK + // bunches. This is especially important for the GRI open: it + // must be accepted before blocked-load completion. + _reliableQueue.ProcessServerPacketAcknowledgements(endpointAccount, packet); + + // Runs only after the final ACK-gated possession stage has + // completed. Time-based and non-blocking. + _pawnLifecycle.PostPossessionUnlock.MaybeSend(endpointAccount); + + // One-shot only: armed by a measured hard landing. + _controllerFeedback.MaybeSendHardLandingWindedRecovery(endpointAccount); + + // bMatchHasBegun is sent only after the client ACKs the GRI + // actor-open bunch. + _griStartup.MaybeSendGriMatchHasBegun(endpoint, endpointAccount); + + _reliableQueue.RetryPendingServerReliables(endpointAccount); + + // ACK reliable actor-channel traffic immediately so the client + // stops retransmitting and does not hit its 60-second timeout. + // Also records the client transport state (endpoint/packet id/ + // ticks) and throttles keep-alive ACKs. + _transportState.MaybeSendTransportAck(endpoint, endpointAccount, packet); + + // Decode the dedicated cCustomisationReplicator actor channel + // before controller traffic. Its client completion handshake is + // ServerNotifyOperationComplete, field 24. + _controllerFeedback.ProcessCustomisationReplicatorFeedback(endpoint, endpointAccount, packet); + + // Decode client → server controller RPCs (movement, spawn-zone + // select, visibility, customisation). + _controllerFeedback.ProcessControllerActorFeedback(endpoint, endpointAccount, packet); + + // Once the client starts sending on the controller's actor + // channel its PlayerController exists, so answer the district + // enter request and drive the streaming plan. Done once per + // connection (per-endpoint key). + ProcessPossessionGate(endpoint, endpointAccount, packet); + + if (endpointAccount != null && _handshake.ProcessTextControlPacket(endpoint, endpointAccount, packet)) + continue; + + // Binary NMT control (HandshakeStart / HandshakeResponse etc.). + // Runs after the text handler; APB's control protocol is text + // based, so binary messages are rare but the handshake can be + // driven over them. + if (endpointAccount != null && _binaryControl.ProcessBinaryControlPacket(endpoint, endpointAccount, packet)) + continue; + + endpointAccount?.IncrementUdpReceiveCount(); + continue; + } + + Account? unparsedAccount = AccountManager.FindByEndpoint(endpoint); + + DistrictLogger.Log(LogLevel.Debug, "District UE3", "Plain parse failed from {0}: {1}", + HandshakeService.EndpointText(endpoint), packet.Error ?? ""); + + PacketDiagnostics.DiagnoseEncryptedPacket(packetBytes, unparsedAccount, endpoint); + + // Retransmit the challenge a limited number of times when the + // client's post-AUTH packets fail to parse (encryption mismatch). + if (unparsedAccount != null && + unparsedAccount.GetHandshakeState() == Account.HandshakeState.ChallengeSent && + unparsedAccount.GetChallengeSendCount() < 3) + { + DistrictLogger.Log(LogLevel.Info, "District Handshake", + "Unparsed post-AUTH packet received; retransmitting the same challenge probe (attempt {0} of 3).", + unparsedAccount.GetChallengeSendCount() + 1u); + + _handshake.SendAuthAckAndChallenge(endpoint, unparsedAccount, unparsedAccount.GetLastClientPacketId(), 0, true); + } + } + } + + // ------------------------------------------------------------------ + // Decrypt / parse + // ------------------------------------------------------------------ + + /// + /// The very first client packet (the AUTH bunch) is plaintext, so a direct + /// parse succeeds. Every packet after it is BTEA-encrypted, so when the + /// endpoint already has a session key the whole datagram is decrypted and + /// parsed first. A plaintext attempt is only a fallback: a ciphertext + /// datagram can occasionally decode as a plausible packet by chance, which + /// would silently discard a real message. + /// + private bool TryDecryptAndParse(IPEndPoint endpoint, byte[] data, Packet packet) + { + Account? keyed = AccountManager.FindByEndpoint(endpoint); + + // Pass 1: decrypt-then-parse when the endpoint is keyed. + if (keyed != null && data.Length >= 8 && data.Length % 4 == 0) + { + byte[] decoded = (byte[])data.Clone(); + if (DistrictCrypto.Decrypt(decoded, keyed.GetEncryptionKey()) && + PacketParser.ParsePacket(decoded, decoded.Length, packet)) + { + return true; + } + } + + // Pass 2: plaintext parse (the AUTH bunch). + if (PacketParser.ParsePacket(data, data.Length, packet)) + return true; + + // Pass 3: retry the decrypt path explicitly (mirrors the C++ which + // runs this second block after the plaintext attempt). + if (keyed != null && data.Length >= 8 && data.Length % 4 == 0) + { + byte[] decoded = (byte[])data.Clone(); + if (DistrictCrypto.Decrypt(decoded, keyed.GetEncryptionKey()) && + PacketParser.ParsePacket(decoded, decoded.Length, packet)) + { + DistrictLogger.Log(LogLevel.Info, "District UDP", "Decrypted {0}-byte packet from {1} (XXTEA)", data.Length, HandshakeService.EndpointText(endpoint)); + return true; + } + } + + return false; + } + + // ------------------------------------------------------------------ + // Possession gate + district-enter answer + // ------------------------------------------------------------------ + + /// + /// Once the client starts sending on the controller's actor channel its + /// PlayerController exists, so answer the district-enter request and drive + /// the streaming plan. Done once per connection (per-endpoint key). + /// + private void ProcessPossessionGate(IPEndPoint endpoint, Account? account, Packet packet) + { + if (account == null) + return; + + bool sawControllerChannel = false; + foreach (Bunch bunch in packet.Bunches) + { + if (bunch.Kind == BunchKind.Data && bunch.ChannelIndex == DistrictConfig.ControllerChannel) + { + sawControllerChannel = true; + break; + } + } + + if (!sawControllerChannel) + return; + + bool doIt; + lock (_possessionLock) + { + doIt = _possessed.Add((EndpointAddress.ToWireValue(endpoint.Address), (ushort)endpoint.Port)); + } + + if (!doIt) + return; + + bool enableLevelStreaming = DistrictConfig.ReadSetting("APB_ENABLE_LEVEL_STREAMING", "EnableLevelStreaming", "0") == "1"; + bool streamBeforeDistrictAnswer = DistrictConfig.ReadSetting("APB_STREAM_BEFORE_DISTRICT_ANSWER", "StreamingBeforeDistrictAnswer", "0") == "1"; + + // Default v1.4 order: district-enter success, tracked/retried GRI open, + // HUD/candidate initial state/MapSelect, then the streaming plan. + if (enableLevelStreaming && streamBeforeDistrictAnswer) + _streaming.SendLevelStreamingStatus(endpoint, account); + + _handshake.SendDistrictEnterAnswer(endpoint, account, DistrictConfig.DistrictType(DistrictConfig.GetConfiguredDistrictMap()), 1); + + // Optional field-6 controller-location update (normally off). + if (DistrictConfig.ReadSetting("APB_SEND_CONTROLLER_LOCATION", "SendControllerLocation", "0") == "1") + SendControllerLocation(endpoint, account); + + if (enableLevelStreaming && !streamBeforeDistrictAnswer) + _streaming.SendLevelStreamingStatus(endpoint, account); + + DistrictLogger.Log(LogLevel.Info, "District Handshake", + DistrictConfig.ReadBool("APB_ENABLE_POSSESSION", "EnablePossession", false) + ? "Possession armed and deferred until ServerSelectSpawnZone field 371." + : "Possession disabled; marker/map-select flow remains enabled."); + } + + private void SendControllerLocation(IPEndPoint endpoint, Account account) + { + DistrictConfig.ReadControllerLocation(out float x, out float y, out float z); + bool compressed = DistrictConfig.ReadSetting("APB_CONTROLLER_LOCATION_COMPRESSED", "ControllerLocationCompressed", "0") == "1"; + + byte[] packet = PacketBuilders.BuildActorVectorFieldPacket( + account.AllocateServerPacketId(), + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldLocation, + DistrictConfig.PlayerControllerFieldMax, + x, y, z, + compressed); + + bool sent = _handshake.SendProtectedPacket(endpoint, account, packet, compressed ? "CONTROLLER-LOCATION-COMPRESSED" : "CONTROLLER-LOCATION-RAW"); + + if (sent) + { + DistrictLogger.Log(LogLevel.Info, "District Handshake", + "Replicated controller Location for account {0} as {1} at ({2:F1}, {3:F1}, {4:F1}).", + account.GetId(), compressed ? "compressed vector" : "three raw floats", x, y, z); + } + } +} diff --git a/DistrictServerCSharp/PORTING.md b/DistrictServerCSharp/PORTING.md new file mode 100644 index 0000000..039ad87 --- /dev/null +++ b/DistrictServerCSharp/PORTING.md @@ -0,0 +1,365 @@ +# C++ → C# DistrictServer Port — Tracking & Status + +This is the living map between the **live C++ DistrictServer** (`DistrictServer/`, +the production oracle) and the **C# re-implementation** (`DistrictServerCSharp/`). +It exists because the C++ stack keeps being fixed and extended in parallel with +the conversion — every C++ change must be mirrored here, and this document is +where you check whether a C++ edit already has a C# home. + +Companion docs: `../journal.md` (chronology), `../memory.md` (protocol findings). +The C++ side's own behaviour is authoritative; the C# port must match it byte-for-byte. + +--- + +## 1. Status summary + +| Layer | C++ source | C# port | Verified | +|-------|-----------|---------|----------| +| Bit primitives (LSB-first reader/writer) | `ApbUdp.cpp` | `Bits/BitReader.cs`, `Bits/BitWriter.cs`, `Bits/RWBits.cs` | ✅ self-test | +| Wire crypto (XTEA) | `Xtea.cpp` | `Crypto/Xtea.cs` | ✅ self-test | +| Wire crypto (BTEA/XXTEA district cipher) | `DistrictServer.cpp` (`Btea*`, `ProtectOutgoingPacket`) | `Net/DistrictCrypto.cs` | ✅ self-test | +| TCP control link to WorldServer | `Network.cpp` | `Net/Network.cs` | ✅ drive harness | +| Account model | `Account.cpp` | `Accounts/Account.cs` | ✅ drive harness | +| Account registry | `DistrictServer.cpp` (`FindAccount*`, `AddOrUpdateAccount`) | `Accounts/AccountManager.cs` | ✅ drive harness | +| Logger (rotation + Logs dir) | `stdafx.cpp` | `Logging/DistrictLogger.cs` | ✅ | +| Configuration + district identity | `DistrictServer.cpp` (config block) | `Config/DistrictConfig.cs` | ✅ | +| World registration + character handoffs | `DistrictServer.cpp` (`WorldControl` block) | `WorldControl/WorldControl.cs` | ✅ drive harness | +| Handshake (AUTH/USES/WELCOME/JOIN/ANS) | `DistrictServer.cpp` | `Handshake/HandshakeService.cs` | ✅ drive harness | +| Per-endpoint channel sequences | `DistrictServer.cpp` (`AllocateChannelSequence`) | `Handshake/ChannelSequenceAllocator.cs` | ✅ drive harness | +| Reliable TX queue + retry + ACK | `DistrictServer.cpp` | `Core/ReliableQueue.cs` | ✅ drive harness | +| GRI match-start replication | `DistrictServer.cpp` (`OpenGriBeforeStreaming`, `MaybeSendGriMatchHasBegun`) | `Gri/GriStartupService.cs` | ✅ drive harness | +| Level streaming plan | `DistrictServer.cpp` | `Streaming/LevelStreamingService.cs` | ✅ compile | +| Spawn-zone markers + selection | `DistrictServer.cpp` | `SpawnZone/SpawnZoneService.cs` | ✅ compile | +| ACK-gated pawn bootstrap + possession | `DistrictServer.cpp` | `Pawn/PawnLifecycleService.cs`, `Pawn/PostPossessionMovementUnlock.cs` | ✅ drive harness | +| Controller field decoding (movement/371/visibility/customisation) | `DistrictServer.cpp` | `Controller/ControllerFeedbackService.cs` | ✅ drive harness | +| UE3 packet parse + builders + field decoders | `ApbUdp.cpp` | `Protocol/PacketParser.cs`, `Protocol/PacketBuilders.cs`, `Protocol/FieldDecoders.cs`, `Protocol/Models.cs`, `Protocol/Diagnostics.cs` | ✅ self-test | +| UDP listener + main dispatch | `DistrictServer.cpp` (`UdpListenerThread`, `main`) | `Net/UdpListener.cs`, `Program.cs` | ✅ drive harness | +| Remote-pawn multiplayer replication + dead-peer lifecycle | `DistrictServer.cpp` (`NotifyAccountPossessed`, `ReplicatePawnToAllViewers`, `MaybePushRemotePawnLocations`, `MarkRemotePawnPeerAlive`, …) | `Pawn/RemotePawnReplication.cs` | ✅ drive harness (2 players) | +| Binary NMT control | `DistrictServer.cpp` (`ProcessBinaryControlPacket`) | `Handshake/BinaryControlService.cs` | ✅ compile | +| Registration-reply replace (`0x31`) | `DistrictServer.cpp` (`ProcessRegistrationResponse` `'1'` case) | `WorldControl/WorldControl.cs` (mirrored 2026-08-17) | ✅ drive harness | +| Persistent-handle logger + quiet console | `stdafx.cpp` (`Logger`, `g_logQuietConsole`) | `Logging/DistrictLogger.cs` (mirrored 2026-08-17) | ✅ production | +| Cooked-package net-index resolver | `DistrictServer.cpp` (`ParseCookedPackageSummary`, `TryComputeExactPackageFirstNetIndex`, …) | `Packages/CookedPackageResolver.cs` | ✅ `--resolver-check` | +| Transport-state tracking (record + keep-alive ACK) | `DistrictServer.cpp` (`RecordClientTransportPacket`, `RecordClientTransportAck`, `MaybeSendTransportAck`) | `Core/TransportState.cs` | ✅ drive harness | +| Customisation replicator request dispatch | `DistrictServer.cpp` (`ProcessCustomisationReplicatorFeedback`, `SendCharacterCustomisationCompletion`) | `Controller/ControllerFeedbackService.cs` | ✅ drive harness | +| Packet diagnostics (capture + XTEA forensics) | `DistrictServer.cpp` (`SaveCapture`, `DiagnoseEncryptedPacket`) | `Core/PacketDiagnostics.cs` | ✅ compile | +| Dead code — not in C++ build | `Unreal.cpp` | ❌ intentionally skipped | — | + +--- + +## 2. File map + +| C++ file | C# files | +|----------|----------| +| `DistrictServer/ApbUdp.cpp` / `.h` | `Protocol/*`, `Bits/*`, `Net/DistrictCrypto.cs` | +| `DistrictServer/DistrictServer.cpp` | `Config/*`, `WorldControl/*`, `Handshake/*`, `Core/*`, `Gri/*`, `Streaming/*`, `SpawnZone/*`, `Pawn/*`, `Controller/*`, `Packages/*`, `Net/UdpListener.cs`, `Accounts/AccountManager.cs`, `Program.cs` | +| `DistrictServer/Account.cpp` / `.h` | `Accounts/Account.cs` | +| `DistrictServer/Network.cpp` / `.h` | `Net/Network.cs` | +| `DistrictServer/Xtea.cpp` / `.h` | `Crypto/Xtea.cs` | +| `DistrictServer/RWBits.h` | `Bits/RWBits.cs` | +| `DistrictServer/stdafx.cpp` / `.h` | `Logging/DistrictLogger.cs` | +| `DistrictServer/Unreal.cpp` / `.h` | — (dead code, not in the C++ build) | +| `DistrictServer/targetver.h` | — (build metadata) | + +--- + +## 3. Function map (C++ → C#) + +### 3.1 World control (registration + handoffs) + +| C++ | C# | +|-----|-----| +| `ProcessRegistrationResponse` | `WorldControl.ProcessRegistrationResponse` | +| `ReceivePhase4CharacterHandoff` | `WorldControl.ReceivePhase4CharacterHandoff` | +| `ReceivePhase3CharacterHandoff` | `WorldControl.ReceivePhase3CharacterHandoff` | +| `ReceivePhase2Handoff` | `WorldControl.ReceivePhase2Handoff` | +| `ReceiveLegacyHandoff` | `WorldControl.ReceiveLegacyHandoff` | +| `ProcessWorldControlRecord` | `WorldControl.ProcessWorldControlRecord` | +| registration datagram (in `main`) | `WorldControl.BuildRegistration` | + +### 3.2 Configuration + +| C++ | C# | +|-----|-----| +| `ReadHandshakeInt` / `ReadHandshakeBool` | `DistrictConfig.ReadInt` / `ReadBool` (env-overridable) | +| `ConfiguredDistrictId/Language/UdpPort`, `ConfiguredWorldServerAddress/Port` | `DistrictConfig.Configured*` | +| `DistrictType`, `IsActionDistrict`, `TryGetActionSpawnDirection` | `DistrictConfig.DistrictType`, `IsActionDistrict`, `TryGetActionSpawnDirection` | +| `TryGetSpawnZoneLocation` | `DistrictConfig.TryGetSpawnZoneLocation` (action → spawn directions, Social → the two cooked spawn-direction actors) | +| `ReadFixedChallenge`, `GenerateChallenge` | `DistrictConfig.ReadFixedChallenge`, `GenerateChallenge` | +| `PackageFirstNetIndex`, `GlobalNetIndex` | `DistrictConfig.PackageFirstNetIndex`, `GlobalNetIndex` | +| `*WireField()` getters (GRI, character-info, MapSelect, 371, visibility, HUD marker, winded) | `DistrictConfig.*WireField()` | +| `ReadStreamingPlan` / `ParsePlanBoolean` | `DistrictConfig` streaming-plan helpers | +| `RemotePawnReplicationEnabled`, `RemotePawnSendLocation/Velocity/Rotation/Pri/Descriptor`, `RemotePawnOpenDelayMilliseconds`, `RemotePawnChannelBase` | `DistrictConfig.RemotePawn*` | + +### 3.3 Handshake + wire sends + +| C++ | C# | +|-----|-----| +| `EndpointText`, `ConstantTimeEquals` | `HandshakeService.EndpointText`, `ConstantTimeEquals` | +| `SendPacket`, `SendProtectedPacket`, `ProtectOutgoingPacket` | `HandshakeService.SendPacket`, `SendProtectedPacket`; `DistrictCrypto` | +| `SendAuthAckAndChallenge`, `SendHandshakeComplete` | `HandshakeService.SendAuthAckAndChallenge`, `SendHandshakeComplete` | +| `SendUses`, `SendWelcome` | `HandshakeService.SendUses`, `SendWelcome` | +| `SendPlayerControllerActor` | `HandshakeService.SendPlayerControllerActor` | +| `SendDistrictEnterAnswer` | `HandshakeService.SendDistrictEnterAnswer` | +| `ProcessAuthPacket`, `ProcessTextControlPacket` | `HandshakeService.ProcessAuthPacket`, `ProcessTextControlPacket` | +| `ProcessBinaryControlPacket` | `BinaryControlService.ProcessBinaryControlPacket` | +| `AllocateChannelSequence` | `ChannelSequenceAllocator.Allocate` (per-endpoint key) | +| `SendTrackedReliablePacket`, `ProcessServerPacketAcknowledgements`, `RetryPendingServerReliables`, `CancelPendingReliablesByLabelPrefix` | `ReliableQueue.*` | + +### 3.4 GRI + streaming + spawn zone + +| C++ | C# | +|-----|-----| +| `OpenGriBeforeStreaming`, `MaybeSendGriMatchHasBegun`, `ResetGriStartupState` | `GriStartupService.*` | +| `SendLevelStreamingStatus`, `SendPreStreamingControllerStartup` | `LevelStreamingService.SendLevelStreamingStatus`, `SendPreStreamingControllerStartup` | +| `BuildCharacterCustomisationTransferPayload`, `ExtractCharacterCustomisationGuid`, `FormatGuid` | `LevelStreamingService.*` | +| `SendSpawnZoneHudMarker`, `MarkSpawnZoneMarkersSent` | `SpawnZoneService.SendSpawnZoneHudMarker` | + +### 3.5 Pawn lifecycle + +| C++ | C# | +|-----|-----| +| `SendPawnAndPossess` | `PawnLifecycleService.SendPawnAndPossess` | +| `SendPawnCharacterBootstrapFields` | `PawnLifecycleService.SendPawnCharacterBootstrapFields` | +| `BuildPawnCompactGolemDescriptor` | `PawnLifecycleService.BuildPawnCompactGolemDescriptor` | +| `SendStarterWeaponInventoryItem` | `PawnLifecycleService.SendStarterWeaponInventoryItem` | +| `PawnAckGatedStageEnabled`, `ResetPawnAckGatedSequenceState`, `ArmPawnAckGatedSequence`, `SendNextPawnAckGatedStage`, `AdvancePawnAckGatedSequenceAfterAck` | `PawnLifecycleService.*` | +| `ResetPostPossessionMovementUnlockState` + post-possession unlock | `PostPossessionMovementUnlock.*` | +| `NotifyAccountPossessed`, `ReplicatePawnToAllViewers`, `ReplicateExistingPawnsToViewer`, `MaybePushRemotePawnLocations`, `SendRemotePawnToViewer`, `IsAccountInWorld`, `GetAccountCurrentLocation`, `GetInWorldViewersOf` | `RemotePawnReplication.*` | +| `MarkRemotePawnPeerAlive`, `AttributeRemotePawnResetErrors`, `CleanupDeadRemotePawnPeers` | `RemotePawnReplication.*` (dead-peer lifecycle) | + +### 3.6 Controller feedback (client → server) + +| C++ | C# | +|-----|-----| +| `ProcessControllerActorFeedback` | `ControllerFeedbackService.ProcessControllerActorFeedback` | +| `ProcessControllerMovementRpc` | `ControllerFeedbackService.ProcessControllerMovementRpc` | +| 371 spawn-zone select handler | `ControllerFeedbackService.HandleServerSelectSpawnZone` | +| `ServerNotifyClientLoaded` handler | `ControllerFeedbackService.HandleServerNotifyClientLoaded` | +| `SendClientReceiveCharacterData/Stats/RolesData` | `ControllerFeedbackService.SendClientReceiveCharacter*` | +| `SendPawnCrouchState` | `ControllerFeedbackService.SendPawnCrouchState` | +| `SendCharacterCustomisationTransfer/Chunk`, `ProcessCustomisationReplicatorFeedback` (ServerSendData 21 + ServerNotifyOperationComplete 24) | `ControllerFeedbackService.SendCharacterCustomisationTransfer/Chunk`, `ProcessCustomisationReplicatorFeedback`, `HandleReplicatorServerSendData`, `HandleReplicatorServerNotifyOperationComplete`, `SendCharacterCustomisationCompletion` | +| `MaybeSendHardLandingWindedRecovery` | `ControllerFeedbackService.MaybeSendHardLandingWindedRecovery` | + +### 3.7 UDP listener + main + +| C++ | C# | +|-----|-----| +| `UdpListenerThread`, `TryDecryptAndParse`, possession gate | `UdpListener.*` | +| `MaybeSendTransportAck` | `TransportState.MaybeSendTransportAck` | +| `main()` (startup, world-control loop) | `Program.Startup` + `Program.RunServer` | + +### 3.8 Ported helpers (small, individually mapped for drift tracking) + +These are the smaller ported helpers. They are enumerated so `Tools/check_port_sync.py` +can tell a genuinely new C++ function from one that already has a C# home. + +| C++ | C# | +|-----|-----| +| `SendAck`, `EncodeUInt32Little` | `HandshakeService.SendAck`, `EncodeUInt32Little` | +| `BteaMx`, `BteaLoadKey`, `BteaEncrypt`, `BteaDecrypt` | `DistrictCrypto.*` | +| `ReadAckMode`, `ReadChallengeMode`, `AckModeName`, `ChallengeModeName`, `GetHandshakeConfigPath` | `DistrictConfig.*` | +| `GetConfiguredDistrictMap`, `DistrictMapName`, `DistrictWelcomeLevel`, `ConfiguredWorldServerAddress`, `ConfiguredWorldServerPort`, `ConfiguredDistrictId`, `ConfiguredDistrictLanguage`, `ConfiguredDistrictUdpPort` | `DistrictConfig.*` | +| `ReadControllerCoordinate`, `ReadControllerLocation` | `DistrictConfig.*` | +| `GriFieldMax` | `DistrictConfig.GriFieldMax` | +| `Lower`, `SplitSetting`, `TrimSetting` | `DistrictConfig` (lower/split/trim helpers) | +| `ReadLittleEndianUInt32`, `ReadLittleEndianUInt16` | `WorldControl.ReadU32`, `WorldControl.ReadU16` | +| `FindAccount`, `FindAccountByEndpoint`, `AddOrUpdateAccount` | `AccountManager.Find`, `FindByEndpoint`, `AddOrUpdate` | +| `SendControllerLocation` | `UdpListener.SendControllerLocation` | +| `SendInventoryActorBootstrap`, `SendHoldableOwningPawnLink`, `BuildStarterWeaponInventoryItem`, `SendStarterWeaponInventoryItem` | `PawnLifecycleService.*` | +| `ResolveInventoryActorArchetypes` | `CookedPackageResolver.ResolveInventoryActorArchetypes` | +| `RecordClientTransportPacket`, `RecordClientTransportAck` | `TransportState.RecordClientTransportPacket`, `RecordClientTransportAck` | +| `NextPawnAckGatedStage`, `PawnAckGatedStageName` | `PawnLifecycleService.*` | +| `ExpectedVisibleStreamingPackages`, `CustomisationReplicatorChannelForGeneration` | `Streaming.LevelStreamingService` / `ControllerFeedbackService` | +| `SendCharacterCustomisationTransfer`, `SendCharacterCustomisationChunk`, `SendCharacterCustomisationCompletion`, `CustomisationByteArrayWireModeName` | `ControllerFeedbackService.*` | +| `SendClientReceiveCharacterData`, `SendClientReceiveCharacterStats`, `SendClientReceiveCharacterRolesData` | `ControllerFeedbackService.SendClientReceiveCharacter*` | +| `IsAcceptedLocalCharacterRequest` (player-info gate) | `ControllerFeedbackService.IsAcceptedLocalCharacterRequest` | +| `ParseExactHexBytes` | `PawnLifecycleService.TryParseExactHexBytes` | +| `ConvertWindowsGuidBytesToFguidDwords` (windows-guid-convert mode) | `PawnLifecycleService.ConvertWindowsGuidBytesToFguidDwords` | +| `PacketCaptureEnabled`, `PacketHexLogEnabled` | `DistrictConfig.ReadBool` | +| `SaveCapture`, `DiagnoseEncryptedPacket` | `Core.PacketDiagnostics.*` | +| `LowerAscii` | inlined as `ToLowerInvariant` | +| `BuildUnreliableActorFloatFieldPacket` (ApbUdp.cpp:1221) | `Protocol.PacketBuilders.BuildUnreliableActorFloatFieldPacket` — the only unreliable field builder the C++ has. `BuildUnreliableActorVectorFieldPacket` / `BuildUnreliableActorRotatorFieldPacket` are **port additions** for the remote-pawn path with no C++ counterpart | +| `ReadEnvironment`, `ReadHandshakeSetting` | `DistrictConfig.ReadSetting` | +| `EndpointForAccount` | `AccountManager.FindByEndpoint` | +| `BteaLoadWords`, `BteaStoreWords` | `DistrictCrypto.LoadWords`, `StoreWords` | +| `ArmPostPossessionMovementUnlock`, `MaybeSendPostPossessionMovementUnlock` | `PostPossessionMovementUnlock.Arm`, `MaybeSend` | + +### 3.9 Cooked-package net-index resolver + +All of the C++ cooked-package machinery lives in `Packages/CookedPackageResolver.cs`. +It reads the actual cooked .u files (APBGame.u for archetype ordinals, the USES +packages for exact NetObjectCounts) so the server's global net indices match the +client's package map instead of relying on hardcoded bases. Verified by +`--resolver-check` against the unpacked 1.1 APBGame.u (controller 12426, holdable +15985 → 47088, storage 25478 → 56581 — byte-identical to the live C++ logs). + +| C++ | C# | +|-----|-----| +| `ReadLe32`, `ReadLeS32`, `ReadPackageFString`, `ReadWholeBinaryFile`, `FileExistsA`, `JoinWindowsPath`, `Lower`, `FindNamedFileRecursive` | `CookedPackageResolver.*` | +| `ParsePackageNameTable`, `ParseCookedPackageSummary`, `FormatExportFName`, `ScoreExportStride`, `ResolveExportStride`, `SplitNumberedObjectName`, `FindCookedExport` | `CookedPackageResolver.*` | +| `PackageNetIndexModelName`, `CalibratePackageNetIndexModel`, `ApplyPackageNetIndexModel` | `CookedPackageResolver.*` | +| `IndexStaticPackageFilesRecursive`, `EnsureStaticPackageFileIndex`, `ReadStaticPackageHeaderInfo`, `ResolveStaticPackageHeader`, `TryComputeExactPackageFirstNetIndex` | `CookedPackageResolver.*` | +| `ResolveConfiguredPackagePath` | `CookedPackageResolver.ResolveConfiguredPackagePath` | +| `ResolveInventoryActorArchetypes` | `CookedPackageResolver.ResolveInventoryActorArchetypes` | +| `AddUniqueNetIndexCandidate` | `CookedPackageResolver.AddUniqueNetIndexCandidate` (unused in C++ too) | + +--- + +## 4. The three live fixes — where they live in both trees + +These are the fixes the C++ tree carries on top of the initial release. **If the +C++ version of any of these changes, mirror it in the C# file listed.** + +| Fix | C++ location | C# location | Verified | +|-----|--------------|-------------|----------| +| **Per-endpoint channel sequences** (was global → broke 2nd player) | `AllocateChannelSequence` keyed by `(endpoint addr, port, channel)` | `Handshake/ChannelSequenceAllocator.cs` keyed by `(EndpointAddress, channel)` | ✅ drive harness | +| **Per-endpoint possessed ASK gate** (was account-keyed, never cleared → reconnect stuck at "Entering district") | `possessed` set keyed by endpoint in `UdpListenerThread` | `Net/UdpListener.cs` `_possessed` `HashSet<(uint Address, ushort Port)>` | ✅ drive harness | +| **Registration UDP-port append** (world advertised every district as 6969) | 7-byte registration, port as raw LE bytes 4–5 | `WorldControl.BuildRegistration` | ✅ drive harness (`30 31 31 30 67 42 00` = port 16999) | + +## 5. ACK-gated pawn bootstrap + +The C++ pawn lifecycle is **field-isolated and ACK-gated**: base links (pawn-open, +PRI, inventory actors, controller/pawn fields) are sent, then the 8 stages +(`UID → GENDER → FACTION → CUSTOMISATION-GUIDS → GIVE-PAWN → CLIENT-RESTART → +CONTROLLER-ALIVE → CLIENT-SET-VIEW-TARGET`) are sent **one at a time**, each +waiting for the client's ACK before the next is sent. This is what finally got +state-19 / in-world spawning working on the C++ side. + +C#: `PawnLifecycleService.SendPawnAndPossess` → `ArmPawnAckGatedSequence` → +`SendNextPawnAckGatedStage` (driven by `ReliableQueue.OnAcknowledged` → +`AdvancePawnAckGatedSequenceAfterAck`). The `--drive` harness proves all 8 stages +complete. + +## 6. Verification + +- **Wire self-test:** `dotnet run -c Release -- --selftest` — output is + byte-identical to the C++ (CHALLENGE/ACK hex match the live client logs). +- **End-to-end drive:** `dotnet run -c Release -- --drive` (run from a scratch + dir; uses scratch ports TCP 21999 + UDP 16999, **never touches the live + stack**). Fake world registers the district + hands off account 1; two fake clients run AUTH → USES+WELCOME → JOIN → controller open → 371 spawn-zone → + district-enter answer → pawn-open → all 8 ACK-gated stages, and each sees the + other's remote pawn. The fake client also drives the customisation replicator + (field 277 → ServerSendData 256/512/768/end-offset) and asserts the DS answers + with `ClientNotifyTransferComplete` (field 23) instead of refusing the + out-of-range chunk. Prints a milestone table and `DRIVE PASS`/`DRIVE FAIL`. + Source: `Drive/DriveHarness.cs`. +- **Resolver check:** `dotnet run -c Release -- --resolver-check [path]` — + exercises the cooked-package resolver against a real cooked APBGame.u + (default: the unpacked 1.1 package). Asserts the summary parses, the + controller export resolves, and the NetIndex model calibrates. +- **Build:** `dotnet build -c Release` (0 warnings / 0 errors expected). +- **Deploy sync:** `py -3.11 Tools/check_deploy_sync.py [--build]` — compares + the `DistrictServer.dll` sha256 in the three instance folders + (`APB SERVER/Districts/{Social,Financial,Waterfront}/`) against the current + build output; reports each instance as MATCH / STALE / MISSING and exits 1 + on any drift (the live server lagging the source). `--build` runs + `dotnet build -c Release` first so the reference is freshly built. Run it + before a deploy (expect green) and after any source change to catch a stale + live build. +- **Production (verified 2026-08-17 13:31):** the C# port is the deployed + district server in all three live districts (Social 6969 / Financial 6970 / + Waterfront 6971, world control 2108). Live processes are the win-x64 .NET + apphost `DistrictServer.exe` from `_apbemu_ref/APB SERVER/Districts/{Social, + Financial,Waterfront}/` (one per UDP port; no .NET district process runs + anywhere else — the only `dotnet.exe` on the box is the SDK's Roslyn + compiler server). The deployed `DistrictServer.dll` is byte-identical across + all three instances: sha256 `526e3d64...`, 245,248 B, dated + **2026-08-17 13:19** (boot 13:31:41, supersedes the 12:47 build `7b82e787...` + which held the previous session). `run_stack.py --check` confirms all three + register on their configured ports with no MISADVERTISED; PIDs 17972 / 1124 / + 21492. `py -3.11 Tools/check_deploy_sync.py` reports all three MATCH the + source. The C++ tree remains the protocol oracle; rollback to the last C++ + binary is one copy away (`_build_scratch/cpp_ds_rollback/DistrictServer.exe.cpp`, + md5 `80fe4873...`). + **Deployment is NOT in git**: `APB SERVER/Districts/` is untracked (the repo + has only the two release commits), so a rebuild+copy is a local runtime + action, never a commit. + **The port close-out is now live** in this build (`526e3d64...`): binary NMT + control (`Handshake/BinaryControlService.cs`), the cooked-package resolver + (`Packages/CookedPackageResolver.cs`), transport-state tracking + (`Core/TransportState.cs`), the customisation replicator request dispatch + + `ClientNotifyTransferComplete` completion + (`Controller/ControllerFeedbackService.cs`), the player-info gate + (`IsAcceptedLocalCharacterRequest`), the `windows-guid-convert` descriptor + mode (`PawnLifecycleService`), and packet diagnostics + (`Core/PacketDiagnostics.cs`). Redeploy recipe for future changes: `dotnet + build -c Release` (0 warnings) → `--selftest` → `--drive` (two-player, + asserts the customisation completion) → `--resolver-check` → `dotnet + publish -c Release -f net8.0 -o _build_scratch/ds_cs_publish + -p:UseAppHost=true` → copy `DistrictServer.exe` + `.dll` + `.deps.json` + + `.runtimeconfig.json` (+ `.pdb`) into all three instance folders → restart + each district in its own console (env `APB_LOG_CONSOLE=1 APB_LOG_PACKET_HEX=0 + APB_CAPTURE_PACKETS=0`, cwd = the instance folder). `--drive` stays the + pre-live regression gate (scratch ports only, never the live instances). + +## 7. NOT yet ported — must be tracked + +These C++ items have **no C# implementation yet** and are intentionally left +out. If the C++ side changes them, there is nothing to mirror into: + +1. **`Unreal.cpp` / `Unreal.h`** — dead code, not in the C++ vcxproj and with + zero callers. Intentionally not ported (see §2). +2. **ApbUdp.cpp raw-float wire helpers** — `FloatToWireBits` (ApbUdp.cpp:1828), + `WriteFloatBits` (ApbUdp.cpp:2185). Both have live C++ callers — PRI stats + (`:1867`, `:1873`) and the non-compressed HUD-marker location + (`:2264`-`:2266`) — and both are mirrored, but *inline* rather than as named + methods: the C# call sites use `BitConverter.SingleToUInt32Bits` + (`PacketBuilders.cs:405`, `:583`-`:585`, `:918`-`:920`, `:1180`-`:1182`). + They sit here, not in §3, only because there is no named C# home to point a + row at. Behaviour is covered. + +Everything else in the C++ DistrictServer now has a C# home (see §3); the +`check_port_sync.py` drift gate reports 0 port gaps in default and `--all` mode. + +`--all` additionally walks `ApbUdp.cpp` and reports 61 **doc gaps** (`ReadBit`, +`WriteBits`, `WriteCompressedVector`, `WriteName`, `ParsePacket`, …). These are +not port gaps: they are the UE3 bit/parse/build primitives, ported wholesale as +the `Bits/*` and `Protocol/*` layers and mapped at layer granularity in §1 and +§2 rather than one §3 row each. The gate exits 0 for them by design. Only a +non-zero **port gap** count means something is genuinely unported. + +## 8. C++ change → mirror checklist (parallel work) + +Whenever a C++ change lands in `DistrictServer/`, run this checklist: + +1. `git diff --stat -- DistrictServer/` to see what moved. +2. For each changed C++ function, find it in §3 and edit the listed C# method. +3. If the change touches a §4 fix (sequences / possessed gate / registration + port) or the ACK-gated pawn flow, re-run `--drive` — it is the regression + guard for exactly those behaviours. +4. If the change touches a §7 subsystem, note it there (it is not yet ported). +5. Rebuild (`dotnet build -c Release`, keep 0 warnings) and re-run + `--selftest` + `--drive`. +6. If the change is deployed, run `py -3.11 Tools/check_deploy_sync.py` — it + must report all three instances MATCH the fresh build (exit 0); a STALE + row means the live districts still run an older build. +7. Add a journal.md entry with the C++ commit/change and what was mirrored. + +The C++ tree currently carries ~880 uncommitted lines over the initial release +(per-endpoint fixes, ACK-gated pawn, GRI, streaming, spawn-zone markers); the C# +port was built from the current working-tree state and mirrors those. Keep them +in lockstep. + +## 9. Layout + +``` +DistrictServerCSharp/ + Bits/ BitReader/BitWriter/RWBits (LSB-first UE3 bit layer) + Crypto/ Xtea + Net/ Network (TCP), DistrictCrypto (BTEA), UdpListener, EndpointAddress + Accounts/ Account, AccountManager + Logging/ DistrictLogger (rotation) + Config/ DistrictConfig + WorldControl/ WorldControl (registration + handoffs) + Handshake/ HandshakeService, ChannelSequenceAllocator, BinaryControlService + Core/ Lifecycle, ReliableQueue, SelectedSpawnLocations, TransportState + Gri/ GriStartupService + Streaming/ LevelStreamingService + SpawnZone/ SpawnZoneService + Pawn/ PawnLifecycleService, PostPossessionMovementUnlock, RemotePawnReplication + Controller/ ControllerFeedbackService + Packages/ CookedPackageResolver (net-index resolver) + Protocol/ Models, PacketParser, PacketBuilders, FieldDecoders, Diagnostics, SelfTest + Drive/ DriveHarness (--drive verification) + Program.cs entry point (--selftest / --drive / --resolver-check / server) +``` diff --git a/DistrictServerCSharp/Packages/CookedPackageResolver.cs b/DistrictServerCSharp/Packages/CookedPackageResolver.cs new file mode 100644 index 0000000..d6b33ca --- /dev/null +++ b/DistrictServerCSharp/Packages/CookedPackageResolver.cs @@ -0,0 +1,1089 @@ +using System.Text; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Logging; + +namespace DistrictServerCSharp.Packages; + +/// +/// How a package-local export ordinal maps to the package-local NetIndex, ported +/// from the C++ PackageNetIndexModel enum. Calibrated once per package +/// against a known object (Default__cAPBPlayerController @ 12426 on this build). +/// +public enum PackageNetIndexModel +{ + Unknown, + ExportZeroBased, + ExportOneBased, + ImportPlusExportZeroBased, + ImportPlusExportOneBased +} + +/// Parsed summary of a cooked UE3 package, ported from the C++ struct. +public sealed class CookedPackageSummary +{ + public string Path = ""; + public byte[] Bytes = Array.Empty(); + public List Names = new(); + public int FileVersion; + public int LicenseeVersion; + public int HeaderSize; + public int NameCount; + public int NameOffset; + public int ExportCount; + public int ExportOffset; + public int ImportCount; + public int ImportOffset; + public int DependsOffset; + public int NameFlagsBytes; + public int ExportStride; +} + +/// A resolved export-table entry, ported from the C++ struct. +public sealed class CookedExportMatch +{ + public uint Ordinal; // zero-based export-table ordinal + public int NameIndex = -1; + public int NameNumber; + public string BaseName = ""; + public string DisplayName = ""; +} + +/// Static-package header facts (first 4 KB), ported from the C++ struct. +public sealed class StaticPackageHeaderInfo +{ + public uint ExportCount; + public uint NetObjectCount; + public uint GenerationCount; + public uint SelectedGeneration; + public string Guid = ""; +} + +/// +/// Cooked-package net-index resolver, ported from the C++ block in +/// DistrictServer.cpp (lines ~6084..8700). Reads the actual cooked .u files +/// (APBGame.u for archetype ordinals, the USES packages for exact +/// NetObjectCounts) so the server's global net indices match the client's +/// package map instead of relying on hardcoded bases. +/// +/// The static package file index is built once per client root and cached, and +/// the whole service is thread-safe (a lock around the index, matching the +/// C++ g_staticPackageFileIndexMutex). +/// +public static class CookedPackageResolver +{ + // Known local NetIndex of APBGame.Default__cAPBPlayerController on this + // build (1.1.0.534979), used to calibrate the package NetIndex model. + private const uint KnownControllerLocalNetIndex = 12426; + + private static readonly object StaticPackageFileIndexLock = new(); + private static string _staticPackageFileIndexRoot = ""; + private static Dictionary> _staticPackageFilesByStem = new(); + + // ------------------------------------------------------------------ + // Low-level binary / file helpers + // ------------------------------------------------------------------ + + public static bool ReadLe32(byte[] bytes, long offset, out uint value) + { + value = 0; + if (offset < 0 || offset > bytes.LongLength || bytes.LongLength - offset < 4) + return false; + + value = (uint)(bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24)); + return true; + } + + public static bool ReadLeS32(byte[] bytes, long offset, out int value) + { + value = 0; + if (!ReadLe32(bytes, offset, out uint raw)) + return false; + value = unchecked((int)raw); + return true; + } + + /// + /// Reads a UE3 FString (int32 length prefix; positive = ANSI, negative = + /// UTF-16) and advances the cursor. Ported from ReadPackageFString. + /// + public static bool ReadPackageFString(byte[] bytes, ref long offset, out string value) + { + value = ""; + if (!ReadLeS32(bytes, offset, out int length)) + return false; + offset += 4; + + if (length == 0) + return true; + + if (length > 0) + { + long count = length; + if (count > 1024L * 1024L || offset > bytes.LongLength || bytes.LongLength - offset < count) + return false; + + value = Encoding.ASCII.GetString(bytes, (int)offset, (int)count); + offset += count; + + while (value.Length > 0 && value[^1] == '\0') + value = value[..^1]; + return true; + } + + long wideCount = -(long)length; + if (wideCount <= 0 || wideCount > 512 * 1024) + return false; + + long byteCount = wideCount * 2; + if (offset > bytes.LongLength || bytes.LongLength - offset < byteCount) + return false; + + var builder = new StringBuilder((int)wideCount); + for (long index = 0; index < wideCount; ++index) + { + int character = bytes[offset + index * 2] | (bytes[offset + index * 2 + 1] << 8); + if (character == 0) + continue; + builder.Append(character <= 0x7F ? (char)character : '?'); + } + + value = builder.ToString(); + offset += byteCount; + return true; + } + + public static bool ReadWholeBinaryFile(string path, out byte[] bytes) + { + bytes = Array.Empty(); + if (!File.Exists(path)) + return false; + + FileInfo info = new(path); + if (info.Length <= 0 || info.Length > 0x7FFFFFFF) + return false; + + bytes = File.ReadAllBytes(path); + return true; + } + + public static bool FileExistsA(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + return File.Exists(path); + } + + public static string JoinWindowsPath(string left, string right) + { + if (string.IsNullOrEmpty(left)) + return right; + if (string.IsNullOrEmpty(right)) + return left; + if (left[^1] == '\\' || left[^1] == '/') + return left + right; + return left + "\\" + right; + } + + public static string Lower(string value) => value.ToLowerInvariant(); + + /// + /// Recursively searches a directory tree for a file whose (lowercased) + /// name is one of the wanted set, descending at most depthRemaining levels + /// and skipping reparse points. Ported from FindNamedFileRecursive. + /// + public static bool FindNamedFileRecursive(string directory, HashSet wantedLower, int depthRemaining, out string result) + { + result = ""; + if (string.IsNullOrEmpty(directory) || depthRemaining < 0) + return false; + + if (!Directory.Exists(directory)) + return false; + + var childDirectories = new List(); + + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + string name = Path.GetFileName(entry); + if (name == "." || name == "..") + continue; + + if (Directory.Exists(entry)) + { + if (depthRemaining > 0) + childDirectories.Add(entry); + continue; + } + + if (wantedLower.Contains(Lower(name))) + { + result = entry; + return true; + } + } + + foreach (string child in childDirectories) + { + if (FindNamedFileRecursive(child, wantedLower, depthRemaining - 1, out result)) + return true; + } + return false; + } + + // ------------------------------------------------------------------ + // Package name-table + summary parsing + // ------------------------------------------------------------------ + + /// + /// Decodes the package name table at NameOffset with the given per-entry + /// flags width, scoring printable names. Returns false on a hard read + /// failure; the caller picks the flags width with the best score. + /// + public static bool ParsePackageNameTable(CookedPackageSummary package, int flagsBytes, out List names, out int printableScore) + { + names = new List(); + printableScore = 0; + + if (package.NameCount <= 0 || package.NameCount > 1000000 || package.NameOffset < 0) + return false; + + long cursor = package.NameOffset; + names.Capacity = package.NameCount; + + for (int index = 0; index < package.NameCount; ++index) + { + if (!ReadPackageFString(package.Bytes, ref cursor, out string name)) + return false; + + if (cursor > package.Bytes.LongLength || package.Bytes.LongLength - cursor < flagsBytes) + return false; + cursor += flagsBytes; + + bool printable = name.Length > 0; + foreach (char character in name) + { + if (character < 0x20 || character >= 0x7F) + { + printable = false; + break; + } + } + if (printable) + ++printableScore; + + names.Add(name); + } + + return names.Count == package.NameCount; + } + + /// + /// Parses the cooked UE3 package summary (tag 0x9E2A83C1, version packed + /// into one DWORD, FString folder name, then the six table count/offset + /// values) and decodes the name table with the best of three flags widths. + /// + public static bool ParseCookedPackageSummary(string path, out CookedPackageSummary package) + { + package = new CookedPackageSummary { Path = path }; + + if (!ReadWholeBinaryFile(path, out byte[] bytes)) + return false; + package.Bytes = bytes; + + if (!ReadLe32(bytes, 0, out uint tag) || tag != 0x9E2A83C1u) + { + DistrictLogger.Log(LogLevel.Error, "SpawnZone Package Resolver", + "Package '{0}' has invalid UE3 tag 0x{1:X8}.", path, tag); + return false; + } + + // This APB UE3 build stores Version and LicenseeVersion as the low and + // high 16-bit halves of one DWORD (for example 0x001F0223). + long cursor = 4; + if (!ReadLe32(bytes, cursor, out uint packedVersion)) + return false; + package.FileVersion = unchecked((int)(packedVersion & 0xFFFFu)); + package.LicenseeVersion = unchecked((int)((packedVersion >> 16) & 0xFFFFu)); + cursor += 4; + + if (!ReadLeS32(bytes, cursor, out package.HeaderSize)) + return false; + cursor += 4; + + if (!ReadPackageFString(bytes, ref cursor, out string folderName)) + return false; + + // PackageFlags precedes the six table count/offset values. + if (!ReadLe32(bytes, cursor, out uint ignoredPackageFlags)) + return false; + cursor += 4; + + if (!ReadLeS32(bytes, cursor, out package.NameCount)) + return false; + cursor += 4; + if (!ReadLeS32(bytes, cursor, out package.NameOffset)) + return false; + cursor += 4; + if (!ReadLeS32(bytes, cursor, out package.ExportCount)) + return false; + cursor += 4; + if (!ReadLeS32(bytes, cursor, out package.ExportOffset)) + return false; + cursor += 4; + // APB's build writes the import table offset before its count. + if (!ReadLeS32(bytes, cursor, out package.ImportOffset)) + return false; + cursor += 4; + if (!ReadLeS32(bytes, cursor, out package.ImportCount)) + return false; + cursor += 4; + if (!ReadLeS32(bytes, cursor, out package.DependsOffset)) + return false; + + long fileSize = bytes.LongLength; + bool sane = + package.HeaderSize > 0 && + package.HeaderSize <= fileSize && + package.NameCount > 0 && package.NameCount < 1000000 && + package.ExportCount > 0 && package.ExportCount < 1000000 && + package.ImportCount >= 0 && package.ImportCount < 1000000 && + package.NameOffset >= 0 && package.NameOffset < fileSize && + package.ExportOffset >= 0 && package.ExportOffset < fileSize && + package.ImportOffset >= 0 && package.ImportOffset < fileSize; + + if (!sane) + { + DistrictLogger.Log(LogLevel.Error, "SpawnZone Package Resolver", + "Package summary failed sanity: path={0} version={1}/{2} header={3} names={4}@0x{5:X} exports={6}@0x{7:X} imports={8}@0x{9:X} depends=0x{10:X} size={11}.", + path, package.FileVersion, package.LicenseeVersion, package.HeaderSize, + package.NameCount, package.NameOffset, package.ExportCount, package.ExportOffset, + package.ImportCount, package.ImportOffset, package.DependsOffset, fileSize); + return false; + } + + int bestScore = -1; + List bestNames = new(); + int bestFlagsBytes = 0; + + foreach (int flagsBytes in new[] { 8, 4, 0 }) + { + if (!ParsePackageNameTable(package, flagsBytes, out List names, out int score)) + continue; + + if (score > bestScore) + { + bestScore = score; + bestNames = names; + bestFlagsBytes = flagsBytes; + } + } + + if (bestScore < package.NameCount / 2) + { + DistrictLogger.Log(LogLevel.Error, "SpawnZone Package Resolver", + "Could not decode package name table: path={0} printable={1}/{2}.", + path, bestScore, package.NameCount); + return false; + } + + package.Names = bestNames; + package.NameFlagsBytes = bestFlagsBytes; + + DistrictLogger.Log(LogLevel.Info, "SpawnZone Package Resolver", + "PACKAGE_SUMMARY path={0} version={1}/{2} header={3} names={4}@0x{5:X} nameFlags={6} exports={7}@0x{8:X} imports={9}@0x{10:X} depends=0x{11:X} size={12}.", + path, package.FileVersion, package.LicenseeVersion, package.HeaderSize, + package.NameCount, package.NameOffset, package.NameFlagsBytes, + package.ExportCount, package.ExportOffset, package.ImportCount, + package.ImportOffset, package.DependsOffset, fileSize); + return true; + } + + public static string FormatExportFName(string baseName, int number) + { + if (number <= 0) + return baseName; + + // UE3 stores the displayed suffix plus one in FName::Number. + return baseName + "_" + (number - 1); + } + + /// + /// Scores a candidate export-table row stride: +5 per export whose name + /// index resolves into the name table, +5000 if that name is the target, + /// -20 for a dangling name index, and +1 per plausible class/super/outer + /// index. Ported from ScoreExportStride. + /// + public static int ScoreExportStride(CookedPackageSummary package, int stride, string targetLower) + { + if (stride < 20 || stride > 256 || package.ExportOffset < 0 || package.ExportCount <= 0) + return -1; + + long exportOffset = package.ExportOffset; + long exportCount = package.ExportCount; + + if (exportOffset > package.Bytes.LongLength || + (stride > 0 && exportCount > (package.Bytes.LongLength - exportOffset) / stride)) + { + return -1; + } + + long samples = Math.Min(exportCount, 512); + int score = 0; + + for (long index = 0; index < samples; ++index) + { + long row = exportOffset + index * stride; + if (!ReadLeS32(package.Bytes, row + 0, out int classIndex) || + !ReadLeS32(package.Bytes, row + 4, out int superIndex) || + !ReadLeS32(package.Bytes, row + 8, out int outerIndex) || + !ReadLeS32(package.Bytes, row + 12, out int nameIndex)) + { + return -1; + } + + if (nameIndex >= 0 && nameIndex < package.Names.Count) + { + score += 5; + if (Lower(package.Names[nameIndex]) == targetLower) + score += 5000; + } + else + { + score -= 20; + } + + int indexLimit = package.ExportCount + package.ImportCount + 16; + if (Math.Abs(classIndex) <= indexLimit) ++score; + if (Math.Abs(superIndex) <= indexLimit) ++score; + if (Math.Abs(outerIndex) <= indexLimit) ++score; + } + return score; + } + + /// + /// Determines the export-table row stride: candidates derived from the + /// distance between ExportOffset and the other table offsets, plus every + /// 4-aligned stride in 20..160, scored by ScoreExportStride. + /// + public static bool ResolveExportStride(CookedPackageSummary package, string targetBaseName) + { + string targetLower = Lower(targetBaseName); + var candidateStrides = new SortedSet(); + + int[] offsets = { package.NameOffset, package.ImportOffset, package.DependsOffset, package.HeaderSize }; + + foreach (int candidateEnd in offsets) + { + if (candidateEnd <= package.ExportOffset || package.ExportCount <= 0) + continue; + + long bytes = (long)candidateEnd - package.ExportOffset; + if (bytes > 0 && bytes % package.ExportCount == 0) + { + int stride = (int)(bytes / package.ExportCount); + if (stride >= 20 && stride <= 256) + candidateStrides.Add(stride); + } + } + + for (int stride = 20; stride <= 160; stride += 4) + candidateStrides.Add(stride); + + int bestScore = -1; + int bestStride = 0; + foreach (int stride in candidateStrides) + { + int score = ScoreExportStride(package, stride, targetLower); + if (score > bestScore) + { + bestScore = score; + bestStride = stride; + } + } + + if (bestStride == 0 || bestScore < 0) + return false; + + package.ExportStride = bestStride; + DistrictLogger.Log(LogLevel.Info, "SpawnZone Package Resolver", + "EXPORT_LAYOUT path={0} stride={1} score={2}.", package.Path, bestStride, bestScore); + return true; + } + + /// + /// Splits a numbered object name ("Foo_12") into its base ("Foo") and the + /// stored FName suffix (13, i.e. displayed 12 + 1). Returns false when the + /// name has no trailing numeric suffix. + /// + public static bool SplitNumberedObjectName(string target, out string baseName, out int suffix) + { + baseName = target; + suffix = -1; + + int underscore = target.LastIndexOf('_'); + if (underscore < 0 || underscore + 1 >= target.Length) + return false; + + for (int index = underscore + 1; index < target.Length; ++index) + { + if (!char.IsDigit(target[index])) + return false; + } + + suffix = int.Parse(target[(underscore + 1)..]); + baseName = target[..underscore]; + return true; + } + + /// + /// Finds a cooked export by display name, resolving the stride first, then + /// matching exact display, embedded base, or a compatible FName number. + /// Ported from FindCookedExport. + /// + public static bool FindCookedExport(CookedPackageSummary package, string targetDisplayName, out CookedExportMatch match) + { + match = new CookedExportMatch(); + + string targetBase; + int requestedSuffix; + SplitNumberedObjectName(targetDisplayName, out targetBase, out requestedSuffix); + + if (!ResolveExportStride(package, targetBase)) + return false; + + string targetDisplayLower = Lower(targetDisplayName); + string targetBaseLower = Lower(targetBase); + + var candidates = new List(); + for (int index = 0; index < package.ExportCount; ++index) + { + long row = (long)package.ExportOffset + (long)index * package.ExportStride; + + if (!ReadLeS32(package.Bytes, row + 12, out int nameIndex) || + !ReadLeS32(package.Bytes, row + 16, out int nameNumber) || + nameIndex < 0 || nameIndex >= package.Names.Count) + { + continue; + } + + string baseName = package.Names[nameIndex]; + string display = FormatExportFName(baseName, nameNumber); + + bool exactDisplay = Lower(display) == targetDisplayLower; + bool embeddedDisplay = Lower(baseName) == targetDisplayLower; + bool compatibleNumber = + requestedSuffix >= 0 && + Lower(baseName) == targetBaseLower && + (nameNumber == requestedSuffix || nameNumber == requestedSuffix + 1); + + if (!exactDisplay && !embeddedDisplay && !compatibleNumber) + continue; + + candidates.Add(new CookedExportMatch + { + Ordinal = (uint)index, + NameIndex = nameIndex, + NameNumber = nameNumber, + BaseName = baseName, + DisplayName = display + }); + } + + if (candidates.Count == 0) + { + DistrictLogger.Log(LogLevel.Error, "SpawnZone Package Resolver", + "Export '{0}' was not found in {1}.", targetDisplayName, package.Path); + return false; + } + + // Prefer the canonical UE3 suffix representation, then an exact + // display-name match, then the first compatible result. + int selected = 0; + for (int index = 0; index < candidates.Count; ++index) + { + if (requestedSuffix >= 0 && candidates[index].NameNumber == requestedSuffix + 1) + { + selected = index; + break; + } + if (Lower(candidates[index].DisplayName) == targetDisplayLower) + selected = index; + } + + match = candidates[selected]; + + DistrictLogger.Log(LogLevel.Success, "SpawnZone Package Resolver", + "EXPORT_FOUND path={0} target={1} ordinal={2} baseName={3} number={4} display={5} matches={6}.", + package.Path, targetDisplayName, match.Ordinal, match.BaseName, + match.NameNumber, match.DisplayName, candidates.Count); + return true; + } + + // ------------------------------------------------------------------ + // Net-index model calibration + // ------------------------------------------------------------------ + + public static string PackageNetIndexModelName(PackageNetIndexModel model) + { + return model switch + { + PackageNetIndexModel.ExportZeroBased => "export-zero-based", + PackageNetIndexModel.ExportOneBased => "export-one-based", + PackageNetIndexModel.ImportPlusExportZeroBased => "imports-plus-export-zero-based", + PackageNetIndexModel.ImportPlusExportOneBased => "imports-plus-export-one-based", + _ => "unknown" + }; + } + + /// + /// Determines how export ordinals map to package-local NetIndices by + /// comparing the controller export's ordinal against the known local + /// NetIndex 12426. Ported from CalibratePackageNetIndexModel. + /// + public static PackageNetIndexModel CalibratePackageNetIndexModel(CookedPackageSummary apbGame, CookedExportMatch controller) + { + uint ordinal = controller.Ordinal; + uint imports = apbGame.ImportCount > 0 ? (uint)apbGame.ImportCount : 0u; + + if (KnownControllerLocalNetIndex == ordinal) return PackageNetIndexModel.ExportZeroBased; + if (KnownControllerLocalNetIndex == ordinal + 1u) return PackageNetIndexModel.ExportOneBased; + if (KnownControllerLocalNetIndex == imports + ordinal) return PackageNetIndexModel.ImportPlusExportZeroBased; + if (KnownControllerLocalNetIndex == imports + ordinal + 1u) return PackageNetIndexModel.ImportPlusExportOneBased; + return PackageNetIndexModel.Unknown; + } + + public static uint ApplyPackageNetIndexModel(PackageNetIndexModel model, CookedPackageSummary package, CookedExportMatch obj) + { + uint imports = package.ImportCount > 0 ? (uint)package.ImportCount : 0u; + + return model switch + { + PackageNetIndexModel.ExportZeroBased => obj.Ordinal, + PackageNetIndexModel.ExportOneBased => obj.Ordinal + 1u, + PackageNetIndexModel.ImportPlusExportZeroBased => imports + obj.Ordinal, + PackageNetIndexModel.ImportPlusExportOneBased => imports + obj.Ordinal + 1u, + _ => 0u + }; + } + + // ------------------------------------------------------------------ + // Static package file index (client root scan) + // ------------------------------------------------------------------ + + public static bool IndexStaticPackageFilesRecursive(string directory, int depthRemaining, Dictionary> filesByStem) + { + if (string.IsNullOrEmpty(directory) || depthRemaining < 0) + return false; + + if (!Directory.Exists(directory)) + return false; + + var childDirectories = new List(); + + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + string name = Path.GetFileName(entry); + if (name == "." || name == "..") + continue; + + if (Directory.Exists(entry)) + { + if (depthRemaining > 0) + childDirectories.Add(entry); + continue; + } + + string lowerName = Lower(name); + int dot = lowerName.LastIndexOf('.'); + if (dot < 0) + continue; + + string extension = lowerName[dot..]; + if (extension != ".u" && extension != ".upk" && extension != ".apb" && extension != ".umap") + continue; + + string stem = lowerName[..dot]; + if (stem.Length > 0) + { + if (!filesByStem.TryGetValue(stem, out List? list)) + { + list = new List(); + filesByStem[stem] = list; + } + list.Add(entry); + } + } + + foreach (string child in childDirectories) + IndexStaticPackageFilesRecursive(child, depthRemaining - 1, filesByStem); + + return true; + } + + public static bool EnsureStaticPackageFileIndex(string clientRoot) + { + lock (StaticPackageFileIndexLock) + { + if (_staticPackageFileIndexRoot == clientRoot && _staticPackageFilesByStem.Count > 0) + return true; + + _staticPackageFilesByStem.Clear(); + _staticPackageFileIndexRoot = clientRoot; + IndexStaticPackageFilesRecursive(clientRoot, 12, _staticPackageFilesByStem); + + DistrictLogger.Log( + _staticPackageFilesByStem.Count == 0 ? LogLevel.Error : LogLevel.Success, + "Static Package Map", + "Indexed {0} cooked package file(s) below APBClientRoot='{1}'.", + _staticPackageFilesByStem.Count, clientRoot); + + return _staticPackageFilesByStem.Count > 0; + } + } + + /// + /// Reads the first 4 KB of a cooked package: tag, folder FString, then the + /// summary fields (export count, generation count, GUID, and the selected + /// generation's export/name/net-object counts). Ported from + /// ReadStaticPackageHeaderInfo with the exact summary offsets. + /// + public static bool ReadStaticPackageHeaderInfo(string path, int requestedGeneration, out StaticPackageHeaderInfo info) + { + info = new StaticPackageHeaderInfo(); + + if (!File.Exists(path)) + return false; + + byte[] header = new byte[4096]; + int read; + using (FileStream stream = File.OpenRead(path)) + { + read = stream.Read(header, 0, header.Length); + } + if (read < 128) + return false; + + if (!ReadLe32(header, 0, out uint tag) || tag != 0x9E2A83C1u) + return false; + + long cursor = 12; + if (!ReadPackageFString(header, ref cursor, out string folderName)) + return false; + + long summaryBase = cursor; + if (!ReadLe32(header, summaryBase + 12, out uint exportCount) || + !ReadLe32(header, summaryBase + 72, out uint generationCount) || + generationCount == 0u || generationCount > 128u) + { + return false; + } + + long guidOffset = summaryBase + 56; + if (guidOffset + 16 > header.Length) + return false; + + if (!ReadLe32(header, guidOffset, out uint guidData1)) + return false; + ushort guidData2 = (ushort)(header[guidOffset + 4] | (header[guidOffset + 5] << 8)); + ushort guidData3 = (ushort)(header[guidOffset + 6] | (header[guidOffset + 7] << 8)); + + var guidText = new StringBuilder(32); + guidText.Append(guidData1.ToString("X8")); + guidText.Append(guidData2.ToString("X4")); + guidText.Append(guidData3.ToString("X4")); + for (int index = 8; index < 16; ++index) + guidText.Append(header[guidOffset + index].ToString("X2")); + + uint generation = requestedGeneration > 0 ? (uint)requestedGeneration : generationCount; + if (generation > generationCount) + return false; + + long generationEntry = summaryBase + 76 + (long)(generation - 1u) * 12; + + if (!ReadLe32(header, generationEntry + 0, out uint generationExportCount) || + !ReadLe32(header, generationEntry + 8, out uint netObjectCount) || + netObjectCount == 0u) + { + return false; + } + + info.ExportCount = exportCount; + info.NetObjectCount = netObjectCount; + info.GenerationCount = generationCount; + info.SelectedGeneration = generation; + info.Guid = guidText.ToString(); + return true; + } + + /// + /// Locates a USES package's cooked file below the client root and + /// GUID-verifies it, returning its header facts. Ported from + /// ResolveStaticPackageHeader. + /// + public static bool ResolveStaticPackageHeader(string clientRoot, UsesPackage package, out string path, out StaticPackageHeaderInfo info) + { + path = ""; + info = new StaticPackageHeaderInfo(); + + if (!EnsureStaticPackageFileIndex(clientRoot)) + return false; + + List candidates; + lock (StaticPackageFileIndexLock) + { + if (!_staticPackageFilesByStem.TryGetValue(Lower(package.Name), out List? found)) + return false; + candidates = new List(found); + } + + foreach (string candidate in candidates) + { + if (!ReadStaticPackageHeaderInfo(candidate, package.Generation, out StaticPackageHeaderInfo candidateInfo)) + continue; + + if (Lower(candidateInfo.Guid) != Lower(package.Guid)) + { + DistrictLogger.Log(LogLevel.Warn, "Static Package Map", + "Ignoring package-name match with wrong GUID: package={0} expectedGuid={1} actualGuid={2} path={3}.", + package.Name, package.Guid, candidateInfo.Guid, candidate); + continue; + } + + path = candidate; + info = candidateInfo; + return true; + } + + return false; + } + + // ------------------------------------------------------------------ + // Exact FirstNetIndex + // ------------------------------------------------------------------ + + /// + /// Computes the exact global FirstNetIndex of a USES package by summing the + /// selected generation's actual NetObjectCount from every preceding + /// package's cooked header. Ported from TryComputeExactPackageFirstNetIndex. + /// + public static bool TryComputeExactPackageFirstNetIndex(string targetPackageName, out uint firstNetIndex) + { + firstNetIndex = 0u; + if (string.IsNullOrEmpty(targetPackageName)) + return false; + + string clientRoot = DistrictConfig.ReadSetting("APB_CLIENT_ROOT", "APBClientRoot", ""); + + if (!EnsureStaticPackageFileIndex(clientRoot)) + return false; + + foreach (UsesPackage package in DistrictConfig.UsesPackages) + { + if (string.Equals(package.Name, targetPackageName, StringComparison.Ordinal)) + { + if (!ResolveStaticPackageHeader(clientRoot, package, out string targetPath, out StaticPackageHeaderInfo targetInfo)) + { + DistrictLogger.Log(LogLevel.Error, "Static Package Map", + "Target package={0} could not be located and GUID-verified. Marker transmission is blocked.", + package.Name); + return false; + } + + if (targetInfo.NetObjectCount <= DistrictConfig.CriminalSpawnZoneLocalNetIndex) + { + DistrictLogger.Log(LogLevel.Error, "Static Package Map", + "Target package={0} has only {1} network objects; spawn-zone local indices {2}/{3} are invalid.", + package.Name, targetInfo.NetObjectCount, + DistrictConfig.EnforcerSpawnZoneLocalNetIndex, + DistrictConfig.CriminalSpawnZoneLocalNetIndex); + return false; + } + + DistrictLogger.Log(LogLevel.Success, "Static Package Map", + "Exact FirstNetIndex package={0} value={1} targetNetObjects={2} guid={3} path={4}.", + package.Name, firstNetIndex, targetInfo.NetObjectCount, targetInfo.Guid, targetPath); + return true; + } + + if (!ResolveStaticPackageHeader(clientRoot, package, out string path, out StaticPackageHeaderInfo headerInfo)) + { + DistrictLogger.Log(LogLevel.Error, "Static Package Map", + "Cannot resolve exact NetObjectCount for package={0} generation={1}. Marker transmission is blocked to prevent a client crash.", + package.Name, package.Generation); + return false; + } + + DistrictLogger.Log( + headerInfo.NetObjectCount == package.NetObjectCount ? LogLevel.Info : LogLevel.Warn, + "Static Package Map", + "package={0} generation={1}/{2} exportCount={3} headerNetObjects={4} hardcodedNetObjects={5} path={6}.", + package.Name, headerInfo.SelectedGeneration, headerInfo.GenerationCount, + headerInfo.ExportCount, headerInfo.NetObjectCount, package.NetObjectCount, path); + + if (firstNetIndex > 0x7FFFFFFFu - headerInfo.NetObjectCount) + return false; + firstNetIndex += headerInfo.NetObjectCount; + } + + DistrictLogger.Log(LogLevel.Error, "Static Package Map", + "Target package '{0}' is not in UsesPackages.", targetPackageName); + return false; + } + + // ------------------------------------------------------------------ + // Configured package path + // ------------------------------------------------------------------ + + /// + /// Resolves a cooked package file: explicit config wins, then the exact + /// relative path below the client root, then a recursive name search. + /// Ported from ResolveConfiguredPackagePath. + /// + public static string ResolveConfiguredPackagePath( + string environmentName, + string iniKey, + string exactRelativePath, + HashSet fallbackNames, + string clientRoot) + { + string configured = DistrictConfig.ReadSetting(environmentName, iniKey, ""); + if (FileExistsA(configured)) + return configured; + + if (!string.IsNullOrEmpty(configured)) + { + string configuredBelowRoot = JoinWindowsPath(clientRoot, configured); + if (FileExistsA(configuredBelowRoot)) + return configuredBelowRoot; + } + + string exact = JoinWindowsPath(clientRoot, exactRelativePath); + if (FileExistsA(exact)) + return exact; + + if (FindNamedFileRecursive(clientRoot, fallbackNames, 8, out string found)) + return found; + + return ""; + } + + // ------------------------------------------------------------------ + // Consumer: inventory archetype resolution + // ------------------------------------------------------------------ + + /// + /// Resolves the holdable-item-manager and storage-inventory archetype net + /// indices: explicit overrides win, otherwise the indices are auto-resolved + /// from the cooked APBGame.u (calibrated via Default__cAPBPlayerController). + /// Ported from ResolveInventoryActorArchetypes. + /// + public static bool ResolveInventoryActorArchetypes( + out uint holdableLocalNetIndex, + out uint inventoryLocalNetIndex, + out uint holdableGlobalNetIndex, + out uint inventoryGlobalNetIndex) + { + holdableLocalNetIndex = 0; + inventoryLocalNetIndex = 0; + holdableGlobalNetIndex = 0; + inventoryGlobalNetIndex = 0; + + uint configuredHoldable = (uint)DistrictConfig.ReadInt("APB_HOLDABLE_ITEM_MANAGER_LOCAL_NET_INDEX", "HoldableItemManagerLocalNetIndex", 0, 0, int.MaxValue); + uint configuredInventory = (uint)DistrictConfig.ReadInt("APB_STORAGE_INVENTORY_LOCAL_NET_INDEX", "StorageInventoryLocalNetIndex", 0, 0, int.MaxValue); + + if (configuredHoldable != 0u && configuredInventory != 0u) + { + holdableLocalNetIndex = configuredHoldable; + inventoryLocalNetIndex = configuredInventory; + holdableGlobalNetIndex = DistrictConfig.GlobalNetIndex("APBGame", holdableLocalNetIndex); + inventoryGlobalNetIndex = DistrictConfig.GlobalNetIndex("APBGame", inventoryLocalNetIndex); + + DistrictLogger.Log(LogLevel.Success, "District Inventory Bootstrap", + "Using configured APBGame local NetIndices: Default__cHoldableItemManager={0} global={1}; Default__cStorageInventory={2} global={3}.", + holdableLocalNetIndex, holdableGlobalNetIndex, inventoryLocalNetIndex, inventoryGlobalNetIndex); + return true; + } + + if (!DistrictConfig.ReadBool("APB_AUTO_RESOLVE_INVENTORY_ARCHETYPES", "AutoResolveInventoryArchetypes", true)) + { + DistrictLogger.Log(LogLevel.Error, "District Inventory Bootstrap", + "Inventory archetype auto-resolution is disabled and both local NetIndex overrides were not supplied."); + return false; + } + + string clientRoot = DistrictConfig.ReadSetting("APB_CLIENT_ROOT", "APBClientRoot", ""); + + string apbGamePath = ResolveConfiguredPackagePath( + "APB_APBGAME_PACKAGE_PATH", "APBGamePackagePath", + "APBGame\\CookedPC\\APBGame.u", + new HashSet { "apbgame.u", "apbgame.upk" }, + clientRoot); + + if (string.IsNullOrEmpty(apbGamePath)) + { + DistrictLogger.Log(LogLevel.Error, "District Inventory Bootstrap", + "Could not locate APBGame.u below APBClientRoot='{0}'.", clientRoot); + return false; + } + + if (!ParseCookedPackageSummary(apbGamePath, out CookedPackageSummary apbGame) || + !FindCookedExport(apbGame, "Default__cAPBPlayerController", out CookedExportMatch controller) || + !FindCookedExport(apbGame, "Default__cHoldableItemManager", out CookedExportMatch holdable) || + !FindCookedExport(apbGame, "Default__cStorageInventory", out CookedExportMatch inventory)) + { + DistrictLogger.Log(LogLevel.Error, "District Inventory Bootstrap", + "Could not resolve one or more required APBGame exports from '{0}'.", apbGamePath); + return false; + } + + PackageNetIndexModel model = CalibratePackageNetIndexModel(apbGame, controller); + + if (model == PackageNetIndexModel.Unknown) + { + DistrictLogger.Log(LogLevel.Error, "District Inventory Bootstrap", + "Could not calibrate APBGame package NetIndex model: controllerOrdinal={0} imports={1} knownLocal={2}.", + controller.Ordinal, apbGame.ImportCount, KnownControllerLocalNetIndex); + return false; + } + + holdableLocalNetIndex = ApplyPackageNetIndexModel(model, apbGame, holdable); + inventoryLocalNetIndex = ApplyPackageNetIndexModel(model, apbGame, inventory); + + if (holdableLocalNetIndex == 0u || inventoryLocalNetIndex == 0u || holdableLocalNetIndex == inventoryLocalNetIndex) + { + DistrictLogger.Log(LogLevel.Error, "District Inventory Bootstrap", + "Resolved invalid local NetIndices: holdable={0} inventory={1} model={2}.", + holdableLocalNetIndex, inventoryLocalNetIndex, PackageNetIndexModelName(model)); + return false; + } + + holdableGlobalNetIndex = DistrictConfig.GlobalNetIndex("APBGame", holdableLocalNetIndex); + inventoryGlobalNetIndex = DistrictConfig.GlobalNetIndex("APBGame", inventoryLocalNetIndex); + + DistrictLogger.Log(LogLevel.Success, "District Inventory Bootstrap", + "Resolved APBGame inventory archetypes from cooked package: model={0} path={1} Default__cHoldableItemManager ordinal={2} local={3} global={4}; Default__cStorageInventory ordinal={5} local={6} global={7}.", + PackageNetIndexModelName(model), apbGamePath, + holdable.Ordinal, holdableLocalNetIndex, holdableGlobalNetIndex, + inventory.Ordinal, inventoryLocalNetIndex, inventoryGlobalNetIndex); + + return true; + } + + // ------------------------------------------------------------------ + // Helper (defined but unused in the C++ too; ported for completeness) + // ------------------------------------------------------------------ + + /// Adds a package-local index's global value to a candidate list, deduplicated. + public static void AddUniqueNetIndexCandidate(List candidates, uint localNetIndex, uint netObjectCount) + { + if (localNetIndex == 0u || localNetIndex >= netObjectCount) + return; + + uint global = DistrictConfig.GlobalNetIndex("rworldsocialdistrict_design", localNetIndex); + if (!candidates.Contains(global)) + candidates.Add(global); + } +} diff --git a/DistrictServerCSharp/Pawn/PawnLifecycleService.cs b/DistrictServerCSharp/Pawn/PawnLifecycleService.cs new file mode 100644 index 0000000..c68d5f9 --- /dev/null +++ b/DistrictServerCSharp/Pawn/PawnLifecycleService.cs @@ -0,0 +1,1102 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Packages; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Pawn; + +/// +/// The ACK-gated pawn possession lifecycle, ported from DistrictServer.cpp. +/// Opens the cAPBPawn channel (plus PlayerReplicationInfo and inventory +/// actors), links controller/pawn/PRI, then advances a per-account stage +/// machine -- UID, gender, faction, customisation GUIDs, GivePawn, +/// ClientRestart, alive, view-target -- one reliable packet at a time, each +/// gated on the client's ACK. +/// +public sealed class PawnLifecycleService +{ + private readonly HandshakeService _handshake; + private readonly ReliableQueue _reliableQueue; + + /// Post-possession movement unlock, armed when the ACK-gated lifecycle completes. + public PostPossessionMovementUnlock PostPossessionUnlock { get; } + + /// + /// Remote-pawn multiplayer replication. Set by the UDP listener after all + /// services are constructed (RemotePawnReplication itself depends on this + /// service for the compact descriptor, so the reference is injected late). + /// + public RemotePawnReplication? RemotePawnReplication { get; set; } + + public PawnLifecycleService(HandshakeService handshake, ReliableQueue reliableQueue) + { + _handshake = handshake; + _reliableQueue = reliableQueue; + PostPossessionUnlock = new PostPossessionMovementUnlock(handshake); + _reliableQueue.OnAcknowledged += AdvancePawnAckGatedSequenceAfterAck; + } + + // ------------------------------------------------------------------ + // Pawn + possession + // ------------------------------------------------------------------ + + public bool SendPawnAndPossess(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + uint pawnArchetype = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.PawnArchetypeObjectIndex); + + bool linkPlayerReplicationInfo = DistrictConfig.ReadBool("APB_LINK_PLAYER_REPLICATION_INFO", "LinkPlayerReplicationInfo", true); + uint playerReplicationInfoArchetype = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.PlayerReplicationInfoArchetypeObjectIndex); + + // SpawnActor rejects a location that is not free, so the spawn point + // remains configurable and honours the spawn-zone selection. + DistrictConfig.ReadControllerLocation(out float spawnX, out float spawnY, out float spawnZ); + if (SelectedSpawnLocations.TryGet(account.GetId(), out float sx, out float sy, out float sz)) + { + spawnX = sx; + spawnY = sy; + spawnZ = sz; + } + + byte[] open = PacketBuilders.BuildActorOpenPacket( + account.AllocateServerPacketId(), + DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + pawnArchetype, + spawnX, spawnY, spawnZ); + + if (!_handshake.SendProtectedPacket(endpoint, account, open, "PAWN-OPEN")) + return false; + + DistrictLogger.Log(LogLevel.Info, "District Handshake", + "Opened pawn channel {0} with archetype Default__cAPBPawn (net index {1}) at ({2:F1}, {3:F1}, {4:F1}).", + DistrictConfig.PawnChannel, pawnArchetype, spawnX, spawnY, spawnZ); + + bool priOpened = !linkPlayerReplicationInfo; + bool controllerPriSent = !linkPlayerReplicationInfo; + bool pawnPriSent = !linkPlayerReplicationInfo; + + if (linkPlayerReplicationInfo) + { + byte[] priOpen = PacketBuilders.BuildActorOpenPacket( + account.AllocateServerPacketId(), + DistrictConfig.PlayerReplicationInfoChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PlayerReplicationInfoChannel), + playerReplicationInfoArchetype, + spawnX, spawnY, spawnZ); + + priOpened = _handshake.SendProtectedPacket(endpoint, account, priOpen, "PLAYER-REPLICATION-INFO-OPEN"); + if (!priOpened) + return false; + + byte[] controllerPri = PacketBuilders.BuildActorObjectFieldPacket( + account.AllocateServerPacketId(), + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldControllerPlayerReplicationInfo, + DistrictConfig.PlayerControllerFieldMax, + DistrictConfig.PlayerReplicationInfoChannel); + + controllerPriSent = _handshake.SendProtectedPacket(endpoint, account, controllerPri, "CONTROLLER-PLAYER-REPLICATION-INFO"); + + byte[] pawnPri = PacketBuilders.BuildActorObjectFieldPacket( + account.AllocateServerPacketId(), + DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + DistrictConfig.FieldPawnPlayerReplicationInfo, + DistrictConfig.PawnFieldMax, + DistrictConfig.PlayerReplicationInfoChannel); + + pawnPriSent = _handshake.SendProtectedPacket(endpoint, account, pawnPri, "PAWN-PLAYER-REPLICATION-INFO"); + + DistrictLogger.Log((controllerPriSent && pawnPriSent) ? LogLevel.Success : LogLevel.Error, "District Character Bootstrap", + "Linked PRI channel={0}: Controller field={1} sent={2}; Pawn field={3} sent={4}; archetypeNetIndex={5}.", + DistrictConfig.PlayerReplicationInfoChannel, + DistrictConfig.FieldControllerPlayerReplicationInfo, + controllerPriSent ? 1 : 0, + DistrictConfig.FieldPawnPlayerReplicationInfo, + pawnPriSent ? 1 : 0, + playerReplicationInfoArchetype); + } + + // 1. Pawn.Controller = controller. + byte[] pawnController = PacketBuilders.BuildActorObjectFieldPacket( + account.AllocateServerPacketId(), + DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + DistrictConfig.FieldPawnController, + DistrictConfig.PawnFieldMax, + DistrictConfig.ControllerChannel); + + bool pawnControllerSent = _handshake.SendProtectedPacket(endpoint, account, pawnController, "PAWN-CONTROLLER"); + + // 2. PlayerController.Pawn = pawn. + byte[] controllerPawn = PacketBuilders.BuildActorObjectFieldPacket( + account.AllocateServerPacketId(), + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldPawn, + DistrictConfig.PlayerControllerFieldMax, + DistrictConfig.PawnChannel); + + bool controllerPawnSent = _handshake.SendProtectedPacket(endpoint, account, controllerPawn, "CONTROLLER-PAWN"); + + // The retail client reads RetHoldableItemManager() during Possess() and + // expects it non-null. Materialize both inventory actors and publish + // controller fields 164/165 before the ACK-gated sequence can reach + // GivePawn or ClientRestart. + InventoryActorBootstrapResult inventoryActorBootstrap = SendInventoryActorBootstrap(endpoint, account, spawnX, spawnY, spawnZ); + + if (!inventoryActorBootstrap.EffectiveSuccess) + { + DistrictLogger.Log(LogLevel.Error, "District Inventory Bootstrap", + "Required inventory actor bootstrap failed for account={0}; possession will not continue.", account.GetId()); + return false; + } + + bool pawnBootstrapAckGated = DistrictConfig.ReadBool("APB_PAWN_BOOTSTRAP_ACK_GATED", "PawnBootstrapAckGated", true); + + if (pawnBootstrapAckGated) + { + bool baseLifecycleSent = priOpened && controllerPriSent && pawnPriSent && + pawnControllerSent && controllerPawnSent && + inventoryActorBootstrap.EffectiveSuccess; + + if (!baseLifecycleSent) + { + DistrictLogger.Log(LogLevel.Error, "District Pawn ACK Gate", + "Cannot arm account={0} because the base pawn/PRI links did not all send successfully.", account.GetId()); + return false; + } + + bool sequenceArmed = ArmPawnAckGatedSequence(endpoint, account); + + DistrictLogger.Log(sequenceArmed ? LogLevel.Success : LogLevel.Error, "District Handshake", + "Pawn lifecycle for account {0} entered ACK-gated field-isolation mode after base links. Later fields and possession RPCs are sent one at a time.", + account.GetId()); + + return sequenceArmed; + } + + // Legacy immediate path (diagnostic fallback). + return SendLegacyImmediatePossession(endpoint, account); + } + + private bool SendLegacyImmediatePossession(IPEndPoint endpoint, Account account) + { + bool sendPawnCharacterBootstrap = DistrictConfig.ReadBool("APB_SEND_PAWN_CHARACTER_BOOTSTRAP", "SendPawnCharacterBootstrap", true); + bool pawnCharacterBootstrapSent = !sendPawnCharacterBootstrap; + + if (sendPawnCharacterBootstrap) + { + pawnCharacterBootstrapSent = SendPawnCharacterBootstrapFields(endpoint, account); + if (!pawnCharacterBootstrapSent) + return false; + + int settleMilliseconds = DistrictConfig.ReadInt("APB_PAWN_CHARACTER_BUILD_SETTLE_MS", "PawnCharacterBuildSettleMilliseconds", 250, 0, 5000); + if (settleMilliseconds > 0) + Thread.Sleep(settleMilliseconds); + } + + int interRpcDelayMilliseconds = DistrictConfig.ReadInt("APB_POSSESSION_RPC_DELAY_MS", "PossessionRpcDelayMilliseconds", 0, 0, 2000); + + bool sendGivePawn = DistrictConfig.ReadBool("APB_SEND_GIVE_PAWN", "SendGivePawn", true); + bool givePawnSent = !sendGivePawn; + if (sendGivePawn) + { + givePawnSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorObjectRpcPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldGivePawn, DistrictConfig.PlayerControllerFieldMax, DistrictConfig.PawnChannel), + "GIVE-PAWN"); + if (givePawnSent && interRpcDelayMilliseconds > 0) + Thread.Sleep(interRpcDelayMilliseconds); + } + + bool sendClientRestart = DistrictConfig.ReadBool("APB_SEND_CLIENT_RESTART", "SendClientRestart", true); + bool restartSent = !sendClientRestart; + if (sendClientRestart) + { + restartSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorObjectRpcPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldClientRestart, DistrictConfig.PlayerControllerFieldMax, DistrictConfig.PawnChannel), + "CLIENT-RESTART"); + if (restartSent && interRpcDelayMilliseconds > 0) + Thread.Sleep(interRpcDelayMilliseconds); + } + + bool sendControllerAliveAfterRestart = DistrictConfig.ReadBool("APB_SEND_CONTROLLER_ALIVE_AFTER_RESTART", "SendControllerAliveAfterRestart", true); + bool controllerAliveSent = !sendControllerAliveAfterRestart; + if (sendControllerAliveAfterRestart) + { + if (!restartSent) + { + DistrictLogger.Log(LogLevel.Error, "District Spawn State", + "Cannot send m_bDead=false because ClientRestart was not sent successfully for account={0}.", account.GetId()); + controllerAliveSent = false; + } + else + { + uint deadField = (uint)DistrictConfig.ReadInt("APB_CONTROLLER_DEAD_FIELD", "ControllerDeadField", (int)DistrictConfig.FieldControllerDead, 0, (int)(DistrictConfig.PlayerControllerFieldMax - 1u)); + controllerAliveSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorBoolFieldPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + deadField, DistrictConfig.PlayerControllerFieldMax, false), + "CONTROLLER-DEAD-FALSE"); + + DistrictLogger.Log(controllerAliveSent ? LogLevel.Success : LogLevel.Error, "District Spawn State", + "Replicated m_bDead=false account={0} field={1} sent={2} after ClientRestart.", account.GetId(), deadField, controllerAliveSent ? 1 : 0); + + if (controllerAliveSent && interRpcDelayMilliseconds > 0) + Thread.Sleep(interRpcDelayMilliseconds); + } + } + + bool sendClientSetViewTarget = DistrictConfig.ReadBool("APB_SEND_CLIENT_SET_VIEW_TARGET", "SendClientSetViewTarget", true); + bool viewTargetSent = !sendClientSetViewTarget; + if (sendClientSetViewTarget) + { + viewTargetSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorObjectRpcPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldClientSetViewTarget, DistrictConfig.PlayerControllerFieldMax, DistrictConfig.PawnChannel, 1), + "CLIENT-SET-VIEW-TARGET"); + } + + return givePawnSent && restartSent && controllerAliveSent && viewTargetSent; + } + + // ------------------------------------------------------------------ + // Pawn character bootstrap fields (legacy immediate mode) + // ------------------------------------------------------------------ + + public bool SendPawnCharacterBootstrapFields(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + bool sendUid = DistrictConfig.ReadBool("APB_SEND_PAWN_CONTROLLER_CHARACTER_UID", "SendPawnControllerCharacterUid", true); + bool sendGender = DistrictConfig.ReadBool("APB_SEND_PAWN_GENDER", "SendPawnGender", true); + bool sendFaction = DistrictConfig.ReadBool("APB_SEND_PAWN_FACTION", "SendPawnFaction", true); + bool sendDescriptor = DistrictConfig.ReadBool("APB_SEND_PAWN_CUSTOMISATION_GUIDS", "SendPawnCustomisationGuids", true); + + int characterUid = (int)account.GetCharacterId(); + byte gender = account.GetCharacterGender(); + byte faction = account.GetCharacterFaction(); + + bool uidSent = !sendUid; + bool genderSent = !sendGender; + bool factionSent = !sendFaction; + bool descriptorSent = !sendDescriptor; + + if (sendUid) + { + uidSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorIntFieldPacket(account.AllocateServerPacketId(), DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + DistrictConfig.FieldPawnControllerCharacterUid, DistrictConfig.PawnFieldMax, new[] { characterUid }), + "PAWN-CONTROLLER-CHARACTER-UID"); + } + + if (sendGender) + { + genderSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorEnumByteFieldPacket(account.AllocateServerPacketId(), DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + DistrictConfig.FieldPawnGender, DistrictConfig.PawnFieldMax, gender, 5u), + "PAWN-GENDER"); + } + + if (sendFaction) + { + factionSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorEnumByteFieldPacket(account.AllocateServerPacketId(), DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + DistrictConfig.FieldPawnFaction, DistrictConfig.PawnFieldMax, faction, 5u), + "PAWN-FACTION"); + } + + if (sendDescriptor) + { + if (!BuildPawnCompactGolemDescriptor(account, out byte[] descriptor)) + return false; + + descriptorSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorCompactGolemDescriptorFieldPacket(account.AllocateServerPacketId(), DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + DistrictConfig.FieldPawnCustomisationGuids, DistrictConfig.PawnFieldMax, descriptor), + "PAWN-CUSTOMISATION-GUIDS"); + } + + DistrictLogger.Log((uidSent && genderSent && factionSent && descriptorSent) ? LogLevel.Success : LogLevel.Error, + "District Character Bootstrap", + "Pawn field isolation (legacy immediate mode): UID(enabled={0},field={1},value={2},sent={3}) Gender(enabled={4},field={5},value={6},sent={7}) Faction(enabled={8},field={9},value={10},sent={11}) Descriptor(enabled={12},field={13},sent={14}).", + sendUid ? 1 : 0, DistrictConfig.FieldPawnControllerCharacterUid, characterUid, uidSent ? 1 : 0, + sendGender ? 1 : 0, DistrictConfig.FieldPawnGender, gender, genderSent ? 1 : 0, + sendFaction ? 1 : 0, DistrictConfig.FieldPawnFaction, faction, factionSent ? 1 : 0, + sendDescriptor ? 1 : 0, DistrictConfig.FieldPawnCustomisationGuids, descriptorSent ? 1 : 0); + + return uidSent && genderSent && factionSent && descriptorSent; + } + + public bool BuildPawnCompactGolemDescriptor(Account account, out byte[] descriptor) + { + descriptor = new byte[48]; + + string wireMode = DistrictConfig.ReadSetting("APB_PAWN_COMPACT_DESCRIPTOR_WIRE_MODE", "PawnCompactDescriptorWireMode", "descriptor-fguid-dwords").ToLowerInvariant(); + string fullOverride = DistrictConfig.ReadSetting("APB_PAWN_COMPACT_DESCRIPTOR_HEX", "PawnCompactGolemDescriptorHex", ""); + + bool hasFullOverride = fullOverride.Length > 0; + if (hasFullOverride && !TryParseExactHexBytes(fullOverride, descriptor)) + { + DistrictLogger.Log(LogLevel.Error, "District Character Bootstrap", + "PawnCompactGolemDescriptorHex must contain exactly 48 bytes / 96 hexadecimal digits."); + return false; + } + + bool useAppearanceDescriptor = DistrictConfig.ReadBool("APB_PAWN_DESCRIPTOR_USE_APPEARANCE_GUID", "PawnDescriptorUseAppearanceGuid", true); + + if (!hasFullOverride && useAppearanceDescriptor) + { + if (account == null) + return false; + + byte[] appearance = account.GetAppearance(); + + // The raw blob's three serialized FGuid values (48 bytes) begin at + // raw offset 16 and are already the exact CompactGolemDescriptor + // A/B/C/D DWORD representation. + const int descriptorOffset = 16; + const int descriptorBytes = 48; + + if (appearance.Length < descriptorOffset + descriptorBytes) + { + DistrictLogger.Log(LogLevel.Error, "District Character Bootstrap", + "Appearance blob is too short to supply all three CompactGolemDescriptor GUIDs: bytes={0} required={1}.", + appearance.Length, descriptorOffset + descriptorBytes); + return false; + } + + Array.Copy(appearance, descriptorOffset, descriptor, 0, descriptorBytes); + } + else if (!hasFullOverride) + { + string tail = DistrictConfig.ReadSetting("APB_PAWN_COMPACT_DESCRIPTOR_TAIL_HEX", "PawnCompactGolemDescriptorTailHex", + "A2E49A6CB9A3E747A5D217125C22A2AC6516A78B8D07C94C822B858FC1627FBA"); + + byte[] tailBytes = new byte[32]; + if (!TryParseExactHexBytes(tail, tailBytes)) + { + DistrictLogger.Log(LogLevel.Error, "District Character Bootstrap", + "PawnCompactGolemDescriptorTailHex must contain exactly 32 bytes / 64 hexadecimal digits."); + return false; + } + + Array.Copy(tailBytes, 0, descriptor, 16, 32); + } + + // Compatibility: previous INI files use "fguid-dwords". The source + // bytes are already serialized FGuid DWORDs, so this mode is a no-op. + // "raw-memory" is equivalent. Keep the old byte conversion only behind + // an explicit legacy diagnostic mode (windows-guid-convert), matching + // the C++ BuildPawnCompactGolemDescriptor. + if (wireMode == "fguid-dwords" || wireMode == "raw-memory" || wireMode == "descriptor-fguid-dwords") + { + wireMode = "descriptor-fguid-dwords"; + } + else if (wireMode == "windows-guid-convert") + { + byte[] converted = new byte[48]; + for (int guidIndex = 0; guidIndex < 3; ++guidIndex) + ConvertWindowsGuidBytesToFguidDwords(descriptor.AsSpan(guidIndex * 16, 16), converted.AsSpan(guidIndex * 16, 16)); + descriptor = converted; + } + else + { + DistrictLogger.Log(LogLevel.Warn, "District Character Bootstrap", + "Unknown PawnCompactDescriptorWireMode '{0}'; using descriptor-fguid-dwords without byte conversion.", wireMode); + wireMode = "descriptor-fguid-dwords"; + } + + DistrictLogger.Log(LogLevel.Info, "District Character Bootstrap", + "Built CompactGolemDescriptor source={0} wireMode={1} bytes={2}.", + hasFullOverride ? "hex-override" : (useAppearanceDescriptor ? "appearance-guid-array" : "fallback-tail"), + wireMode, + Diagnostics.Hex(descriptor, descriptor.Length)); + + return true; + } + + /// + /// Ported from ConvertWindowsGuidBytesToFguidDwords in DistrictServer.cpp: + /// reorders the 16 bytes of a Windows GUID into the four-DWORD FGuid + /// representation (first DWORD little-endian, then the two 16-bit halves, + /// then the trailing 8 bytes). Only used by the legacy + /// windows-guid-convert diagnostic wire mode. + /// + private static void ConvertWindowsGuidBytesToFguidDwords(ReadOnlySpan source, Span destination) + { + ReadOnlySpan map = stackalloc byte[16] + { + 0, 1, 2, 3, + 6, 7, 4, 5, + 11, 10, 9, 8, + 15, 14, 13, 12 + }; + + for (int index = 0; index < 16; ++index) + destination[index] = source[map[index]]; + } + + private static bool TryParseExactHexBytes(string hex, byte[] output) + { + string clean = hex.Replace(" ", "").Replace("-", ""); + if (clean.Length != output.Length * 2) + return false; + + for (int i = 0; i < output.Length; ++i) + { + if (!byte.TryParse(clean.Substring(i * 2, 2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out byte value)) + return false; + output[i] = value; + } + + return true; + } + + // ------------------------------------------------------------------ + // Inventory actor bootstrap + // ------------------------------------------------------------------ + + public sealed class InventoryActorBootstrapResult + { + public bool Enabled; + public bool Required; + public bool ArchetypesResolved; + public bool HoldableOpened; + public bool InventoryOpened; + public bool HoldableLinked; + public bool InventoryLinked; + public bool HoldableOwningPawnLinked; + public bool StarterWeaponRequested; + public bool StarterWeaponDeferred; + public bool StarterWeaponSent; + public bool EffectiveSuccess = true; + public ushort HoldableChannel; + public ushort InventoryChannel; + public uint HoldableLocalNetIndex; + public uint InventoryLocalNetIndex; + public uint HoldableGlobalNetIndex; + public uint InventoryGlobalNetIndex; + } + + public InventoryActorBootstrapResult SendInventoryActorBootstrap(IPEndPoint endpoint, Account account, float spawnX, float spawnY, float spawnZ) + { + var result = new InventoryActorBootstrapResult + { + Enabled = DistrictConfig.ReadBool("APB_ENABLE_INVENTORY_ACTOR_BOOTSTRAP", "EnableInventoryActorBootstrap", true), + Required = DistrictConfig.ReadBool("APB_REQUIRE_INVENTORY_ACTOR_BOOTSTRAP", "RequireInventoryActorBootstrap", false) + }; + + if (!result.Enabled) + { + result.EffectiveSuccess = true; + DistrictLogger.Log(LogLevel.Info, "District Inventory Bootstrap", "Inventory actor bootstrap is disabled for account={0}.", account?.GetId() ?? 0u); + return result; + } + + if (account == null) + { + result.EffectiveSuccess = !result.Required; + return result; + } + + result.HoldableChannel = (ushort)DistrictConfig.ReadInt("APB_HOLDABLE_ITEM_MANAGER_CHANNEL", "HoldableItemManagerChannel", DistrictConfig.HoldableItemManagerChannelFallback, 9, 1022); + result.InventoryChannel = (ushort)DistrictConfig.ReadInt("APB_STORAGE_INVENTORY_CHANNEL", "StorageInventoryChannel", DistrictConfig.StorageInventoryChannelFallback, 9, 1022); + + if (result.HoldableChannel == result.InventoryChannel || + result.HoldableChannel == DistrictConfig.ControllerChannel || + result.InventoryChannel == DistrictConfig.ControllerChannel || + result.HoldableChannel == DistrictConfig.GriChannel || + result.InventoryChannel == DistrictConfig.GriChannel || + result.HoldableChannel == DistrictConfig.PawnChannel || + result.InventoryChannel == DistrictConfig.PawnChannel || + result.HoldableChannel == DistrictConfig.PlayerReplicationInfoChannel || + result.InventoryChannel == DistrictConfig.PlayerReplicationInfoChannel) + { + DistrictLogger.Log(LogLevel.Error, "District Inventory Bootstrap", + "Invalid/conflicting inventory actor channels: holdable={0} inventory={1}.", result.HoldableChannel, result.InventoryChannel); + result.EffectiveSuccess = !result.Required; + return result; + } + + result.ArchetypesResolved = ResolveInventoryActorArchetypes(result); + if (!result.ArchetypesResolved) + { + result.EffectiveSuccess = !result.Required; + return result; + } + + int interPacketDelayMilliseconds = DistrictConfig.ReadInt("APB_INVENTORY_BOOTSTRAP_INTER_PACKET_MS", "InventoryBootstrapInterPacketMilliseconds", 25, 0, 1000); + void Delay() + { + if (interPacketDelayMilliseconds > 0) + Thread.Sleep(interPacketDelayMilliseconds); + } + + result.HoldableOpened = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorOpenPacket(account.AllocateServerPacketId(), result.HoldableChannel, + ChannelSequenceAllocator.Allocate(endpoint, result.HoldableChannel), + result.HoldableGlobalNetIndex, spawnX, spawnY, spawnZ), + "HOLDABLE-ITEM-MANAGER-OPEN"); + if (result.HoldableOpened) + Delay(); + + result.InventoryOpened = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorOpenPacket(account.AllocateServerPacketId(), result.InventoryChannel, + ChannelSequenceAllocator.Allocate(endpoint, result.InventoryChannel), + result.InventoryGlobalNetIndex, spawnX, spawnY, spawnZ), + "STORAGE-INVENTORY-OPEN"); + + DistrictLogger.Log(result.InventoryOpened ? LogLevel.Success : LogLevel.Error, "District Inventory Bootstrap", + "Storage inventory open channel={0} serverPacketId={1} channelSequence={2} localNetIndex={3} globalNetIndex={4} sent={5}.", + result.InventoryChannel, account.GetId(), 0, result.InventoryLocalNetIndex, result.InventoryGlobalNetIndex, result.InventoryOpened ? 1 : 0); + + if (result.InventoryOpened) + Delay(); + + if (result.HoldableOpened) + { + result.HoldableLinked = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorObjectFieldPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldControllerHoldableItemManager, DistrictConfig.PlayerControllerFieldMax, result.HoldableChannel), + "CONTROLLER-HOLDABLE-ITEM-MANAGER"); + if (result.HoldableLinked) + Delay(); + } + + if (result.InventoryOpened) + { + result.InventoryLinked = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorObjectFieldPacket(account.AllocateServerPacketId(), DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldControllerInventory, DistrictConfig.PlayerControllerFieldMax, result.InventoryChannel), + "CONTROLLER-STORAGE-INVENTORY"); + } + + if (result.HoldableOpened && result.HoldableLinked) + { + result.HoldableOwningPawnLinked = SendHoldableOwningPawnLink(endpoint, account, result.HoldableChannel); + if (result.HoldableOwningPawnLinked) + Delay(); + } + + if (result.InventoryOpened && result.InventoryLinked) + { + result.StarterWeaponRequested = DistrictConfig.ReadBool("APB_SEND_STARTER_WEAPON_INVENTORY", "SendStarterWeaponInventory", false); + result.StarterWeaponDeferred = result.StarterWeaponRequested && + DistrictConfig.ReadBool("APB_DEFER_STARTER_WEAPON_UNTIL_PAWN_COMPLETE", "DeferStarterWeaponUntilPawnComplete", true); + + if (!result.StarterWeaponDeferred) + { + result.StarterWeaponSent = SendStarterWeaponInventoryItem(endpoint, account, result.InventoryChannel, out bool requested); + if (requested && result.StarterWeaponSent) + Delay(); + } + else + { + DistrictLogger.Log(LogLevel.Info, "District Weapon Bootstrap", + "Deferred starter InventoryItem for account={0} until the complete ACK-gated pawn lifecycle.", account.GetId()); + } + } + + bool actualSuccess = result.HoldableOpened && result.InventoryOpened && result.HoldableLinked && result.InventoryLinked && + result.HoldableOwningPawnLinked && + (!result.StarterWeaponRequested || result.StarterWeaponDeferred || result.StarterWeaponSent); + + result.EffectiveSuccess = actualSuccess || !result.Required; + + DistrictLogger.Log(actualSuccess ? LogLevel.Success : LogLevel.Warn, "District Inventory Bootstrap", + "Inventory actor bootstrap account={0} required={1} holdable(channel={2} field={3} local={4} global={5} open={6} link={7}) inventory(channel={8} field={9} local={10} global={11} open={12} link={13}) holdableOwningPawn(field={14}/{15} linked={16}) starterWeapon(requested={17} deferred={18} sent={19} fieldRange=545..644 storageFieldMax=3594) effectiveSuccess={20}.", + account.GetId(), result.Required ? 1 : 0, + result.HoldableChannel, DistrictConfig.FieldControllerHoldableItemManager, result.HoldableLocalNetIndex, result.HoldableGlobalNetIndex, result.HoldableOpened ? 1 : 0, result.HoldableLinked ? 1 : 0, + result.InventoryChannel, DistrictConfig.FieldControllerInventory, result.InventoryLocalNetIndex, result.InventoryGlobalNetIndex, result.InventoryOpened ? 1 : 0, result.InventoryLinked ? 1 : 0, + DistrictConfig.FieldHoldableOwningPawn, DistrictConfig.HoldableItemManagerFieldMax, result.HoldableOwningPawnLinked ? 1 : 0, + result.StarterWeaponRequested ? 1 : 0, result.StarterWeaponDeferred ? 1 : 0, result.StarterWeaponSent ? 1 : 0, + result.EffectiveSuccess ? 1 : 0); + + return result; + } + + private bool ResolveInventoryActorArchetypes(InventoryActorBootstrapResult result) + { + // Configured overrides win; otherwise the indices are auto-resolved + // from the cooked APBGame.u (calibrated via Default__cAPBPlayerController). + // Ported from ResolveInventoryActorArchetypes. + if (!CookedPackageResolver.ResolveInventoryActorArchetypes( + out uint holdableLocalNetIndex, out uint inventoryLocalNetIndex, + out uint holdableGlobalNetIndex, out uint inventoryGlobalNetIndex)) + { + return false; + } + + result.HoldableLocalNetIndex = holdableLocalNetIndex; + result.InventoryLocalNetIndex = inventoryLocalNetIndex; + result.HoldableGlobalNetIndex = holdableGlobalNetIndex; + result.InventoryGlobalNetIndex = inventoryGlobalNetIndex; + return true; + } + + private bool SendHoldableOwningPawnLink(IPEndPoint endpoint, Account account, ushort holdableChannel) + { + if (account == null || holdableChannel == 0) + return false; + + if (!DistrictConfig.ReadBool("APB_LINK_HOLDABLE_OWNING_PAWN", "LinkHoldableOwningPawn", true)) + { + DistrictLogger.Log(LogLevel.Info, "District Weapon Bootstrap", + "HoldableItemManager.m_OwningPawn link disabled for account={0}.", account.GetId()); + return true; + } + + bool sent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorObjectFieldPacket(account.AllocateServerPacketId(), holdableChannel, + ChannelSequenceAllocator.Allocate(endpoint, holdableChannel), + DistrictConfig.FieldHoldableOwningPawn, DistrictConfig.HoldableItemManagerFieldMax, DistrictConfig.PawnChannel), + "HOLDABLE-OWNING-PAWN"); + + DistrictLogger.Log(sent ? LogLevel.Success : LogLevel.Error, "District Weapon Bootstrap", + "Linked cHoldableItemManager.m_OwningPawn account={0} holdableChannel={1} field={2}/{3} pawnChannel={4} sent={5}.", + account.GetId(), holdableChannel, DistrictConfig.FieldHoldableOwningPawn, DistrictConfig.HoldableItemManagerFieldMax, DistrictConfig.PawnChannel, sent ? 1 : 0); + + return sent; + } + + private bool SendStarterWeaponInventoryItem(IPEndPoint endpoint, Account account, ushort inventoryChannel, out bool requested) + { + requested = DistrictConfig.ReadBool("APB_SEND_STARTER_WEAPON_INVENTORY", "SendStarterWeaponInventory", false); + if (!requested || account == null || inventoryChannel == 0) + return false; + + int slot = DistrictConfig.ReadInt("APB_STARTER_WEAPON_SLOT", "StarterWeaponSlot", 0, 0, 99); + int itemType = DistrictConfig.ReadInt("APB_STARTER_WEAPON_ITEM_TYPE", "StarterWeaponInventoryItemType", 962864000, 0, int.MaxValue); + + if (itemType <= 7 && !DistrictConfig.ReadBool("APB_ALLOW_STARTER_NON_WEAPON_ITEM_TYPE", "AllowStarterNonWeaponItemType", false)) + { + DistrictLogger.Log(LogLevel.Error, "District Weapon Bootstrap", + "Starter weapon inventory send rejected: StarterWeaponInventoryItemType={0} is in the known non-weapon range 0..7.", itemType); + return false; + } + + uint field = 545u + (uint)slot; + if (field > 644u) + { + DistrictLogger.Log(LogLevel.Error, "District Weapon Bootstrap", + "Computed starter weapon field {0} is outside the proven range 545..644.", field); + return false; + } + + byte[] item = BuildStarterWeaponInventoryItem(account, itemType, slot); + + bool writeStructPresenceBit = DistrictConfig.ReadBool("APB_STARTER_WEAPON_STRUCT_PRESENCE_BIT", "StarterWeaponStructPresenceBit", false); + uint serverPacketId = account.AllocateServerPacketId(); + ushort channelSequence = ChannelSequenceAllocator.Allocate(endpoint, inventoryChannel); + + byte[] packet = PacketBuilders.BuildActorRawFieldPacket(serverPacketId, inventoryChannel, channelSequence, field, 3594u, item, writeStructPresenceBit); + + bool sent = _handshake.SendProtectedPacket(endpoint, account, packet, "STORAGE-STARTER-WEAPON-ITEM"); + + DistrictLogger.Log(sent ? LogLevel.Success : LogLevel.Error, "District Weapon Bootstrap", + "Replicated starter InventoryItem account={0} inventoryChannel={1} slot={2} field={3}/3594 itemType={4} serverPacketId={5} channelSequence={6} sent={7}.", + account.GetId(), inventoryChannel, slot, field, itemType, serverPacketId, channelSequence, sent ? 1 : 0); + + return sent; + } + + private static byte[] BuildStarterWeaponInventoryItem(Account account, int inventoryItemType, int slot) + { + // 36-byte InventoryItemWire: GuidA/B/C/D, CreatorUid, ItemType, Start, Expiry, PackedData. + var item = new byte[36]; + uint accountId = account.GetId(); + int characterUid = (int)account.GetCharacterId(); + + uint guidA = 0x31504E57u; // "WNP1" + uint guidB = (uint)characterUid ^ 0xA53C9E17u; + uint guidC = accountId ^ 0x52425041u; // "APBR" + uint guidD = 0x10000000u | (uint)(slot + 1); + + if (guidB == 0) guidB = 0x10203040u; + if (guidC == 0) guidC = 0x50607080u; + + WriteU32(item, 0, guidA); + WriteU32(item, 4, guidB); + WriteU32(item, 8, guidC); + WriteU32(item, 12, guidD); + WriteI32(item, 16, characterUid); + WriteI32(item, 20, inventoryItemType); + WriteI32(item, 24, DistrictConfig.ReadInt("APB_STARTER_WEAPON_START_TIME", "StarterWeaponStartTime", 0, 0, int.MaxValue)); + WriteI32(item, 28, DistrictConfig.ReadInt("APB_STARTER_WEAPON_EXPIRY_TIME", "StarterWeaponExpiryTime", 0, 0, int.MaxValue)); + WriteI32(item, 32, DistrictConfig.ReadInt("APB_STARTER_WEAPON_PACKED_DATA", "StarterWeaponPackedData", 0, 0, int.MaxValue)); + + string recordMode = DistrictConfig.ReadSetting("APB_STARTER_WEAPON_RECORD_MODE", "StarterWeaponRecordMode", "full").ToLowerInvariant(); + if (recordMode == "zero") + { + Array.Clear(item); + } + else if (recordMode == "itemtype-only" || recordMode == "item-type-only") + { + Array.Clear(item); + WriteI32(item, 20, inventoryItemType); + } + else if (recordMode == "guid-itemtype" || recordMode == "guid-and-itemtype") + { + WriteI32(item, 16, 0); + WriteI32(item, 24, 0); + WriteI32(item, 28, 0); + WriteI32(item, 32, 0); + } + + return item; + } + + private static void WriteU32(byte[] buffer, int offset, uint value) + { + buffer[offset] = (byte)value; + buffer[offset + 1] = (byte)(value >> 8); + buffer[offset + 2] = (byte)(value >> 16); + buffer[offset + 3] = (byte)(value >> 24); + } + + private static void WriteI32(byte[] buffer, int offset, int value) => WriteU32(buffer, offset, (uint)value); + + // ------------------------------------------------------------------ + // ACK-gated pawn stage machine + // ------------------------------------------------------------------ + + private enum PawnAckGatedStage + { + ControllerCharacterUid, + Gender, + Faction, + CustomisationGuids, + GivePawn, + ClientRestart, + ControllerAlive, + ClientSetViewTarget, + Complete + } + + private sealed class PawnAckGatedSequenceState + { + public IPEndPoint Endpoint = null!; + public PawnAckGatedStage NextStage = PawnAckGatedStage.ControllerCharacterUid; + public uint WaitingPacketId; + public string WaitingLabel = ""; + public bool Active; + public long ArmedTick; + } + + private readonly object _pawnAckGate = new(); + private readonly Dictionary _pawnAckGatedSequenceStates = new(); + + private static string PawnAckGatedStageName(PawnAckGatedStage stage) => stage switch + { + PawnAckGatedStage.ControllerCharacterUid => "UID", + PawnAckGatedStage.Gender => "GENDER", + PawnAckGatedStage.Faction => "FACTION", + PawnAckGatedStage.CustomisationGuids => "CUSTOMISATION-GUIDS", + PawnAckGatedStage.GivePawn => "GIVE-PAWN", + PawnAckGatedStage.ClientRestart => "CLIENT-RESTART", + PawnAckGatedStage.ControllerAlive => "CONTROLLER-ALIVE", + PawnAckGatedStage.ClientSetViewTarget => "CLIENT-SET-VIEW-TARGET", + _ => "COMPLETE" + }; + + private static PawnAckGatedStage NextPawnAckGatedStage(PawnAckGatedStage stage) => stage switch + { + PawnAckGatedStage.ControllerCharacterUid => PawnAckGatedStage.Gender, + PawnAckGatedStage.Gender => PawnAckGatedStage.Faction, + PawnAckGatedStage.Faction => PawnAckGatedStage.CustomisationGuids, + PawnAckGatedStage.CustomisationGuids => PawnAckGatedStage.GivePawn, + PawnAckGatedStage.GivePawn => PawnAckGatedStage.ClientRestart, + PawnAckGatedStage.ClientRestart => PawnAckGatedStage.ControllerAlive, + PawnAckGatedStage.ControllerAlive => PawnAckGatedStage.ClientSetViewTarget, + _ => PawnAckGatedStage.Complete + }; + + private static bool PawnAckGatedStageEnabled(PawnAckGatedStage stage) + { + bool sendCharacterBootstrap = DistrictConfig.ReadBool("APB_SEND_PAWN_CHARACTER_BOOTSTRAP", "SendPawnCharacterBootstrap", true); + + return stage switch + { + PawnAckGatedStage.ControllerCharacterUid => sendCharacterBootstrap && DistrictConfig.ReadBool("APB_SEND_PAWN_CONTROLLER_CHARACTER_UID", "SendPawnControllerCharacterUid", true), + PawnAckGatedStage.Gender => sendCharacterBootstrap && DistrictConfig.ReadBool("APB_SEND_PAWN_GENDER", "SendPawnGender", true), + PawnAckGatedStage.Faction => sendCharacterBootstrap && DistrictConfig.ReadBool("APB_SEND_PAWN_FACTION", "SendPawnFaction", true), + PawnAckGatedStage.CustomisationGuids => sendCharacterBootstrap && DistrictConfig.ReadBool("APB_SEND_PAWN_CUSTOMISATION_GUIDS", "SendPawnCustomisationGuids", true), + PawnAckGatedStage.GivePawn => DistrictConfig.ReadBool("APB_SEND_GIVE_PAWN", "SendGivePawn", true), + PawnAckGatedStage.ClientRestart => DistrictConfig.ReadBool("APB_SEND_CLIENT_RESTART", "SendClientRestart", true), + PawnAckGatedStage.ControllerAlive => DistrictConfig.ReadBool("APB_SEND_CONTROLLER_ALIVE_AFTER_RESTART", "SendControllerAliveAfterRestart", true), + PawnAckGatedStage.ClientSetViewTarget => DistrictConfig.ReadBool("APB_SEND_CLIENT_SET_VIEW_TARGET", "SendClientSetViewTarget", true), + _ => false + }; + } + + public void ResetPawnAckGatedSequenceState(uint accountId) + { + lock (_pawnAckGate) + { + _pawnAckGatedSequenceStates.Remove(accountId); + } + } + + public bool ArmPawnAckGatedSequence(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + uint accountId = account.GetId(); + + lock (_pawnAckGate) + { + _pawnAckGatedSequenceStates[accountId] = new PawnAckGatedSequenceState + { + Endpoint = endpoint, + NextStage = PawnAckGatedStage.ControllerCharacterUid, + Active = true, + ArmedTick = Environment.TickCount64 + }; + } + + DistrictLogger.Log(LogLevel.Warn, "District Pawn ACK Gate", + "Armed account={0} delayAfterAckMs={1} stages={{UID:{2} Gender:{3} Faction:{4} Descriptor:{5} GivePawn:{6} ClientRestart:{7} Alive:{8} ViewTarget:{9}}}.", + accountId, + DistrictConfig.ReadInt("APB_PAWN_BOOTSTRAP_ACK_DELAY_MS", "PawnBootstrapAckDelayMilliseconds", 250, 0, 10000), + PawnAckGatedStageEnabled(PawnAckGatedStage.ControllerCharacterUid) ? 1 : 0, + PawnAckGatedStageEnabled(PawnAckGatedStage.Gender) ? 1 : 0, + PawnAckGatedStageEnabled(PawnAckGatedStage.Faction) ? 1 : 0, + PawnAckGatedStageEnabled(PawnAckGatedStage.CustomisationGuids) ? 1 : 0, + PawnAckGatedStageEnabled(PawnAckGatedStage.GivePawn) ? 1 : 0, + PawnAckGatedStageEnabled(PawnAckGatedStage.ClientRestart) ? 1 : 0, + PawnAckGatedStageEnabled(PawnAckGatedStage.ControllerAlive) ? 1 : 0, + PawnAckGatedStageEnabled(PawnAckGatedStage.ClientSetViewTarget) ? 1 : 0); + + return SendNextPawnAckGatedStage(account); + } + + private bool SendNextPawnAckGatedStage(Account? account) + { + if (account == null) + return false; + + uint accountId = account.GetId(); + + for (;;) + { + PawnAckGatedStage stage; + IPEndPoint endpoint; + + lock (_pawnAckGate) + { + if (!_pawnAckGatedSequenceStates.TryGetValue(accountId, out PawnAckGatedSequenceState? state) || !state.Active) + return false; + + if (state.WaitingPacketId != 0) + return true; + + stage = state.NextStage; + endpoint = state.Endpoint; + state.NextStage = NextPawnAckGatedStage(stage); + } + + if (stage == PawnAckGatedStage.Complete) + { + lock (_pawnAckGate) + { + if (_pawnAckGatedSequenceStates.TryGetValue(accountId, out PawnAckGatedSequenceState? state)) + { + state.Active = false; + } + } + + DistrictLogger.Log(LogLevel.Success, "District Pawn ACK Gate", + "Sequence complete for account={0}. Every enabled stage was ACKed individually.", accountId); + + // Multiplayer visibility: this player is now fully possessed, + // so publish its pawn to every other in-world player and open + // the existing players' pawns on it. + RemotePawnReplication?.NotifyAccountPossessed(account); + + PostPossessionUnlock.Arm(endpoint, accountId); + + // TODO(next milestone): deferred starter-weapon inventory item + // after the complete pawn lifecycle (SendStarterWeaponInventoryItem + // is already ported above). + + return true; + } + + if (!PawnAckGatedStageEnabled(stage)) + { + DistrictLogger.Log(LogLevel.Info, "District Pawn ACK Gate", + "Skipping disabled stage account={0} stage={1}.", accountId, PawnAckGatedStageName(stage)); + continue; + } + + uint serverPacketId = account.AllocateServerPacketId(); + ushort channelIndex; + byte[] packet; + string label = "PAWN-ACK-STAGE-" + PawnAckGatedStageName(stage); + + switch (stage) + { + case PawnAckGatedStage.ControllerCharacterUid: + channelIndex = DistrictConfig.PawnChannel; + packet = PacketBuilders.BuildActorIntFieldPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + DistrictConfig.FieldPawnControllerCharacterUid, DistrictConfig.PawnFieldMax, new[] { (int)account.GetCharacterId() }); + break; + + case PawnAckGatedStage.Gender: + channelIndex = DistrictConfig.PawnChannel; + packet = PacketBuilders.BuildActorEnumByteFieldPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + DistrictConfig.FieldPawnGender, DistrictConfig.PawnFieldMax, account.GetCharacterGender(), 5u); + break; + + case PawnAckGatedStage.Faction: + channelIndex = DistrictConfig.PawnChannel; + packet = PacketBuilders.BuildActorEnumByteFieldPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + DistrictConfig.FieldPawnFaction, DistrictConfig.PawnFieldMax, account.GetCharacterFaction(), 5u); + break; + + case PawnAckGatedStage.CustomisationGuids: + { + if (!BuildPawnCompactGolemDescriptor(account, out byte[] descriptor)) + { + DistrictLogger.Log(LogLevel.Error, "District Pawn ACK Gate", "Could not build descriptor for account={0}.", accountId); + ResetPawnAckGatedSequenceState(accountId); + return false; + } + + channelIndex = DistrictConfig.PawnChannel; + packet = PacketBuilders.BuildActorCompactGolemDescriptorFieldPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + DistrictConfig.FieldPawnCustomisationGuids, DistrictConfig.PawnFieldMax, descriptor); + + DistrictLogger.Log(LogLevel.Warn, "District Pawn ACK Gate", + "Descriptor stage armed account={0} field={1} payloadBits=390. This remains the known crash candidate.", accountId, DistrictConfig.FieldPawnCustomisationGuids); + break; + } + + case PawnAckGatedStage.GivePawn: + channelIndex = DistrictConfig.ControllerChannel; + packet = PacketBuilders.BuildActorObjectRpcPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + DistrictConfig.FieldGivePawn, DistrictConfig.PlayerControllerFieldMax, DistrictConfig.PawnChannel); + break; + + case PawnAckGatedStage.ClientRestart: + channelIndex = DistrictConfig.ControllerChannel; + packet = PacketBuilders.BuildActorObjectRpcPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + DistrictConfig.FieldClientRestart, DistrictConfig.PlayerControllerFieldMax, DistrictConfig.PawnChannel); + break; + + case PawnAckGatedStage.ControllerAlive: + { + uint deadField = (uint)DistrictConfig.ReadInt("APB_CONTROLLER_DEAD_FIELD", "ControllerDeadField", (int)DistrictConfig.FieldControllerDead, 0, (int)(DistrictConfig.PlayerControllerFieldMax - 1u)); + channelIndex = DistrictConfig.ControllerChannel; + packet = PacketBuilders.BuildActorBoolFieldPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + deadField, DistrictConfig.PlayerControllerFieldMax, false); + + DistrictLogger.Log(LogLevel.Warn, "District Spawn State", + "Sending cAPBPlayerController.m_bDead=false for account={0} field={1} after ClientRestart ACK.", accountId, deadField); + break; + } + + case PawnAckGatedStage.ClientSetViewTarget: + channelIndex = DistrictConfig.ControllerChannel; + packet = PacketBuilders.BuildActorObjectRpcPacket(serverPacketId, channelIndex, + ChannelSequenceAllocator.Allocate(endpoint, channelIndex), + DistrictConfig.FieldClientSetViewTarget, DistrictConfig.PlayerControllerFieldMax, DistrictConfig.PawnChannel, 1); + break; + + default: + return false; + } + + if (!_reliableQueue.SendTrackedReliablePacket(endpoint, account, serverPacketId, packet, label)) + { + DistrictLogger.Log(LogLevel.Error, "District Pawn ACK Gate", + "Send failed account={0} stage={1} packetId={2}.", accountId, PawnAckGatedStageName(stage), serverPacketId); + ResetPawnAckGatedSequenceState(accountId); + return false; + } + + lock (_pawnAckGate) + { + if (_pawnAckGatedSequenceStates.TryGetValue(accountId, out PawnAckGatedSequenceState? state)) + { + state.WaitingPacketId = serverPacketId; + state.WaitingLabel = label; + } + } + + DistrictLogger.Log(LogLevel.Success, "District Pawn ACK Gate", + "Sent account={0} stage={1} packetId={2} channel={3}; no later stage will be sent until this packet is ACKed.", + accountId, PawnAckGatedStageName(stage), serverPacketId, channelIndex); + + return true; + } + } + + private void AdvancePawnAckGatedSequenceAfterAck(uint accountId, uint acknowledgedPacketId, PendingServerReliable acknowledged) + { + bool belongsToSequence; + + lock (_pawnAckGate) + { + belongsToSequence = + _pawnAckGatedSequenceStates.TryGetValue(accountId, out PawnAckGatedSequenceState? state) && + state.Active && + state.WaitingPacketId == acknowledgedPacketId; + + if (belongsToSequence) + { + state!.WaitingPacketId = 0; + state.WaitingLabel = ""; + } + } + + if (!belongsToSequence) + return; + + int delayMilliseconds = DistrictConfig.ReadInt("APB_PAWN_BOOTSTRAP_ACK_DELAY_MS", "PawnBootstrapAckDelayMilliseconds", 250, 0, 10000); + + if (acknowledged.Label == "PAWN-ACK-STAGE-CUSTOMISATION-GUIDS") + { + int buildSettleMilliseconds = DistrictConfig.ReadInt("APB_PAWN_CHARACTER_BUILD_SETTLE_MS", "PawnCharacterBuildSettleMilliseconds", 250, 0, 10000); + delayMilliseconds = Math.Max(delayMilliseconds, buildSettleMilliseconds); + } + + DistrictLogger.Log(LogLevel.Success, "District Pawn ACK Gate", + "ACK accepted account={0} packetId={1} label={2}; advancing after {3} ms.", accountId, acknowledgedPacketId, acknowledged.Label, delayMilliseconds); + + if (delayMilliseconds > 0) + Thread.Sleep(delayMilliseconds); + + Account? account = AccountManager.Find(accountId); + SendNextPawnAckGatedStage(account); + } +} diff --git a/DistrictServerCSharp/Pawn/PostPossessionMovementUnlock.cs b/DistrictServerCSharp/Pawn/PostPossessionMovementUnlock.cs new file mode 100644 index 0000000..b1d146a --- /dev/null +++ b/DistrictServerCSharp/Pawn/PostPossessionMovementUnlock.cs @@ -0,0 +1,181 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Pawn; + +/// +/// Post-possession movement unlock, ported from the +/// g_postPossessionMovementUnlockStates machinery in DistrictServer.cpp. +/// Armed when the ACK-gated pawn lifecycle completes; on a timer it clears the +/// controller's move-input and the pawn's winded flag (the emulated pawn falls +/// after ClientRestart and lands winded, which IsPawnImmobile() treats as +/// immobile). +/// +public sealed class PostPossessionMovementUnlock +{ + private sealed class UnlockState + { + public IPEndPoint Endpoint = null!; + public long NextAttemptTick; + public int Attempt; + public int TotalAttempts; + public int RetryIntervalMilliseconds; + public bool ForcePlayerWalking; + public bool ClearMoveInput = true; + public bool ClearPawnWinded = true; + public uint PawnIsWindedField = DistrictConfig.DefaultFieldPawnIsWinded; + } + + private readonly HandshakeService _handshake; + private readonly object _gate = new(); + private readonly Dictionary _states = new(); + + public PostPossessionMovementUnlock(HandshakeService handshake) => _handshake = handshake; + + public void Reset(uint accountId) + { + lock (_gate) + { + _states.Remove(accountId); + } + } + + public void Arm(IPEndPoint endpoint, uint accountId) + { + bool enabled = DistrictConfig.ReadBool("APB_ENABLE_POST_POSSESSION_MOVEMENT_UNLOCK", "EnablePostPossessionMovementUnlock", true); + + Reset(accountId); + + if (!enabled) + { + DistrictLogger.Log(LogLevel.Info, "District Player State", "Post-possession movement unlock disabled for account={0}.", accountId); + return; + } + + int initialDelayMilliseconds = DistrictConfig.ReadInt("APB_POST_POSSESSION_MOVEMENT_UNLOCK_DELAY_MS", "PostPossessionMovementUnlockDelayMilliseconds", 750, 0, 10000); + int totalAttempts = DistrictConfig.ReadInt("APB_POST_POSSESSION_MOVEMENT_UNLOCK_ATTEMPTS", "PostPossessionMovementUnlockAttempts", 4, 1, 12); + int retryIntervalMilliseconds = DistrictConfig.ReadInt("APB_POST_POSSESSION_MOVEMENT_UNLOCK_INTERVAL_MS", "PostPossessionMovementUnlockIntervalMilliseconds", 500, 50, 5000); + + // Forcing ClientGotoState(PlayerWalking) proved harmful (forward-only + // sliding); leave APB's own PlayerSpawnWaitOnStreaming/ClientRestart + // transition intact. + bool forcePlayerWalking = DistrictConfig.ReadBool("APB_POST_POSSESSION_FORCE_PLAYER_WALKING", "PostPossessionForcePlayerWalking", false); + bool clearMoveInput = DistrictConfig.ReadBool("APB_POST_POSSESSION_CLEAR_MOVE_INPUT", "PostPossessionClearMoveInput", true); + bool clearPawnWinded = DistrictConfig.ReadBool("APB_POST_POSSESSION_CLEAR_PAWN_WINDED", "PostPossessionClearPawnWinded", true); + uint pawnIsWindedField = DistrictConfig.PawnIsWindedWireField(); + + if (!forcePlayerWalking && !clearMoveInput && !clearPawnWinded) + { + DistrictLogger.Log(LogLevel.Info, "District Player State", + "Post-possession movement unlock has no enabled actions for account={0}.", accountId); + return; + } + + lock (_gate) + { + _states[accountId] = new UnlockState + { + Endpoint = endpoint, + NextAttemptTick = Environment.TickCount64 + initialDelayMilliseconds, + TotalAttempts = totalAttempts, + RetryIntervalMilliseconds = retryIntervalMilliseconds, + ForcePlayerWalking = forcePlayerWalking, + ClearMoveInput = clearMoveInput, + ClearPawnWinded = clearPawnWinded, + PawnIsWindedField = pawnIsWindedField + }; + } + + DistrictLogger.Log(forcePlayerWalking ? LogLevel.Warn : LogLevel.Info, "District Player State", + "Armed post-possession locomotion release account={0} initialDelayMs={1} attempts={2} intervalMs={3} forcePlayerWalking={4} clearMoveInput={5} clearPawnWinded={6} windedField={7}.", + accountId, initialDelayMilliseconds, totalAttempts, retryIntervalMilliseconds, + forcePlayerWalking ? 1 : 0, clearMoveInput ? 1 : 0, clearPawnWinded ? 1 : 0, pawnIsWindedField); + } + + /// Runs one due attempt for the account (called from the per-packet dispatch). + public void MaybeSend(Account? account) + { + if (account == null) + return; + + uint accountId = account.GetId(); + + UnlockState? state; + bool due; + bool finalAttempt; + + lock (_gate) + { + if (!_states.TryGetValue(accountId, out state)) + return; + + if (Environment.TickCount64 < state.NextAttemptTick) + return; + + due = true; + ++state.Attempt; + finalAttempt = state.Attempt >= state.TotalAttempts; + + if (finalAttempt) + _states.Remove(accountId); + else + state.NextAttemptTick = Environment.TickCount64 + state.RetryIntervalMilliseconds; + } + + if (!due) + return; + + IPEndPoint endpoint = state.Endpoint; + + uint walkingPacketId = 0; + bool walkingSent = !state.ForcePlayerWalking; + + if (state.ForcePlayerWalking) + { + walkingPacketId = account.AllocateServerPacketId(); + walkingSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildClientGotoStatePacket(walkingPacketId, DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldClientGotoState, DistrictConfig.PlayerControllerFieldMax, "PlayerWalking"), + "POST-POSSESSION-PLAYER-WALKING"); + } + + uint moveInputPacketId = 0; + bool moveInputSent = !state.ClearMoveInput; + + if (state.ClearMoveInput) + { + moveInputPacketId = account.AllocateServerPacketId(); + moveInputSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorBoolFieldPacket(moveInputPacketId, DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldClientIgnoreMoveInput, DistrictConfig.PlayerControllerFieldMax, false), + "POST-POSSESSION-MOVE-INPUT-FALSE"); + } + + uint windedPacketId = 0; + bool windedSent = !state.ClearPawnWinded; + + if (state.ClearPawnWinded) + { + windedPacketId = account.AllocateServerPacketId(); + windedSent = _handshake.SendProtectedPacket(endpoint, account, + PacketBuilders.BuildActorBoolFieldPacket(windedPacketId, DistrictConfig.PawnChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.PawnChannel), + state.PawnIsWindedField, DistrictConfig.PawnFieldMax, false), + "POST-POSSESSION-PAWN-WINDED-FALSE"); + } + + DistrictLogger.Log((walkingSent && moveInputSent && windedSent) ? LogLevel.Success : LogLevel.Warn, "District Player State", + "Post-possession locomotion release account={0} attempt={1}/{2} PlayerWalking(enabled={3},packetId={4},sent={5}) IgnoreMoveInputFalse(enabled={6},packetId={7},sent={8}) PawnWindedFalse(enabled={9},field={10},packetId={11},sent={12}) final={13}.", + accountId, state.Attempt, state.TotalAttempts, + state.ForcePlayerWalking ? 1 : 0, walkingPacketId, walkingSent ? 1 : 0, + state.ClearMoveInput ? 1 : 0, moveInputPacketId, moveInputSent ? 1 : 0, + state.ClearPawnWinded ? 1 : 0, state.PawnIsWindedField, windedPacketId, windedSent ? 1 : 0, + finalAttempt ? 1 : 0); + } +} diff --git a/DistrictServerCSharp/Pawn/RemotePawnReplication.cs b/DistrictServerCSharp/Pawn/RemotePawnReplication.cs new file mode 100644 index 0000000..348a841 --- /dev/null +++ b/DistrictServerCSharp/Pawn/RemotePawnReplication.cs @@ -0,0 +1,567 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Controller; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Net; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.Pawn; + +/// +/// Remote-pawn multiplayer replication, ported from the g_remotePawn* block of +/// DistrictServer.cpp. Makes every possessed player visible to every other +/// possessed player on the same district instance. +/// +/// Each (viewer, target) pair gets two actor channels on the VIEWER's +/// connection (a cAPBPawn proxy plus its cAPBPlayerReplicationInfo) opened from +/// the same APBGame archetypes the owning connection uses. The target's current +/// ServerMove location is published as Actor.Location (field 6) updates, +/// throttled to ~33 ms, so remote pawns track the moving owner. +/// +public sealed class RemotePawnReplication +{ + private const int RemotePawnPushIntervalMilliseconds = 33; + private const int VelocityDecayMilliseconds = 250; + + // Dead-peer lifecycle thresholds, ported verbatim from the C++. + private const int DeadPeerErrorThreshold = 3; + private const long DeadPeerLiveWindowMilliseconds = 30000; + private const long DeadPeerProbeIntervalMilliseconds = 10000; + private const int DeadPeerProbeFailLimit = 3; + private const long DeadPeerRemoveSilenceMilliseconds = 60000; + + private readonly HandshakeService _handshake; + private readonly ControllerFeedbackService _controllerFeedback; + private readonly PawnLifecycleService _pawnLifecycle; + private readonly object _gate = new(); + private readonly Dictionary<(uint Viewer, uint Target), RemotePawnLink> _links = new(); + private readonly Dictionary _nextChannel = new(); + private readonly HashSet _inWorldAccounts = new(); + + // Dead-peer bookkeeping: a viewer whose UDP endpoint went away (client + // quit, or a stale endpoint we keep pushing to) surfaces as an ICMP + // "port unreachable" echo, which Windows reports as WSAECONNRESET on the + // next recvfrom. Peers are suspended from the push list after repeated + // resets, re-probed, and finally removed for good. + private readonly Dictionary _deadPeers = new(); + private readonly HashSet _lastPushedViewers = new(); + + private sealed class DeadPeerState + { + public int ConsecutiveErrors; + public int FailedProbes; + public long NextProbeTick; + public long LastSeenTick; + public long CreatedTick; + public bool Suspended; + } + + public RemotePawnReplication(HandshakeService handshake, ControllerFeedbackService controllerFeedback, PawnLifecycleService pawnLifecycle) + { + _handshake = handshake; + _controllerFeedback = controllerFeedback; + _pawnLifecycle = pawnLifecycle; + } + + private sealed class RemotePawnLink + { + public bool Opened; + public ushort PawnChannel; + public ushort PriChannel; + public long LastLocationPushTick; + } + + public bool IsAccountInWorld(uint accountId) + { + lock (_gate) + { + return _inWorldAccounts.Contains(accountId); + } + } + + /// + /// Any traffic from a remote-pawn participant proves it is alive: clear + /// its error counters and lift a suspension immediately, so a recovered + /// peer resumes pushing. Called on the UDP listener thread whenever a + /// parsed packet resolves to an account. Ported from + /// MarkRemotePawnPeerAlive. + /// + public void MarkRemotePawnPeerAlive(uint accountId) + { + lock (_gate) + { + if (!_deadPeers.TryGetValue(accountId, out DeadPeerState? state)) + return; + + bool wasSuspended = state.Suspended; + state.ConsecutiveErrors = 0; + state.FailedProbes = 0; + state.Suspended = false; + state.LastSeenTick = Environment.TickCount64; + + if (wasSuspended) + { + DistrictLogger.Log(LogLevel.Success, "District Remote Pawn", + "viewer={0} restored (traffic received).", accountId); + } + } + } + + /// + /// Called on recvfrom WSAECONNRESET (UDP listener thread): attribute the + /// dead-peer ICMP echo to the viewers pushed in the previous batch. Peers + /// that have sent traffic within DeadPeerLiveWindowMilliseconds are + /// presumed alive and skipped, so batch pollution cannot suspend a live + /// peer. The batch list is cleared after each burst so one probe + /// generates at most one increment per peer. Ported from + /// AttributeRemotePawnResetErrors. + /// + public void AttributeRemotePawnResetErrors() + { + lock (_gate) + { + if (_lastPushedViewers.Count == 0) + return; + + long now = Environment.TickCount64; + + foreach (uint viewerId in _lastPushedViewers) + { + if (viewerId == 0) + continue; + + if (!_deadPeers.TryGetValue(viewerId, out DeadPeerState? state)) + { + state = new DeadPeerState { CreatedTick = now }; + _deadPeers[viewerId] = state; + } + + if (state.LastSeenTick != 0 && + now - state.LastSeenTick < DeadPeerLiveWindowMilliseconds) + { + continue; // provably alive; reset came from another peer + } + + ++state.ConsecutiveErrors; + + if (!state.Suspended && state.ConsecutiveErrors >= DeadPeerErrorThreshold) + { + state.Suspended = true; + state.FailedProbes = 0; + state.NextProbeTick = now + DeadPeerProbeIntervalMilliseconds; + DistrictLogger.Log(LogLevel.Warn, "District Remote Pawn", + "viewer={0} marked dead ({1} consecutive ICMP errors); pushes paused, re-probing every {2} ms.", + viewerId, state.ConsecutiveErrors, DeadPeerProbeIntervalMilliseconds); + } + else if (state.Suspended) + { + ++state.FailedProbes; + DistrictLogger.Log(LogLevel.Warn, "District Remote Pawn", + "viewer={0} re-probe {1} failed.", viewerId, state.FailedProbes); + } + } + + _lastPushedViewers.Clear(); + } + } + + /// + /// Remove peers that are gone for good from the push list + /// (_inWorldAccounts + _links) and prune stale benign dead-peer + /// bookkeeping. Called from the push path on the UDP thread. Ported from + /// CleanupDeadRemotePawnPeers. + /// + private void CleanupDeadRemotePawnPeers() + { + var toRemove = new List(); + var toPrune = new List(); + + long now = Environment.TickCount64; + + lock (_gate) + { + foreach (KeyValuePair entry in _deadPeers) + { + DeadPeerState state = entry.Value; + if (state.Suspended) + { + if (state.FailedProbes < DeadPeerProbeFailLimit) + continue; + if (state.LastSeenTick != 0 && + now - state.LastSeenTick < DeadPeerRemoveSilenceMilliseconds) + continue; + toRemove.Add(entry.Key); + } + else + { + // Not suspended and long idle (either never seen at all + // -- attribution-only entry -- or silent for a while): + // drop the bookkeeping; the entry is recreated if the + // peer is ever pushed again. + long idleSince = state.LastSeenTick != 0 ? state.LastSeenTick : state.CreatedTick; + if (now - idleSince >= DeadPeerRemoveSilenceMilliseconds) + toPrune.Add(entry.Key); + } + } + } + + if (toRemove.Count == 0 && toPrune.Count == 0) + return; + + lock (_gate) + { + foreach (uint accountId in toRemove) + { + _inWorldAccounts.Remove(accountId); + + // Drop every link where this account is the viewer. + List<(uint Viewer, uint Target)> deadKeys = + _links.Keys.Where(k => k.Viewer == accountId).ToList(); + foreach ((uint Viewer, uint Target) key in deadKeys) + _links.Remove(key); + + _deadPeers.Remove(accountId); + + DistrictLogger.Log(LogLevel.Warn, "District Remote Pawn", + "viewer={0} removed from push list (dead peer, {1} failed re-probes, no traffic for {2} ms).", + accountId, DeadPeerProbeFailLimit, DeadPeerRemoveSilenceMilliseconds); + } + + foreach (uint accountId in toPrune) + _deadPeers.Remove(accountId); + } + } + + /// + /// Called when a player finishes the ACK-gated possession sequence. Marks + /// the account in-world, waits for its own character build to settle, then + /// opens every existing player's pawn on the newcomer and the newcomer's + /// pawn on everyone already in-world. Ported from NotifyAccountPossessed. + /// + public void NotifyAccountPossessed(Account? account) + { + if (account == null || !DistrictConfig.RemotePawnReplicationEnabled()) + return; + + lock (_gate) + { + _inWorldAccounts.Add(account.GetId()); + } + + // Opening a remote cAPBPawn on another client makes it build the remote + // character's mesh immediately. If that lands while the viewer is still + // building its OWN freshly possessed character, the two builds share + // engine globals and can corrupt the heap (observed as a write into + // msvcr90.dll). Stagger the opens so the owner's character build has + // settled first. + int openDelayMilliseconds = DistrictConfig.RemotePawnOpenDelayMilliseconds(); + if (openDelayMilliseconds > 0) + Thread.Sleep(openDelayMilliseconds); + + // Newcomer sees everyone already in-world. + ReplicateExistingPawnsToViewer(account); + + // Everyone already in-world sees the newcomer's pawn. + ReplicatePawnToAllViewers(account); + } + + /// + /// Publishes the source's current location/velocity/facing to its in-world + /// viewers, throttled per link to ~33 ms. Ported from + /// MaybePushRemotePawnLocations. All pushes are UNRELIABLE: at the + /// ServerMove cadence reliable bunches overflow the client's channel and + /// the remote pawn silently disappears. + /// + public void MaybePushRemotePawnLocations(Account? source) + { + if (source == null || + !DistrictConfig.RemotePawnReplicationEnabled() || + !DistrictConfig.RemotePawnSendLocation() || + !IsAccountInWorld(source.GetId())) + { + return; + } + + if (!GetAccountCurrentLocation(source, out float x, out float y, out float z)) + return; + + // Retire peers that have failed their re-probes (dead for good). + CleanupDeadRemotePawnPeers(); + + // Track the viewers pushed THIS batch for dead-peer attribution on the + // next WSAECONNRESET. Reset each pass so the list always holds exactly + // the most recent batch (it is also cleared by + // AttributeRemotePawnResetErrors after a burst). + lock (_gate) + { + _lastPushedViewers.Clear(); + } + + long now = Environment.TickCount64; + + float velocityX = 0.0f; + float velocityY = 0.0f; + float velocityZ = 0.0f; + int yaw = 0; + + if (_controllerFeedback.TryGetMotionSnapshot(source.GetId(), out MotionSnapshot motion)) + { + // Decay the replicated velocity to zero once the source stops + // sending motion samples (no ServerMove within 250 ms), so the + // remote pawn does not keep its last speed -- "runs in place" -- + // after the owner stops. + if (now - motion.LastMotionSampleTick < VelocityDecayMilliseconds) + { + velocityX = motion.VelocityX; + velocityY = motion.VelocityY; + velocityZ = motion.VelocityZ; + } + + // Prefer the owner's actual facing (View) over the velocity + // heading: in a third-person shooter the character faces the aim + // direction, not the movement direction. + yaw = motion.Yaw; + } + + bool sendVelocity = DistrictConfig.RemotePawnSendVelocity(); + bool sendRotation = DistrictConfig.RemotePawnSendRotation(); + + foreach (Account viewer in GetInWorldViewersOf(source.GetId())) + { + RemotePawnLink link; + lock (_gate) + { + if (!_links.TryGetValue((viewer.GetId(), source.GetId()), out RemotePawnLink? existing) || !existing.Opened) + continue; + + if (now - existing.LastLocationPushTick < RemotePawnPushIntervalMilliseconds) + continue; + + link = existing; + existing.LastLocationPushTick = now; + _lastPushedViewers.Add(viewer.GetId()); + } + + IPEndPoint viewerEndpoint = EndpointForAccount(viewer); + + // Velocity first (Actor field 4, live cache) so the AnimTree + // blends walk/run before the position steps. + if (sendVelocity) + { + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildUnreliableActorVectorFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + DistrictConfig.FieldVelocity, DistrictConfig.PawnFieldMax, + velocityX, velocityY, velocityZ), + "REMOTE-PAWN-VELOCITY"); + } + + if (sendRotation) + { + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildUnreliableActorRotatorFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + DistrictConfig.FieldRotation, DistrictConfig.PawnFieldMax, + 0, yaw, 0), + "REMOTE-PAWN-ROTATION"); + } + + // Vector properties on this client read the same no-presence + // compressed format the actor-open and HUD-marker paths write + // (WriteCompressedVector). Raw floats misalign the reader and the + // decoded "location" lands outside the district grid, which GPFs + // the client. + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildUnreliableActorVectorFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + DistrictConfig.FieldLocation, DistrictConfig.PawnFieldMax, + x, y, z), + "REMOTE-PAWN-LOCATION"); + } + } + + // ------------------------------------------------------------------ + // Replication helpers + // ------------------------------------------------------------------ + + private void ReplicatePawnToAllViewers(Account? target) + { + if (target == null || !DistrictConfig.RemotePawnReplicationEnabled()) + return; + + foreach (Account viewer in GetInWorldViewersOf(target.GetId())) + { + SendRemotePawnToViewer(viewer, target); + } + } + + private void ReplicateExistingPawnsToViewer(Account? viewer) + { + if (viewer == null || !DistrictConfig.RemotePawnReplicationEnabled()) + return; + + foreach (Account target in AccountManager.GetAll()) + { + if (target.GetId() == viewer.GetId() || !IsAccountInWorld(target.GetId())) + continue; + + SendRemotePawnToViewer(viewer, target); + } + } + + private bool SendRemotePawnToViewer(Account? viewer, Account? target) + { + if (viewer == null || target == null || + viewer.GetId() == target.GetId() || + !viewer.HasEndpoint() || + !target.HasCharacterProfile()) + { + return false; + } + + RemotePawnLink link; + lock (_gate) + { + if (_links.TryGetValue((viewer.GetId(), target.GetId()), out RemotePawnLink? existing) && existing.Opened) + return true; + + if (!_nextChannel.TryGetValue(viewer.GetId(), out ushort nextChannel) || nextChannel < DistrictConfig.RemotePawnChannelBase) + nextChannel = DistrictConfig.RemotePawnChannelBase; + + link = new RemotePawnLink + { + PawnChannel = nextChannel++, + PriChannel = nextChannel++, + Opened = true + }; + _nextChannel[viewer.GetId()] = nextChannel; + _links[(viewer.GetId(), target.GetId())] = link; + } + + IPEndPoint viewerEndpoint = EndpointForAccount(viewer); + + float x = 0.0f; + float y = 0.0f; + float z = 500.0f; + GetAccountCurrentLocation(target, out x, out y, out z); + + uint pawnArchetype = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.PawnArchetypeObjectIndex); + uint priArchetype = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.PlayerReplicationInfoArchetypeObjectIndex); + + if (!_handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildActorOpenPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + ChannelSequenceAllocator.Allocate(viewerEndpoint, link.PawnChannel), + pawnArchetype, x, y, z), + "REMOTE-PAWN-OPEN")) + { + return false; + } + + if (DistrictConfig.RemotePawnSendPri()) + { + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildActorOpenPacket( + viewer.AllocateServerPacketId(), link.PriChannel, + ChannelSequenceAllocator.Allocate(viewerEndpoint, link.PriChannel), + priArchetype, x, y, z), + "REMOTE-PRI-OPEN"); + + // Pawn.PlayerReplicationInfo -> remote PRI channel. + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildActorObjectFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + ChannelSequenceAllocator.Allocate(viewerEndpoint, link.PawnChannel), + DistrictConfig.FieldPawnPlayerReplicationInfo, DistrictConfig.PawnFieldMax, + link.PriChannel), + "REMOTE-PAWN-PRI"); + } + + // Character identity fields so the remote pawn renders with the owner's + // character build (same fields the owning connection gets). + int characterUid = (int)target.GetCharacterId(); + + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildActorIntFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + ChannelSequenceAllocator.Allocate(viewerEndpoint, link.PawnChannel), + DistrictConfig.FieldPawnControllerCharacterUid, DistrictConfig.PawnFieldMax, + new[] { characterUid }), + "REMOTE-PAWN-UID"); + + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildActorEnumByteFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + ChannelSequenceAllocator.Allocate(viewerEndpoint, link.PawnChannel), + DistrictConfig.FieldPawnGender, DistrictConfig.PawnFieldMax, + target.GetCharacterGender(), 5u), + "REMOTE-PAWN-GENDER"); + + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildActorEnumByteFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + ChannelSequenceAllocator.Allocate(viewerEndpoint, link.PawnChannel), + DistrictConfig.FieldPawnFaction, DistrictConfig.PawnFieldMax, + target.GetCharacterFaction(), 5u), + "REMOTE-PAWN-FACTION"); + + if (DistrictConfig.RemotePawnSendDescriptor() && + _pawnLifecycle.BuildPawnCompactGolemDescriptor(target, out byte[] descriptor)) + { + _handshake.SendProtectedPacket(viewerEndpoint, viewer, + PacketBuilders.BuildActorCompactGolemDescriptorFieldPacket( + viewer.AllocateServerPacketId(), link.PawnChannel, + ChannelSequenceAllocator.Allocate(viewerEndpoint, link.PawnChannel), + DistrictConfig.FieldPawnCustomisationGuids, DistrictConfig.PawnFieldMax, + descriptor), + "REMOTE-PAWN-CUSTOMISATION-GUIDS"); + } + + DistrictLogger.Log(LogLevel.Success, "District Remote Pawn", + "Opened remote pawn of account={0} on viewer={1} channels pawn={2} pri={3} at ({4:F1}, {5:F1}, {6:F1}).", + target.GetId(), viewer.GetId(), link.PawnChannel, link.PriChannel, x, y, z); + + return true; + } + + private List GetInWorldViewersOf(uint targetId) + { + var viewers = new List(); + foreach (Account candidate in AccountManager.GetAll()) + { + if (candidate.GetId() == targetId || !candidate.HasEndpoint()) + continue; + if (IsAccountInWorld(candidate.GetId())) + viewers.Add(candidate); + } + return viewers; + } + + private bool GetAccountCurrentLocation(Account account, out float x, out float y, out float z) + { + if (_controllerFeedback.TryGetMotionSnapshot(account.GetId(), out MotionSnapshot motion)) + { + x = motion.LocationX; + y = motion.LocationY; + z = motion.LocationZ; + return true; + } + + if (SelectedSpawnLocations.TryGet(account.GetId(), out float sx, out float sy, out float sz)) + { + x = sx; + y = sy; + z = sz; + return true; + } + + DistrictConfig.ReadControllerLocation(out x, out y, out z); + return true; + } + + private static IPEndPoint EndpointForAccount(Account account) + => new(EndpointAddress.FromWireValue(account.GetEndpointAddress()), account.GetEndpointPort()); +} diff --git a/DistrictServerCSharp/Program.cs b/DistrictServerCSharp/Program.cs new file mode 100644 index 0000000..f1f26a1 --- /dev/null +++ b/DistrictServerCSharp/Program.cs @@ -0,0 +1,201 @@ +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Net; +using DistrictServerCSharp.Packages; +using DistrictServerCSharp.Protocol; +using DistrictServerCSharp.World; + +namespace DistrictServerCSharp; + +public static class Program +{ + public static int Main(string[] args) + { + if (args.Length > 0 && (args[0] == "--selftest" || args[0] == "--self-test")) + { + bool ok = SelfTest.Run(out string details); + Console.WriteLine(ok ? "SELF-TEST PASS" : "SELF-TEST FAIL"); + Console.WriteLine(details); + return ok ? 0 : 1; + } + + // In-process end-to-end verification harness (scratch ports only). + // See Drive/DriveHarness.cs. Never touches the live stack. + if (args.Length > 0 && args[0] == "--drive") + return Drive.DriveHarness.Run(); + + // Exercises the cooked-package net-index resolver against a real cooked + // package (default: the unpacked 1.1 APBGame.u). Read-only. + if (args.Length > 0 && args[0] == "--resolver-check") + { + string? path = args.Length > 1 ? args[1] : null; + return RunResolverCheck(path); + } + + return RunServer(); + } + + private static int RunResolverCheck(string? explicitPath) + { + string path = explicitPath ?? DistrictConfig.ReadSetting("APB_APBGAME_PACKAGE_PATH", "APBGamePackagePath", ""); + if (string.IsNullOrEmpty(path)) + path = @"D:\apbemulatorserverstack\version_dumps\_scratch\1.1\unpacked\APBGame.u"; + + Console.WriteLine($"RESOLVER-CHECK path={path}"); + + if (!CookedPackageResolver.ParseCookedPackageSummary(path, out CookedPackageSummary apbGame)) + { + Console.WriteLine("RESOLVER-CHECK FAIL: could not parse package summary"); + return 1; + } + + if (!CookedPackageResolver.FindCookedExport(apbGame, "Default__cAPBPlayerController", out CookedExportMatch controller)) + { + Console.WriteLine("RESOLVER-CHECK FAIL: Default__cAPBPlayerController not found"); + return 1; + } + + PackageNetIndexModel model = CookedPackageResolver.CalibratePackageNetIndexModel(apbGame, controller); + if (model == PackageNetIndexModel.Unknown) + { + Console.WriteLine($"RESOLVER-CHECK FAIL: could not calibrate model (ordinal={controller.Ordinal} imports={apbGame.ImportCount})"); + return 1; + } + + uint controllerLocal = CookedPackageResolver.ApplyPackageNetIndexModel(model, apbGame, controller); + Console.WriteLine($"RESOLVER-CHECK controller ordinal={controller.Ordinal} display={controller.DisplayName} model={CookedPackageResolver.PackageNetIndexModelName(model)} localNetIndex={controllerLocal} imports={apbGame.ImportCount} names={apbGame.Names.Count} stride={apbGame.ExportStride}"); + + if (CookedPackageResolver.FindCookedExport(apbGame, "Default__cHoldableItemManager", out CookedExportMatch holdable) && + CookedPackageResolver.FindCookedExport(apbGame, "Default__cStorageInventory", out CookedExportMatch inventory)) + { + uint holdableLocal = CookedPackageResolver.ApplyPackageNetIndexModel(model, apbGame, holdable); + uint inventoryLocal = CookedPackageResolver.ApplyPackageNetIndexModel(model, apbGame, inventory); + Console.WriteLine($"RESOLVER-CHECK holdable ordinal={holdable.Ordinal} local={holdableLocal} global={DistrictConfig.GlobalNetIndex("APBGame", holdableLocal)}"); + Console.WriteLine($"RESOLVER-CHECK inventory ordinal={inventory.Ordinal} local={inventoryLocal} global={DistrictConfig.GlobalNetIndex("APBGame", inventoryLocal)}"); + } + + Console.WriteLine("RESOLVER-CHECK PASS"); + return 0; + } + + /// + /// Loads configuration, runs the wire self-test, connects to the World + /// Server, registers this district and starts the UDP listener. Shared by + /// the production entry point and the --drive verification harness. + /// Returns null when any startup step fails. + /// + public static UdpListener? Startup(Network network) + { + DistrictLogger.Clear(); + + if (!SelfTest.Run(out string selfTestDetails)) + { + DistrictLogger.Log(LogLevel.Error, "APB UDP self-test", "{0}", selfTestDetails); + return null; + } + + DistrictLogger.Log(LogLevel.Success, "APB UDP self-test", "{0}", selfTestDetails); + + DistrictConfig.AckModeValue = DistrictConfig.ReadAckMode(); + DistrictConfig.ChallengeModeValue = DistrictConfig.ReadChallengeMode(); + DistrictConfig.FixedChallenge = DistrictConfig.ReadFixedChallenge(); + + DistrictLogger.Log(LogLevel.Info, "HandshakeConfig", + "Loaded configuration from {0} (environment variables override it).", + DistrictConfig.GetHandshakeConfigPath()); + + DistrictLogger.Log(LogLevel.Info, "HandshakeConfig", + "Protection={0}, challengeMode={1}, fixedChallenge={2}", + DistrictConfig.AckModeName(DistrictConfig.AckModeValue), + DistrictConfig.ChallengeModeName(DistrictConfig.ChallengeModeValue), + DistrictConfig.FixedChallenge == 0 ? "generated" : "configured"); + + DistrictMap configuredDistrictMap = DistrictConfig.GetConfiguredDistrictMap(); + DistrictLogger.Log(LogLevel.Info, "HandshakeConfig", + "DistrictMap={0} districtType={1} welcomeLevel={2}", + DistrictConfig.DistrictMapName(configuredDistrictMap), + DistrictConfig.DistrictType(configuredDistrictMap), + DistrictConfig.DistrictWelcomeLevel(configuredDistrictMap)); + + DistrictLogger.Log(LogLevel.Info, "HandshakeConfig", + "GRI match-start replication: enabled={0} field={1} fieldMax={2} afterOpenAckDelayMs={3}", + DistrictConfig.ReadBool("APB_ENABLE_GRI_MATCH_START", "EnableGriMatchStartReplication", true) ? 1 : 0, + DistrictConfig.GriMatchHasBegunWireField(), + DistrictConfig.GriFieldMax(), + DistrictConfig.ReadInt("APB_GRI_MATCH_START_DELAY_MS", "GriMatchStartAfterOpenAckMilliseconds", 0, 0, 60000)); + + DistrictLogger.Log(LogLevel.Info, "HandshakeConfig", + "AckReliableActorPackets={0} KeepAliveAckSeconds={1} LogReliableActorTraffic={2}", + DistrictConfig.ReadBool("APB_ACK_RELIABLE_ACTOR_PACKETS", "AckReliableActorPackets", true) ? 1 : 0, + DistrictConfig.ReadInt("APB_KEEPALIVE_ACK_SECONDS", "KeepAliveAckSeconds", 5, 1, 60), + DistrictConfig.ReadBool("APB_LOG_RELIABLE_ACTOR_TRAFFIC", "LogReliableActorTraffic", true) ? 1 : 0); + + string worldServerAddress = DistrictConfig.ConfiguredWorldServerAddress(); + int worldServerPort = DistrictConfig.ConfiguredWorldServerPort(); + + if (!network.Setup(worldServerAddress, worldServerPort)) + { + DistrictLogger.Log(LogLevel.Error, "Network::Setup()", "Socket setup failed"); + return null; + } + + DistrictLogger.Log(LogLevel.Info, "Network::Setup()", "Ready to connect to World Server at {0}:{1}", worldServerAddress, worldServerPort); + + if (!network.Connect()) + { + DistrictLogger.Log(LogLevel.Error, "Network::Connect()", "Connection failed"); + return null; + } + + DistrictLogger.Log(LogLevel.Success, "Network::Connect()", "Connected to World Server"); + + var worldControl = new WorldControl(network); + + byte[] registration = WorldControl.BuildRegistration(); + if (!network.Send(registration)) + { + DistrictLogger.Log(LogLevel.Error, "Network::Send()", "District registration failed"); + return null; + } + + DistrictLogger.Log(LogLevel.Info, "Network::Send()", "Initial district registration data sent"); + + byte[]? initial = network.Receive(2); + if (initial == null || !worldControl.ProcessRegistrationResponse(initial)) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Initial packet failed to process"); + return null; + } + + var udpListener = new UdpListener(DistrictConfig.ConfiguredDistrictUdpPort()); + var udpThread = new Thread(udpListener.Run) { IsBackground = true }; + udpThread.Start(); + return udpListener; + } + + private static int RunServer() + { + using var network = new Network(); + + UdpListener? udpListener = Startup(network); + if (udpListener == null) + return 1; + + var worldControl = new WorldControl(network); + + while (true) + { + byte[]? prefixBuffer = network.Receive(1); + if (prefixBuffer == null) + break; + + if (!worldControl.ProcessWorldControlRecord(prefixBuffer[0])) + break; + } + + DistrictLogger.Log(LogLevel.Error, "main()", "WorldServer control connection ended; exiting in 5 seconds..."); + + Thread.Sleep(5000); + return 1; + } +} diff --git a/DistrictServerCSharp/Protocol/Diagnostics.cs b/DistrictServerCSharp/Protocol/Diagnostics.cs new file mode 100644 index 0000000..3700c57 --- /dev/null +++ b/DistrictServerCSharp/Protocol/Diagnostics.cs @@ -0,0 +1,72 @@ +using System.Text; + +namespace DistrictServerCSharp.Protocol; + +/// Diagnostic formatting helpers ported from ApbUdp.cpp. +public static class Diagnostics +{ + /// Uppercase, space-separated hex dump, truncated at bytes. + public static string Hex(ReadOnlySpan data, int maximum = 256) + { + if (data.Length == 0) + return string.Empty; + + int outputSize = Math.Min(data.Length, maximum); + var builder = new StringBuilder(outputSize * 3); + + for (int index = 0; index < outputSize; ++index) + { + if (index != 0) + builder.Append(' '); + builder.Append(data[index].ToString("X2")); + } + + if (outputSize < data.Length) + builder.Append(" ..."); + + return builder.ToString(); + } + + /// One-line human description of a parsed packet, matching the C++ DescribePacket. + public static string DescribePacket(Packet packet) + { + var builder = new StringBuilder(); + builder.Append($"prefix=0x{packet.Prefix:X4} packetId={packet.PacketId} payloadBits={packet.PayloadBitCount} bunches={packet.Bunches.Count}"); + + if (!packet.Valid) + builder.Append($" invalid={packet.Error}"); + + for (int index = 0; index < packet.Bunches.Count; ++index) + { + Bunch bunch = packet.Bunches[index]; + builder.Append($" | #{index}"); + + if (bunch.Kind == BunchKind.Ack) + { + builder.Append($" ACK({bunch.AckPacketId})"); + continue; + } + + builder.Append(" DATA"); + builder.Append($" open={(bunch.Open ? 1 : 0)}"); + builder.Append($" close={(bunch.Close ? 1 : 0)}"); + builder.Append($" rel={(bunch.Reliable ? 1 : 0)}"); + builder.Append($" ch={bunch.ChannelIndex}"); + builder.Append($" seq={bunch.ChannelSequence}"); + builder.Append($" type={bunch.ChannelType}"); + builder.Append($" bits={bunch.DataBitCount}"); + + foreach (string text in bunch.ControlStrings) + builder.Append($" text=[{text}]"); + + if (bunch.ChannelType == 1 && + bunch.ControlStrings.Count == 0 && + bunch.RawData.Length != 0) + { + builder.Append($" controlMessage={bunch.RawData[0]}"); + } + } + + return builder.ToString(); + } +} diff --git a/DistrictServerCSharp/Protocol/FieldDecoders.cs b/DistrictServerCSharp/Protocol/FieldDecoders.cs new file mode 100644 index 0000000..8b3979f --- /dev/null +++ b/DistrictServerCSharp/Protocol/FieldDecoders.cs @@ -0,0 +1,849 @@ +using DistrictServerCSharp.Bits; + +namespace DistrictServerCSharp.Protocol; + +/// +/// Actor-channel field decoders, ported from ApbUdp.cpp. These read client +/// RPCs and replicated fields off the cAPBPlayerController channel using the +/// live class net cache bounds (controller max 634). +/// +public static class FieldDecoders +{ + /// + /// Reads the first ClassNetCache field index from an actor-channel bunch. + /// Used for small class-specific RPC channels such as + /// cCustomisationReplicator. + /// + public static bool DecodeActorFieldIndex( + Bunch bunch, + uint fieldMax, + out uint fieldIndex, + out int parameterBits, + out string error) + { + fieldIndex = 0; + parameterBits = 0; + error = string.Empty; + + if (bunch.Kind != BunchKind.Data || + bunch.RawData.Length == 0 || + bunch.DataBitCount == 0) + { + error = "actor bunch has no data"; + return false; + } + + var reader = new BitReader(bunch.RawData, 0, bunch.DataBitCount); + + if (!reader.ReadBoundedInt(fieldMax, out fieldIndex)) + { + error = "truncated actor field index"; + return false; + } + + parameterBits = reader.Remaining; + return true; + } + + /// Decodes a CSA key-pressed RPC. + public static bool DecodeCSAKeyPressedRpc( + Bunch bunch, + uint fieldMax, + uint expectedField, + CSAKeyPressedRpc rpc, + out string error) + { + rpc.Matched = false; + rpc.InputMapping = 0; + rpc.AimRotation = 0; + rpc.CameraCollidePercent = 0.0f; + rpc.TargetByChannel = false; + rpc.TargetReference = 0; + rpc.ConsumedBits = 0; + error = string.Empty; + + if (bunch.Kind != BunchKind.Data || bunch.RawData.Length == 0) + { + error = "CSA key-pressed bunch has no data"; + return false; + } + + var reader = new BitReader(bunch.RawData, 0, bunch.DataBitCount); + + if (!reader.ReadBoundedInt(fieldMax, out uint field) || field != expectedField) + { + error = "not the expected CSA key-pressed field"; + return false; + } + + bool ReadIntParameter(out int result) + { + result = 0; + if (!reader.ReadBit(out bool present)) + return false; + uint raw = 0; + if (present && !reader.ReadBits(32, out raw)) + return false; + result = (int)raw; + return true; + } + + if (!ReadIntParameter(out rpc.InputMapping) || + !ReadIntParameter(out rpc.AimRotation)) + { + error = "truncated CSA key-pressed integer parameter"; + return false; + } + + if (!reader.ReadBit(out bool cameraPresent)) + { + error = "truncated CSA key-pressed camera parameter"; + return false; + } + uint cameraRaw = 0; + if (cameraPresent && !reader.ReadBits(32, out cameraRaw)) + { + error = "truncated CSA key-pressed camera parameter"; + return false; + } + if (cameraPresent) + rpc.CameraCollidePercent = BitConverter.UInt32BitsToSingle(cameraRaw); + + // CPF_OptionalParm object parameters serialize directly, without the + // ordinary RPC non-default bit. This is the only layout that consumes + // the captured field-343 invocation's exact 119 bits. + if (reader.Remaining > 0) + { + if (!reader.ReadBit(out rpc.TargetByChannel) || + !reader.ReadBoundedInt(rpc.TargetByChannel ? 0x3FFu : 0x80000000u, out rpc.TargetReference)) + { + error = "truncated CSA key-pressed target reference"; + return false; + } + } + + rpc.Matched = true; + rpc.ConsumedBits = reader.Tell; + return true; + } + + /// + /// Decodes a one-int actor RPC parameter: + /// SerializeInt(FieldIndex, FieldMax) + /// IntProperty non-default/presence bit + /// raw int32 value + /// APB build 3908 uses this exact shape for + /// cCustomisationReplicator.ServerSendData(int nBaseIndex). + /// + public static bool DecodeActorIntRpc( + Bunch bunch, + uint fieldMax, + uint expectedField, + out int value, + out int trailingBits, + out string error) + { + value = 0; + trailingBits = 0; + error = string.Empty; + + if (bunch.Kind != BunchKind.Data || + bunch.RawData.Length == 0 || + bunch.DataBitCount == 0) + { + error = "actor bunch has no data"; + return false; + } + + var reader = new BitReader(bunch.RawData, 0, bunch.DataBitCount); + + if (!reader.ReadBoundedInt(fieldMax, out uint fieldIndex)) + { + error = "truncated actor field index"; + return false; + } + + if (fieldIndex != expectedField) + { + error = "actor field does not match expected int RPC"; + return false; + } + + if (!reader.ReadBit(out bool present)) + { + error = "truncated int parameter presence bit"; + return false; + } + + if (!present) + { + error = "int parameter was serialized as default/absent"; + return false; + } + + if (!reader.ReadBits(32, out uint rawValue)) + { + error = "truncated int parameter value"; + return false; + } + + value = (int)rawValue; + trailingBits = reader.Remaining; + return true; + } + + // Each parameter reader returns (ok, error). The error string is threaded + // through so no static/thread-shared state is needed. + private static (bool Ok, string? Error) ReadFloatParameter(BitReader reader, string name, out bool present, out float value) + { + present = false; + value = 0.0f; + + if (!reader.ReadBit(out present)) + return (false, $"truncated {name} presence bit"); + + if (!present) + return (true, null); + + if (!reader.ReadBits(32, out uint raw)) + return (false, $"truncated {name} float"); + + value = BitConverter.UInt32BitsToSingle(raw); + + if (!float.IsFinite(value)) + return (false, $"{name} is not finite"); + + return (true, null); + } + + private static (bool Ok, string? Error) ReadByteParameter(BitReader reader, string name, out bool present, out byte value) + { + present = false; + value = 0; + + if (!reader.ReadBit(out present)) + return (false, $"truncated {name} presence bit"); + + if (!present) + return (true, null); + + if (!reader.ReadBits(8, out uint raw)) + return (false, $"truncated {name} byte"); + + value = (byte)raw; + return (true, null); + } + + private static (bool Ok, string? Error) ReadIntParameter(BitReader reader, string name, out bool present, out uint value) + { + present = false; + value = 0; + + if (!reader.ReadBit(out present)) + return (false, $"truncated {name} presence bit"); + + if (!present) + return (true, null); + + if (!reader.ReadBits(32, out value)) + return (false, $"truncated {name} int"); + + return (true, null); + } + + private static (bool Ok, string? Error) ReadCompressedVectorParameter( + BitReader reader, + string name, + out bool present, + out int x, + out int y, + out int z) + { + present = false; + x = 0; + y = 0; + z = 0; + + if (!reader.ReadBit(out present)) + return (false, $"truncated {name} presence bit"); + + if (!present) + return (true, null); + + if (!reader.ReadBoundedInt(20, out uint bitsMinusOne)) + return (false, $"truncated {name} bit width"); + + uint bits = bitsMinusOne + 1u; + + if (bits > 20u) + return (false, $"{name} has invalid bit width"); + + uint bias = 1u << (int)bits; + uint maximum = 1u << (int)(bits + 1u); + + if (!reader.ReadBoundedInt(maximum, out uint encodedX) || + !reader.ReadBoundedInt(maximum, out uint encodedY) || + !reader.ReadBoundedInt(maximum, out uint encodedZ)) + { + return (false, $"truncated {name} vector"); + } + + x = (int)encodedX - (int)bias; + y = (int)encodedY - (int)bias; + z = (int)encodedZ - (int)bias; + + return (true, null); + } + + /// + /// Fully decodes every contiguous movement RPC at the beginning of one + /// PlayerController actor bunch. This handles standalone calls and the + /// normal UE3 coalesced forms: + /// OldServerMove + ServerMove + /// OldServerMove + DualServerMove + /// FVector parameters use the build-3908 compressed-vector serializer. + /// If a non-movement field follows, it is left in TrailingBits rather than + /// being misinterpreted as part of the movement RPC. + /// + public static bool DecodeControllerMovementRpc( + Bunch bunch, + uint fieldMax, + uint dualServerMoveField, + uint oldServerMoveField, + uint serverMoveField, + ControllerMovementRpc movement, + out string error) + { + movement.Matched = false; + error = string.Empty; + + if (bunch.Kind != BunchKind.Data || + bunch.RawData.Length == 0 || + bunch.DataBitCount == 0) + { + return false; + } + + var reader = new BitReader(bunch.RawData, 0, bunch.DataBitCount); + + int firstFieldBegin = reader.Tell; + + while (reader.Remaining > 0) + { + int fieldBegin = reader.Tell; + + if (!reader.ReadBoundedInt(fieldMax, out uint fieldIndex)) + { + error = "truncated movement field index"; + return false; + } + + bool isDual = fieldIndex == dualServerMoveField; + bool isOld = fieldIndex == oldServerMoveField; + bool isServer = fieldIndex == serverMoveField; + + if (!isDual && !isOld && !isServer) + { + // A different RPC follows the movement call in this bunch. + // Preserve its full field index and parameters as trailing bits. + movement.TrailingBits = bunch.DataBitCount - fieldBegin; + movement.ConsumedBits = fieldBegin; + return movement.Matched; + } + + if (!movement.Matched) + { + movement.Matched = true; + movement.FieldIndex = fieldIndex; + movement.FieldIndexBits = reader.Tell - firstFieldBegin; + movement.ParameterBits = bunch.DataBitCount - reader.Tell; + } + + ++movement.RpcCount; + + if (isOld) + { + movement.IsOldServerMove = true; + ++movement.OldServerMoveCount; + + bool oldAccelXPresent = false; byte oldAccelX = 0; + bool oldAccelYPresent = false; byte oldAccelY = 0; + bool oldAccelZPresent = false; byte oldAccelZ = 0; + bool oldFlagsPresent = false; byte oldFlags = 0; + + (bool ok, string? err) = ReadFloatParameter(reader, "OldTimeStamp", out _, out _); + if (ok) (ok, err) = ReadByteParameter(reader, "OldAccelX", out oldAccelXPresent, out oldAccelX); + if (ok) (ok, err) = ReadByteParameter(reader, "OldAccelY", out oldAccelYPresent, out oldAccelY); + if (ok) (ok, err) = ReadByteParameter(reader, "OldAccelZ", out oldAccelZPresent, out oldAccelZ); + if (ok) (ok, err) = ReadByteParameter(reader, "OldMoveFlags", out oldFlagsPresent, out oldFlags); + + if (!ok) + { + error = err ?? "truncated OldServerMove"; + return false; + } + + movement.OldAccelerationPresent = oldAccelXPresent || oldAccelYPresent || oldAccelZPresent; + movement.OldAccelX = oldAccelX; + movement.OldAccelY = oldAccelY; + movement.OldAccelZ = oldAccelZ; + movement.OldMoveFlagsPresent = oldFlagsPresent; + movement.OldMoveFlags = oldFlags; + continue; + } + + if (isServer) + { + movement.IsServerMove = true; + ++movement.ServerMoveCount; + + bool timePresent = false; float timeStamp = 0.0f; + bool accelPresent = false; int accelX = 0, accelY = 0, accelZ = 0; + bool locationPresent = false; int locationX = 0, locationY = 0, locationZ = 0; + bool flagsPresent = false; byte flags = 0; + bool rollPresent = false; byte roll = 0; + bool viewPresent = false; uint view = 0; + + (bool ok, string? err) = ReadFloatParameter(reader, "TimeStamp", out timePresent, out timeStamp); + if (ok) (ok, err) = ReadCompressedVectorParameter(reader, "InAccel", out accelPresent, out accelX, out accelY, out accelZ); + if (ok) (ok, err) = ReadCompressedVectorParameter(reader, "ClientLoc", out locationPresent, out locationX, out locationY, out locationZ); + if (ok) (ok, err) = ReadByteParameter(reader, "MoveFlags", out flagsPresent, out flags); + if (ok) (ok, err) = ReadByteParameter(reader, "ClientRoll", out rollPresent, out roll); + if (ok) (ok, err) = ReadIntParameter(reader, "View", out viewPresent, out view); + + if (!ok) + { + error = err ?? "truncated ServerMove"; + return false; + } + + if (timePresent) + { + movement.TimeStampPresent = true; + movement.HasTimeStamp = true; + movement.TimeStamp = timeStamp; + } + + movement.AccelerationPresent = accelPresent; + movement.AccelerationX = accelX; + movement.AccelerationY = accelY; + movement.AccelerationZ = accelZ; + + movement.ClientLocationPresent = locationPresent; + movement.ClientLocationX = locationX; + movement.ClientLocationY = locationY; + movement.ClientLocationZ = locationZ; + + movement.MoveFlagsPresent = flagsPresent; + movement.MoveFlags = flags; + movement.ClientRollPresent = rollPresent; + movement.ClientRoll = roll; + movement.ViewPresent = viewPresent; + movement.View = view; + continue; + } + + // DualServerMove. Wrapped in its own block so its locals are + // siblings of the isOld/isServer blocks -- C# forbids shadowing a + // nested block's locals from the enclosing while body (C++ allowed + // it, which is why the original compiles there). + { + movement.IsDualServerMove = true; + ++movement.DualServerMoveCount; + + bool timeStamp0Present = false; float timeStamp0 = 0.0f; + bool accel0Present = false; int accel0X = 0, accel0Y = 0, accel0Z = 0; + bool pendingFlagsPresent = false; byte pendingFlags = 0; + bool view0Present = false; uint view0 = 0; + bool timePresent = false; float timeStamp = 0.0f; + bool accelPresent = false; int accelX = 0, accelY = 0, accelZ = 0; + bool locationPresent = false; int locationX = 0, locationY = 0, locationZ = 0; + bool flagsPresent = false; byte flags = 0; + bool rollPresent = false; byte roll = 0; + bool viewPresent = false; uint view = 0; + + (bool ok, string? err) = ReadFloatParameter(reader, "TimeStamp0", out timeStamp0Present, out timeStamp0); + if (ok) (ok, err) = ReadCompressedVectorParameter(reader, "InAccel0", out accel0Present, out accel0X, out accel0Y, out accel0Z); + if (ok) (ok, err) = ReadByteParameter(reader, "PendingFlags", out pendingFlagsPresent, out pendingFlags); + if (ok) (ok, err) = ReadIntParameter(reader, "View0", out view0Present, out view0); + if (ok) (ok, err) = ReadFloatParameter(reader, "TimeStamp", out timePresent, out timeStamp); + if (ok) (ok, err) = ReadCompressedVectorParameter(reader, "InAccel", out accelPresent, out accelX, out accelY, out accelZ); + if (ok) (ok, err) = ReadCompressedVectorParameter(reader, "ClientLoc", out locationPresent, out locationX, out locationY, out locationZ); + if (ok) (ok, err) = ReadByteParameter(reader, "NewFlags", out flagsPresent, out flags); + if (ok) (ok, err) = ReadByteParameter(reader, "ClientRoll", out rollPresent, out roll); + if (ok) (ok, err) = ReadIntParameter(reader, "View", out viewPresent, out view); + + if (!ok) + { + error = err ?? "truncated DualServerMove"; + return false; + } + + if (timePresent) + { + movement.TimeStampPresent = true; + movement.HasTimeStamp = true; + movement.TimeStamp = timeStamp; + } + else if (timeStamp0Present) + { + movement.TimeStampPresent = true; + movement.HasTimeStamp = true; + movement.TimeStamp = timeStamp0; + } + + // Prefer the newest move's parameters; if they are default, retain + // the first move's active input/flags for diagnostics. + movement.AccelerationPresent = accelPresent || accel0Present; + movement.AccelerationX = accelPresent ? accelX : accel0X; + movement.AccelerationY = accelPresent ? accelY : accel0Y; + movement.AccelerationZ = accelPresent ? accelZ : accel0Z; + + movement.ClientLocationPresent = locationPresent; + movement.ClientLocationX = locationX; + movement.ClientLocationY = locationY; + movement.ClientLocationZ = locationZ; + + movement.MoveFlagsPresent = flagsPresent || pendingFlagsPresent; + movement.MoveFlags = flagsPresent ? flags : pendingFlags; + movement.ClientRollPresent = rollPresent; + movement.ClientRoll = roll; + movement.ViewPresent = viewPresent || view0Present; + movement.View = viewPresent ? view : view0; + } + } + + movement.ConsumedBits = reader.Tell; + movement.TrailingBits = reader.Remaining; + + return movement.Matched; + } + + /// + /// Decodes actor-channel fields sent by the client on its + /// cAPBPlayerController channel. Field 78 is skipped using its observed + /// fixed 56-bit parameter width. Field 484 is skipped using its proven + /// 168-bit total field width inside the reliable post-stream batch. Other + /// unknown fields are returned with their field index and stop parsing + /// because their parameter layout is not yet known. Field 90 is fully + /// decoded as ServerUpdateLevelVisibility(FName PackageName, bool + /// bIsVisible). Field 371 is decoded as + /// ServerSelectSpawnZone(cPlayerCharacterSpawnZone SpawnZone). Multiple + /// recognized RPCs may be packed into one bunch. + /// + public static bool DecodeControllerActorFields( + Bunch bunch, + uint fieldMax, + uint serverUpdateLevelVisibilityField, + uint serverNotifyClientLoadedField, + uint serverSelectSpawnZoneField, + List fields, + out string error) + { + fields.Clear(); + error = string.Empty; + + if (bunch.Kind != BunchKind.Data) + { + error = "not a data bunch"; + return false; + } + + if (bunch.RawData.Length == 0 || bunch.DataBitCount == 0) + { + error = "empty actor bunch"; + return false; + } + + var reader = new BitReader(bunch.RawData, 0, bunch.DataBitCount); + + while (reader.Remaining > 0) + { + var field = new ControllerActorField { BeginBit = reader.Tell }; + + if (!reader.ReadBoundedInt(fieldMax, out uint fieldIndex)) + { + error = $"truncated field index at bit {field.BeginBit}"; + return fields.Count > 0; + } + + field.FieldIndex = fieldIndex; + + // Field 78 is emitted continuously by this client build. Every + // standalone occurrence is exactly 66 bits total: + // 10 bits bounded field index, 56 bits parameters. + // Field 484 is a separate fixed-width call embedded between the + // second and third visibility RPCs in the reliable post-stream + // batch. + const uint kKnownFrequentFixedWidthField = 78; + const int kKnownFrequentFixedWidthFieldTotalBits = 66; + + // The reliable post-stream batch is laid out as: + // field 90 artprops 356 bits + // field 90 terrain 484 bits + // field 484 168 bits total + // ten remaining field-90 visibility RPCs 2928 bits + // 356 + 484 + 168 + 2928 = 3936 exactly. + // + // Important: UE3 SerializeInt is value-dependent. Field 484's + // bounded index consumes 9 bits in this build, not 10. Therefore + // its parameters occupy 159 bits. Store total field widths and + // subtract the number of index bits actually consumed instead of + // assuming a fixed index width. + const uint kKnownPostStreamFixedWidthField = 484; + const int kKnownPostStreamFixedWidthFieldTotalBits = 168; + + // Captured immediately after the now-proven wire field 372 + // ServerNotifyClientLoaded invocation. BeginStartUpSequence then + // calls NotifyServerLfgStateChanged; the remaining invocation is + // 21 bits total in this build: + // field index 530 = 10 bits, parameters = 11 bits + const uint kKnownStartupFollowupField = 530; + const int kKnownStartupFollowupFieldTotalBits = 21; + + int fixedTotalBits = 0; + + if (fieldIndex == kKnownFrequentFixedWidthField) + fixedTotalBits = kKnownFrequentFixedWidthFieldTotalBits; + else if (fieldIndex == kKnownPostStreamFixedWidthField) + fixedTotalBits = kKnownPostStreamFixedWidthFieldTotalBits; + else if (fieldIndex == kKnownStartupFollowupField) + fixedTotalBits = kKnownStartupFollowupFieldTotalBits; + + if (fixedTotalBits != 0) + { + int indexBits = reader.Tell - field.BeginBit; + + if (indexBits > fixedTotalBits) + { + error = $"fixed-width controller field {fieldIndex} consumed {indexBits} index bits, exceeding total width {fixedTotalBits}"; + return fields.Count > 0; + } + + int fixedParameterBits = fixedTotalBits - indexBits; + + if (reader.Remaining < fixedParameterBits) + { + error = $"truncated fixed-width controller field {fieldIndex} at bit {field.BeginBit}; indexBits={indexBits} parameterBits={fixedParameterBits} remaining={reader.Remaining}"; + return fields.Count > 0; + } + + while (fixedParameterBits >= 32) + { + if (!reader.ReadBits(32, out _)) + { + error = "failed to skip fixed-width controller field parameters"; + return fields.Count > 0; + } + fixedParameterBits -= 32; + } + + if (fixedParameterBits != 0) + { + if (!reader.ReadBits(fixedParameterBits, out _)) + { + error = "failed to skip trailing fixed-width controller field parameters"; + return fields.Count > 0; + } + } + + field.EndBit = reader.Tell; + fields.Add(field); + continue; + } + + if (fieldIndex == serverNotifyClientLoadedField) + { + // Reliable server RPC with ParmsSize=0. The bounded field index + // is the complete invocation. + field.IsServerNotifyClientLoaded = true; + field.EndBit = reader.Tell; + fields.Add(field); + continue; + } + + if (fieldIndex == serverSelectSpawnZoneField) + { + // ServerSelectSpawnZone has one UObject RPC parameter. The + // first bit is the top-level non-default/presence flag; only + // then does UPackageMapLevel::SerializeObject read its selector. + if (!reader.ReadBit(out bool parameterPresent)) + { + error = "truncated SpawnZone parameter-presence bit for ServerSelectSpawnZone"; + return fields.Count > 0; + } + + bool byChannel = false; + uint reference = 0; + + if (parameterPresent) + { + if (!reader.ReadBit(out byChannel)) + { + error = "truncated object-reference selector for ServerSelectSpawnZone"; + return fields.Count > 0; + } + + if (!reader.ReadBoundedInt(byChannel ? 0x3FFu : 0x80000000u, out reference)) + { + error = "truncated SpawnZone object reference for ServerSelectSpawnZone"; + return fields.Count > 0; + } + } + + field.IsServerSelectSpawnZone = true; + field.ObjectReferenceByChannel = byChannel; + field.ObjectReferenceValue = reference; + field.EndBit = reader.Tell; + fields.Add(field); + continue; + } + + // Exact live cAPBPlayerController cache: + // 489 ServerRequestCharacterData(int nCharacterUID) + const uint kServerRequestCharacterDataField = 489; + + if (fieldIndex == kServerRequestCharacterDataField) + { + if (!reader.ReadBit(out bool characterUidPresent)) + { + error = "truncated CharacterUID presence bit for ServerRequestCharacterData"; + return fields.Count > 0; + } + + uint characterUid = 0; + if (characterUidPresent && !reader.ReadBits(32, out characterUid)) + { + error = "truncated CharacterUID for ServerRequestCharacterData"; + return fields.Count > 0; + } + + field.IsServerRequestCharacterData = true; + field.RequestedCharacterUid = (int)characterUid; + field.EndBit = reader.Tell; + fields.Add(field); + continue; + } + + // Opening PlayerInfo in build 3908 emits both requests in one + // reliable controller bunch: + // 491 ServerRequestCharacterStats(int nCharacterUID) + // 498 ServerRequestCharacterRolesData(int nCharacterUID) + // Each parameter is one RPC delta/presence bit plus int32. + const uint kServerRequestCharacterStatsField = 491; + const uint kServerRequestCharacterRolesDataField = 498; + + if (fieldIndex == kServerRequestCharacterStatsField) + { + if (!reader.ReadBit(out bool characterUidPresent)) + { + error = "truncated CharacterUID presence bit for ServerRequestCharacterStats"; + return fields.Count > 0; + } + + uint characterUid = 0; + if (characterUidPresent && !reader.ReadBits(32, out characterUid)) + { + error = "truncated CharacterUID for ServerRequestCharacterStats"; + return fields.Count > 0; + } + + field.IsServerRequestCharacterStats = true; + field.RequestedCharacterStatsUid = (int)characterUid; + field.EndBit = reader.Tell; + fields.Add(field); + continue; + } + + if (fieldIndex == kServerRequestCharacterRolesDataField) + { + if (!reader.ReadBit(out bool characterUidPresent)) + { + error = "truncated CharacterUID presence bit for ServerRequestCharacterRolesData"; + return fields.Count > 0; + } + + uint characterUid = 0; + if (characterUidPresent && !reader.ReadBits(32, out characterUid)) + { + error = "truncated CharacterUID for ServerRequestCharacterRolesData"; + return fields.Count > 0; + } + + field.IsServerRequestCharacterRolesData = true; + field.RequestedCharacterRolesUid = (int)characterUid; + field.EndBit = reader.Tell; + fields.Add(field); + continue; + } + + if (fieldIndex != serverUpdateLevelVisibilityField) + { + field.EndBit = reader.Tell; + fields.Add(field); + + error = $"unknown controller field {fieldIndex} at bit {field.BeginBit}; parameter size is unknown"; + return true; + } + + if (!reader.ReadBit(out bool stringNameFollows)) + { + error = "truncated FName selector for ServerUpdateLevelVisibility"; + return false; + } + + if (stringNameFollows) + { + if (!reader.ReadFString(out field.PackageName)) + { + error = "invalid FString package name for ServerUpdateLevelVisibility"; + return false; + } + } + else + { + field.PackageName = "None"; + } + + if (!reader.ReadBit(out bool visible)) + { + error = "truncated bIsVisible for ServerUpdateLevelVisibility"; + return false; + } + + field.IsServerUpdateLevelVisibility = true; + field.IsVisible = visible; + field.EndBit = reader.Tell; + + fields.Add(field); + } + + return fields.Count > 0; + } + + /// Reads the leading message type byte and payload of a binary control message. + public static bool ReadBinaryControlMessage(Bunch bunch, out byte messageType, out byte[] payload) + { + messageType = 0; + payload = Array.Empty(); + + if (bunch.Kind != BunchKind.Data || + bunch.ChannelType != 1 || + bunch.DataBitCount < 8 || + bunch.RawData.Length == 0) + { + return false; + } + + messageType = bunch.RawData[0]; + int payloadBytes = bunch.DataBitCount / 8; + + if (payloadBytes > 1) + { + payload = new byte[payloadBytes - 1]; + Array.Copy(bunch.RawData, 1, payload, 0, payload.Length); + } + + return true; + } +} diff --git a/DistrictServerCSharp/Protocol/Models.cs b/DistrictServerCSharp/Protocol/Models.cs new file mode 100644 index 0000000..36f7083 --- /dev/null +++ b/DistrictServerCSharp/Protocol/Models.cs @@ -0,0 +1,246 @@ +namespace DistrictServerCSharp.Protocol; + +/// Kind of a UE3 bunch inside a packet. +public enum BunchKind +{ + Data, + Ack +} + +/// +/// One bunch inside a UE3 packet, ported from ApbUdp.h. The C++ structs are +/// mutated during parsing, so these are mutable classes rather than records. +/// +public sealed class Bunch +{ + public BunchKind Kind = BunchKind.Data; + public uint AckPacketId; + public bool Open; + public bool Close; + public bool Reliable; + public ushort ChannelIndex; + public ushort ChannelSequence; + public byte ChannelType; + public ushort DataBitCount; + public int DataBitOffset; + public byte[] RawData = Array.Empty(); + public List ControlStrings = new(); +} + +/// A parsed UE3 packet. +public sealed class Packet +{ + public bool Valid; + public ushort Prefix; + public uint PacketId; + public int PayloadBitCount; + public List Bunches = new(); + public string? Error; +} + +/// Parsed AUTH control string. +public sealed class AuthCommand +{ + public bool Valid; + public uint AccountId; + public string AuthKeyText = ""; + public byte[] AuthKey = new byte[20]; + public string? Error; +} + +/// +/// cGolemTypes.CompactGolemDescriptor as declared by APBGame.u: +/// +0x00 Guid m_CharacterGuid +/// +0x10 Guid m_StatueGuid +/// +0x20 Guid m_AudioGUID +/// Each UE3 FGuid is four little-endian uint32 values. +/// +public sealed class CompactGolemDescriptor +{ + public byte[] Bytes = new byte[48]; +} + +/// +/// cAPBPlayerController.CharacterData, reflected size 108 bytes: +/// int m_aCharacterFnMods[4] +/// int m_nWeaponPrimary +/// int m_aWeaponPrimaryFnMods[3] +/// int m_nWeaponSecondary +/// int m_aWeaponSecondaryFnMods[3] +/// int m_nWeaponGrenade +/// FString m_sGraffitiSymbolName +/// FString m_sThemeName +/// FGuid m_nGraffitiCustomisationGuid +/// FGuid m_nThemeGuid +/// +public sealed class CharacterDataPayload +{ + public int[] CharacterFnMods = new int[4]; + public int WeaponPrimary; + public int[] WeaponPrimaryFnMods = new int[3]; + public int WeaponSecondary; + public int[] WeaponSecondaryFnMods = new int[3]; + public int WeaponGrenade; + public string GraffitiSymbolName = ""; + public string ThemeName = ""; + public uint[] GraffitiCustomisationGuid = new uint[4]; + public uint[] ThemeGuid = new uint[4]; +} + +/// cCharacterScorer.CharacterStats, reflected size 36 bytes. +public sealed class CharacterStatsPayload +{ + public float TotalTimeInSeconds; + public int TotalKills; + public float SessionTimeInSeconds; + public int SessionKills; + public int SessionMissionsWon; + public int SessionMissionsLost; + public int SessionPlayersArrested; + public int SessionPlayersFreed; + public int SessionMedals; +} + +/// cAPBPlayerController.CharacterRolesData is one fixed byte[99]. +public sealed class CharacterRolesDataPayload +{ + public byte[] RoleMilestones = new byte[99]; +} + +/// Wire modes for the fixed byte[256] array in ClientReceiveData. +public enum FixedByteArrayWireMode +{ + PerElementDelta, + SinglePresenceRaw, + Raw +} + +/// +/// Network form of APBGame.cHUDMarkerManager.HUDMarkerData. UObject pointers +/// are represented through the package map rather than copied from the local +/// 32-byte script struct. A zero reference serializes null. +/// +public sealed class HUDMarkerWireData +{ + public bool LinkedActorByChannel; + public uint LinkedActorReference; + + public float LocationX; + public float LocationY; + public float LocationZ; + + public byte OffsetOverride; + public byte AutoRouteData; + public byte Type; + public byte State; + public bool IsBeingModified; + public int UserData; + public int UserData2; + public int ServerMarkerId; + + // APBGame.u metadata: OffsetOverride, AutoRouteData and Type are unbacked + // ByteProperties (raw 8 bits); only State is enum-backed and uses + // SerializeInt(value, 19). RawByteEncoding makes State raw too. + public bool RawByteEncoding; + // HUDMarkerData.Location resolves to Core.Vector. Its UE3 NetSerializeItem + // path uses FVector compressed network serialization. + public bool CompressedLocation = true; + // RPC parameter presence is now written unconditionally by the builder. + public bool WriteStructPresenceBit = true; + // The first three byte fields are metadata-proven raw bytes; unused. + public uint OffsetOverrideMax = 2; + public uint AutoRouteDataMax = 4; + public uint TypeMax = 256; + public uint StateMax = 19; +} + +/// +/// Fully decoded movement RPCs at the beginning of one PlayerController actor +/// bunch. See DecodeControllerMovementRpc. +/// +public sealed class ControllerMovementRpc +{ + public bool Matched; + + public bool IsDualServerMove; + public bool IsOldServerMove; + public bool IsServerMove; + + public uint FieldIndex; + public int FieldIndexBits; + public int ParameterBits; + public int ConsumedBits; + public int TrailingBits; + + public uint RpcCount; + public uint DualServerMoveCount; + public uint OldServerMoveCount; + public uint ServerMoveCount; + + public bool TimeStampPresent; + public bool HasTimeStamp; + public float TimeStamp; + + public bool AccelerationPresent; + public int AccelerationX; + public int AccelerationY; + public int AccelerationZ; + + public bool ClientLocationPresent; + public int ClientLocationX; + public int ClientLocationY; + public int ClientLocationZ; + + public bool MoveFlagsPresent; + public byte MoveFlags; + + public bool ClientRollPresent; + public byte ClientRoll; + + public bool ViewPresent; + public uint View; + + public bool OldAccelerationPresent; + public byte OldAccelX; + public byte OldAccelY; + public byte OldAccelZ; + public bool OldMoveFlagsPresent; + public byte OldMoveFlags; +} + +/// One decoded controller actor-channel field. +public sealed class ControllerActorField +{ + public uint FieldIndex; + public int BeginBit; + public int EndBit; + + public bool IsServerUpdateLevelVisibility; + public bool IsServerNotifyClientLoaded; + public bool IsServerSelectSpawnZone; + public bool IsServerRequestCharacterData; + public bool IsServerRequestCharacterStats; + public bool IsServerRequestCharacterRolesData; + + public int RequestedCharacterUid; + public int RequestedCharacterStatsUid; + public int RequestedCharacterRolesUid; + + public bool ObjectReferenceByChannel; + public uint ObjectReferenceValue; + + public string PackageName = ""; + public bool IsVisible; +} + +/// Decoded CSA key-pressed RPC. +public sealed class CSAKeyPressedRpc +{ + public bool Matched; + public int InputMapping; + public int AimRotation; + public float CameraCollidePercent; + public bool TargetByChannel; + public uint TargetReference; + public int ConsumedBits; +} diff --git a/DistrictServerCSharp/Protocol/PacketBuilders.cs b/DistrictServerCSharp/Protocol/PacketBuilders.cs new file mode 100644 index 0000000..975c45c --- /dev/null +++ b/DistrictServerCSharp/Protocol/PacketBuilders.cs @@ -0,0 +1,1240 @@ +using System.Text; +using DistrictServerCSharp.Bits; + +namespace DistrictServerCSharp.Protocol; + +/// +/// UE3 packet builders, ported from ApbUdp.cpp. Each function produces a +/// complete datagram (packet id + bunches + trailer) that the client's +/// UNetConnection/UActorChannel parses as the corresponding control message, +/// actor-channel open, replicated property or RPC. +/// +public static class PacketBuilders +{ + private static void WriteReliableControlBunch(BitWriter writer, ushort channelSequence, ReadOnlySpan data) + { + // Data bunch, no open/close flags. The client already opened channel 0 + // with the APB AUTH FString. + writer.WriteBit(false); // not ACK + writer.WriteBit(false); // no open/close control flags + writer.WriteBit(true); // reliable + writer.WriteBits(0, 10); // control channel index + writer.WriteBits((uint)(channelSequence % 1024u), 10); + writer.WriteBits(1, 3); // CHTYPE_Control + writer.WriteBits((uint)(data.Length * 8u), 12); + writer.WriteBytes(data); + } + + private static byte[] BuildControlPacketInternal( + ushort prefix, + uint serverPacketId, + bool includeAck, + uint acknowledgedPacketId, + ushort channelSequence, + ReadOnlySpan data) + { + var writer = new BitWriter(); + // 30-bit packet id at bit 0 (see BuildAckPacket). `prefix` is ignored. + _ = prefix; + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + + if (includeAck) + { + writer.WriteBit(true); + writer.WriteBit(true); + writer.WriteBits(acknowledgedPacketId & 0x3FFFFFFFu, 30); + } + + WriteReliableControlBunch(writer, channelSequence, data); + + return writer.FinishWithTrailer(); + } + + /// + /// ACK packet: 30-bit packet id at bit 0, then an ack bunch + /// [IsAck=1][bHasId=1][30-bit ack id], plus the terminator bit. + /// + public static byte[] BuildAckPacket(ushort prefix, uint serverPacketId, uint acknowledgedPacketId) + { + var writer = new BitWriter(); + // Verified against the client's UNetConnection::ReceivedPacket: + // PacketId = ReadInt(0x40000000) -> a 30-bit field starting at bit 0 + // What older code modelled as a 16-bit "prefix" followed by a 14-bit id + // is really this single 30-bit field. `prefix` is ignored. + _ = prefix; + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + + // An ack bunch is [IsAck=1][bHasId][if bHasId: ReadInt(0x40000000)]. + // The old 14-bit ack id was 16 bits short and shifted everything that + // followed it, which is why challenge bunches never parsed. + writer.WriteBit(true); + writer.WriteBit(true); + writer.WriteBits(acknowledgedPacketId & 0x3FFFFFFFu, 30); + return writer.FinishWithTrailer(); + } + + public static byte[] BuildAckAndBinaryControlPacket( + ushort prefix, + uint serverPacketId, + uint acknowledgedPacketId, + ushort channelSequence, + byte messageType, + ReadOnlySpan payload) + { + var message = new List(payload.Length + 1) { messageType }; + message.AddRange(payload.ToArray()); + + return BuildControlPacketInternal( + prefix, + serverPacketId, + false, + acknowledgedPacketId, + channelSequence, + message.ToArray()); + } + + public static byte[] BuildBinaryControlPacket( + ushort prefix, + uint serverPacketId, + ushort channelSequence, + byte messageType, + ReadOnlySpan payload) + { + var message = new List(payload.Length + 1) { messageType }; + message.AddRange(payload.ToArray()); + + return BuildControlPacketInternal( + prefix, + serverPacketId, + false, + 0, + channelSequence, + message.ToArray()); + } + + public static byte[] BuildAckAndTextControlPacket( + ushort prefix, + uint serverPacketId, + uint acknowledgedPacketId, + ushort channelSequence, + string text) + { + int serializedLength = text.Length + 1; + + var message = new byte[4 + serializedLength]; + message[0] = (byte)serializedLength; + message[1] = (byte)(serializedLength >> 8); + message[2] = (byte)(serializedLength >> 16); + message[3] = (byte)(serializedLength >> 24); + Encoding.ASCII.GetBytes(text, 0, text.Length, message, 4); + + return BuildControlPacketInternal( + prefix, + serverPacketId, + false, + acknowledgedPacketId, + channelSequence, + message); + } + + /// + /// The APB control protocol is TEXT based. The client's + /// UNetPendingLevel::NotifyReceivedText reads each control bunch as an + /// FString and matches command words (UPGRADE, USES, UNLOAD, FAILURE, + /// USERFLAG, CHALLENGE, DLMGR, WELCOME). There are no binary NMT opcodes on + /// this build, so the handshake challenge must be sent as the FString + /// "CHALLENGE VER=<ver> CHALLENGE=<value>" + /// with no ack bunch in front of it (an ack would shift the string and the + /// command word would never match). + /// + public static byte[] BuildTextControlPacket( + ushort prefix, + uint serverPacketId, + ushort channelSequence, + string text) + { + int serializedLength = text.Length + 1; + + var message = new byte[4 + serializedLength]; + message[0] = (byte)serializedLength; + message[1] = (byte)(serializedLength >> 8); + message[2] = (byte)(serializedLength >> 16); + message[3] = (byte)(serializedLength >> 24); + Encoding.ASCII.GetBytes(text, 0, text.Length, message, 4); + + return BuildControlPacketInternal( + prefix, + serverPacketId, + false, + 0, + channelSequence, + message); + } + + /// + /// Actor channel open bunch. UActorChannel::ReceivedBunch, when the channel + /// has no actor yet, requires bOpen and then reads a single object + /// reference through UPackageMapLevel::SerializeObject. It spawns + /// NewActor->Class using that object as the archetype, and if the result's + /// NetPlayerIndex is 0 it binds the actor to the connection's local player. + /// + /// Bunch header layout (from UNetConnection::ReceivedPacket): + /// [IsAck=0][bControl=1][bOpen=1][bClose=0][bReliable=1] + /// [ChIndex SerializeInt(0x3FF)] + /// [ChSequence SerializeInt(0x400)] (reliable) + /// [ChType SerializeInt(8)] (reliable or open) + /// [BunchDataBits SerializeInt(MaxPacket*8)] + /// [payload] + /// + public static byte[] BuildActorOpenPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint archetypeNetIndex, + float spawnX, + float spawnY, + float spawnZ) + { + // Serialise the archetype reference on its own so the exact bit count + // is known before the bunch header is written. The client reads a + // compressed spawn location straight after the reference and passes it + // to SpawnActor, so it has to be here -- omitting it made the client + // read past the end and spawn at (-2,-2,-2), which SpawnActor rejects. + var payload = new BitWriter(); + payload.WriteObjectByNetIndex(archetypeNetIndex); + payload.WriteCompressedVector(spawnX, spawnY, spawnZ); + byte[] payloadBytes = payload.Snapshot(); + int payloadBits = payload.BitCount; + + var writer = new BitWriter(); + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + + writer.WriteBit(false); // not an ack + writer.WriteBit(true); // bControl: open/close flags follow + writer.WriteBit(true); // bOpen + writer.WriteBit(false); // bClose + writer.WriteBit(true); // bReliable + + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt(channelSequence, 0x400u); + writer.WriteBoundedInt(2u, 8u); // CHTYPE_Actor + + writer.WriteBoundedInt((uint)payloadBits, 512u * 8u); + + writer.WriteBitsFrom(payloadBytes, payloadBits); + + return writer.FinishWithTrailer(); + } + + public static byte[] BuildActorClosePacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence) + { + var writer = new BitWriter(); + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + writer.WriteBit(false); // not an ack + writer.WriteBit(true); // bControl + writer.WriteBit(false); // bOpen + writer.WriteBit(true); // bClose + writer.WriteBit(true); // bReliable + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt(channelSequence, 0x400u); + writer.WriteBoundedInt(2u, 8u); // CHTYPE_Actor + writer.WriteBoundedInt(0u, 512u * 8u); + return writer.FinishWithTrailer(); + } + + private static void WriteActorBunchHeader( + BitWriter writer, + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + int payloadBits) + { + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + writer.WriteBit(false); // not an ack + writer.WriteBit(false); // no open/close flags: channel exists + writer.WriteBit(true); // reliable + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt(channelSequence, 0x400u); + writer.WriteBoundedInt(2u, 8u); // CHTYPE_Actor + writer.WriteBoundedInt((uint)payloadBits, 512u * 8u); + } + + /// + /// One replicated ObjectProperty carrying a single object reference, + /// e.g. Controller.Pawn or Pawn.Controller. RPC parameters use the separate + /// builder below because they have a top-level presence bit. + /// + public static byte[] BuildActorObjectFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + ushort referencedChannel) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteObjectByChannel(referencedChannel); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + public static byte[] BuildActorObjectRpcPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + ushort referencedChannel, + int trailingDefaultParameterCount = 0) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + bool objectPresent = referencedChannel != 0; + payload.WriteBit(objectPresent); + if (objectPresent) + payload.WriteObjectByChannel(referencedChannel); + + // Each omitted/default RPC parameter contributes one false + // non-default marker. This is needed by functions such as: + // ClientSetViewTarget(Actor A, + // optional ViewTargetTransitionParams TransitionParams) + for (int index = 0; index < trailingDefaultParameterCount; ++index) + payload.WriteBit(false); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// One-int RPC parameter (e.g. cCustomisationReplicator.ServerSendData(int + /// nBaseIndex)): SerializeInt(fieldIndex, fieldMax) + IntProperty + /// non-default/presence bit + raw int32 value. Mirrors the wire shape the + /// client writes and FieldDecoders.DecodeActorIntRpc reads. + /// + public static byte[] BuildActorIntRpcPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + int value) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteBit(true); // non-default presence bit + payload.WriteBits((uint)value, 32); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + public static byte[] BuildActorDefaultRpcPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + int parameterCount) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + for (int index = 0; index < parameterCount; ++index) + payload.WriteBit(false); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// Calls PlayerController.ClientGotoState(NewState, NAME_None). + public static byte[] BuildClientGotoStatePacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + string stateName) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + // ClientGotoState(name NewState, optional name NewLabel). + // + // APB's UPackageMap::SerializeName FString path derives the FName + // number from the transmitted string. Do not append a raw int32 here: + // doing so is not part of the proven wire format used by this build. + payload.WriteName(stateName); + payload.WriteBit(false); // NewLabel = NAME_None + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// Sends an unreliable client RPC carrying one raw float parameter. + /// Used for PlayerController.ClientAckGoodMove(TimeStamp). + /// + public static byte[] BuildUnreliableActorFloatFieldPacket( + uint serverPacketId, + ushort channelIndex, + uint fieldIndex, + uint fieldMax, + float value) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + // RPC float parameter outer non-default/presence bit. + payload.WriteBit(true); + + payload.WriteBits(BitConverter.SingleToUInt32Bits(value), 32); + + // Unreliable bunch on an already-open actor channel: + // PacketId, IsAck=0, no open/close flags, Reliable=0, + // ChannelIndex, DataBits, Payload. + var writer = new BitWriter(); + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + writer.WriteBit(false); + writer.WriteBit(false); + writer.WriteBit(false); + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt((uint)payload.BitCount, 512u * 8u); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + + return writer.FinishWithTrailer(); + } + + /// + /// One field carrying a list of 32-bit integers, e.g. + /// Receive_DS2GC_ANS_DISTRICT_ENTER(returnCode, districtUID, instanceNo). + /// + public static byte[] BuildActorIntFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + ReadOnlySpan values) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + foreach (int value in values) + payload.WriteBits((uint)value, 32); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// Replicated enum-backed ByteProperty. Replicated properties serialize + /// directly after the field index; unlike RPC parameters, there is no + /// non-default/presence bit. + /// + public static byte[] BuildActorEnumByteFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + byte value, + uint enumValueCount) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteBoundedInt(value, Math.Max(enumValueCount, 2u)); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cGolemTypes.CompactGolemDescriptor as declared by APBGame.u: three Guid + /// StructProperties at offsets 0x00, 0x10 and 0x20. Each UE3 FGuid is four + /// little-endian uint32 values. This is a replicated StructProperty, not an + /// RPC parameter, so no outer presence/default bit is written. Total + /// property data: 384 bits. + /// + public static byte[] BuildActorCompactGolemDescriptorFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + byte[] descriptor) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + for (int dwordIndex = 0; dwordIndex < 12; ++dwordIndex) + { + int offset = dwordIndex * 4; + uint value = (uint)descriptor[offset] | + ((uint)descriptor[offset + 1] << 8) | + ((uint)descriptor[offset + 2] << 16) | + ((uint)descriptor[offset + 3] << 24); + payload.WriteBits(value, 32); + } + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// Replicated fixed-size native StructProperty/raw field. Retained for + /// unrelated experiments; CompactGolemDescriptor uses the explicit + /// three-FGuid builder above. + /// + public static byte[] BuildActorRawFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + ReadOnlySpan value, + bool writeStructPresenceBit) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + if (writeStructPresenceBit) + payload.WriteBit(value.Length != 0); + + if (value.Length != 0) + payload.WriteBytes(value); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// One replicated BoolProperty. UE3 serializes a network bool as a single + /// payload bit after its ClassNetCache field index. + /// + public static byte[] BuildActorBoolFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + bool value) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteBit(value); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// One field carrying an FVector/Vector StructProperty. + /// + /// =false writes three IEEE-754 floats, which + /// is the likely StructProperty(Vector) representation. =true uses the same + /// FVector::SerializeCompressed encoding already proven for actor spawn + /// locations. The latter is retained as an experimental switch because APB + /// may use native vector NetSerialize for this property. + /// + public static byte[] BuildActorVectorFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + float x, + float y, + float z, + bool compressed) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + if (compressed) + { + payload.WriteCompressedVector(x, y, z); + } + else + { + payload.WriteBits(BitConverter.SingleToUInt32Bits(x), 32); + payload.WriteBits(BitConverter.SingleToUInt32Bits(y), 32); + payload.WriteBits(BitConverter.SingleToUInt32Bits(z), 32); + } + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// Unreliable counterpart of used + /// for high-cadence remote-pawn updates (Actor field 4 velocity / field 6 + /// location). No channel sequence and no channel type: at ~30 Hz reliable + /// bunches overflow the client's channel and the remote pawn silently + /// disappears. Header: PacketId, IsAck=0, no open/close flags, Reliable=0, + /// ChannelIndex, DataBits, payload. + /// + public static byte[] BuildUnreliableActorVectorFieldPacket( + uint serverPacketId, + ushort channelIndex, + uint fieldIndex, + uint fieldMax, + float x, + float y, + float z) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteCompressedVector(x, y, z); + + var writer = new BitWriter(); + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + writer.WriteBit(false); // not an ack + writer.WriteBit(false); // no open/close flags + writer.WriteBit(false); // unreliable + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt((uint)payload.BitCount, 512u * 8u); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// Unreliable high-cadence remote-pawn rotation (Actor field 5). Same + /// sequence-less header as the unreliable vector builder; the payload uses + /// the compressed-rotator encoding (pitch/yaw/roll high bytes). + /// + public static byte[] BuildUnreliableActorRotatorFieldPacket( + uint serverPacketId, + ushort channelIndex, + uint fieldIndex, + uint fieldMax, + int pitch, + int yaw, + int roll) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteCompressedRotator(pitch, yaw, roll); + + var writer = new BitWriter(); + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + writer.WriteBit(false); // not an ack + writer.WriteBit(false); // no open/close flags + writer.WriteBit(false); // unreliable + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt((uint)payload.BitCount, 512u * 8u); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// ClientUpdateLevelStreamingStatus(name PackageName, bool bShouldBeLoaded, + /// bool bShouldBeVisible, bool bBlockOnLoad). USES only registers a package + /// with the package map; this is what makes the client actually stream a + /// sublevel in. Without it the persistent map comes up but no block geometry + /// is ever added to the world, which is why the client sees only skydome and + /// SpawnActor finds nowhere valid to spawn. + /// + public static byte[] BuildLevelStreamingStatusPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + string packageName, + bool shouldBeLoaded, + bool shouldBeVisible, + bool blockOnLoad, + bool nameIncludesNumber, + int boolCount) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteName(packageName, 0, nameIncludesNumber); + + // The parameter tail is configurable because the client consumes fewer + // bits than we write: leftover bits decode as a second field index + // (field 88, ClientForceGarbageCollection, sits right next to 89) which + // triggers a GC that unloads the level that just streamed in. That is + // the one-frame flash of geometry. + var values = new[] { shouldBeLoaded, shouldBeVisible, blockOnLoad, false }; + for (int i = 0; i < boolCount && i < 4; ++i) + payload.WriteBit(values[i]); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// Engine.PlayerController.ClientSetHUD(class<HUD> newHUDType, + /// class<Scoreboard> newScoringType). Both parameters are ClassProperty + /// values and therefore use UPackageMapLevel::SerializeObject. A null + /// scoreboard is encoded as the package-map None reference (global net + /// index zero). RPC ClassProperty/ObjectProperty parameters have an outer + /// non-null/non-default bit before the package-map object reference. + /// + public static byte[] BuildClientSetHudPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + uint hudClassNetIndex, + uint scoringClassNetIndex) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + payload.WriteBit(hudClassNetIndex != 0u); + if (hudClassNetIndex != 0u) + payload.WriteObjectByNetIndex(hudClassNetIndex); + + payload.WriteBit(scoringClassNetIndex != 0u); + if (scoringClassNetIndex != 0u) + payload.WriteObjectByNetIndex(scoringClassNetIndex); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cAPBPlayerController.ClientSetInitialState(int nCharacterUID, byte + /// Faction, byte Gender). Reflection on build 3908 proves a six-byte + /// in-memory parameter struct. Every top-level RPC parameter has a + /// non-default/presence bit: + /// nCharacterUID present-bit + int32 + /// Faction present-bit + SerializeInt(value, 5) + /// Gender present-bit + SerializeInt(value, 5) + /// + public static byte[] BuildClientSetInitialStatePacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + int characterUid, + byte faction, + byte gender) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + bool characterUidPresent = characterUid != 0; + payload.WriteBit(characterUidPresent); + if (characterUidPresent) + payload.WriteBits((uint)characterUid, 32); + + // Both ByteProperty parameters reference five-entry UEnums: + // etFaction: None, Enforcer, Criminal, Both, MAX + // etGender: None, Male, Female, Both, MAX + const uint kFactionEnumCount = 5; + const uint kGenderEnumCount = 5; + + payload.WriteBit(faction != 0u); + if (faction != 0u) + payload.WriteBoundedInt(faction, kFactionEnumCount); + + payload.WriteBit(gender != 0u); + if (gender != 0u) + payload.WriteBoundedInt(gender, kGenderEnumCount); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cAPBPlayerController.ClientReceiveCharacterInfo( + /// cGameInfoCache.CharacterInfoPacket packet) + /// + /// Build-3908 reflection: CharacterInfoPacket property size = 45 bytes, + /// function parameter size = 48 bytes (tail padding). + /// 0x00 int m_nAccountUID + /// 0x04 int m_nCharacterUID + /// 0x08 int m_nClanUID + /// 0x0C int m_nGroupID + /// 0x10 int m_nSideID + /// 0x14 FString m_sCharacterName + /// 0x20 FString m_sClanName + /// 0x2C byte m_eFaction (cSDD.etFaction) + /// As with the proven HUDMarkerData RPC, the StructProperty parameter has + /// one top-level non-default bit. Its members are then serialized directly + /// in reflected offset order; there are no per-member delta bits. + /// + public static byte[] BuildClientReceiveCharacterInfoPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + int accountUid, + int characterUid, + int clanUid, + int groupId, + int sideId, + string characterName, + string clanName, + byte faction, + uint factionValueMax) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + payload.WriteBit(true); // packet StructProperty present + + payload.WriteBits((uint)accountUid, 32); + payload.WriteBits((uint)characterUid, 32); + payload.WriteBits((uint)clanUid, 32); + payload.WriteBits((uint)groupId, 32); + payload.WriteBits((uint)sideId, 32); + + payload.WriteFString(characterName, true); + payload.WriteFString(clanName, true); + + payload.WriteBoundedInt(faction, Math.Max(factionValueMax, 2u)); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cAPBPlayerController.ClientReceiveCharacterData( + /// CharacterData playerCharacterData) + /// + /// Build-3908 reflection: CharacterData property size = 108 bytes, + /// function parameter size = 108 bytes. Like CharacterInfoPacket, the RPC + /// has one top-level StructProperty non-default bit and then direct member + /// serialization in offset order. + /// + public static byte[] BuildClientReceiveCharacterDataPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + CharacterDataPayload data, + bool explicitPayload) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + payload.WriteBit(explicitPayload); + + if (explicitPayload) + { + foreach (int value in data.CharacterFnMods) + payload.WriteBits((uint)value, 32); + + payload.WriteBits((uint)data.WeaponPrimary, 32); + + foreach (int value in data.WeaponPrimaryFnMods) + payload.WriteBits((uint)value, 32); + + payload.WriteBits((uint)data.WeaponSecondary, 32); + + foreach (int value in data.WeaponSecondaryFnMods) + payload.WriteBits((uint)value, 32); + + payload.WriteBits((uint)data.WeaponGrenade, 32); + + payload.WriteFString(data.GraffitiSymbolName, true); + payload.WriteFString(data.ThemeName, true); + + foreach (uint value in data.GraffitiCustomisationGuid) + payload.WriteBits(value, 32); + + foreach (uint value in data.ThemeGuid) + payload.WriteBits(value, 32); + } + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cAPBPlayerController.ClientReceiveCharacterStats( + /// CharacterStats playerCharacterStats) + /// + /// Reflected struct layout: + /// 0x00 float m_fTotalTimeInSeconds + /// 0x04 int m_nTotalKills + /// 0x08 float m_fSessionTimeInSeconds + /// 0x0C int m_nSessionKills + /// 0x10 int m_nSessionMissionWon + /// 0x14 int m_nSessionMissionLost + /// 0x18 int m_nSessionPlayerArrested + /// 0x1C int m_nSessionPlayerFreed + /// 0x20 int m_nSessionMedals + /// + public static byte[] BuildClientReceiveCharacterStatsPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + CharacterStatsPayload stats, + bool explicitPayload) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteBit(explicitPayload); + + if (explicitPayload) + { + payload.WriteBits(BitConverter.SingleToUInt32Bits(stats.TotalTimeInSeconds), 32); + payload.WriteBits((uint)stats.TotalKills, 32); + payload.WriteBits(BitConverter.SingleToUInt32Bits(stats.SessionTimeInSeconds), 32); + payload.WriteBits((uint)stats.SessionKills, 32); + payload.WriteBits((uint)stats.SessionMissionsWon, 32); + payload.WriteBits((uint)stats.SessionMissionsLost, 32); + payload.WriteBits((uint)stats.SessionPlayersArrested, 32); + payload.WriteBits((uint)stats.SessionPlayersFreed, 32); + payload.WriteBits((uint)stats.SessionMedals, 32); + } + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cAPBPlayerController.ClientReceiveCharacterRolesData( + /// CharacterRolesData RolesData). The only struct member is a fixed + /// byte[99]. APB's RPC serializer emits one StructProperty presence bit + /// followed by the 99 raw array elements. + /// + public static byte[] BuildClientReceiveCharacterRolesDataPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + CharacterRolesDataPayload roles, + bool explicitPayload) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteBit(explicitPayload); + + if (explicitPayload) + { + foreach (byte value in roles.RoleMilestones) + payload.WriteBits(value, 8); + } + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cAPBPlayerController.ClientPrecacheCustomisation( + /// Guid TheGuid, cEnums.etPlayerCustomisation eType, bool bLocalPlayer) + /// + /// The live build-3908 ClassNetCache maps this RPC to field 279. + /// Reflection/decompilation proves a 16-byte Guid, a ByteProperty enum, + /// and a BoolProperty. The Guid is a non-default StructProperty parameter: + /// one presence bit followed by FGuid A/B/C/D. Character customisation is + /// enum value zero, so its top-level delta marker is false. bLocalPlayer + /// is true and therefore serialises as one true bit. + /// + public static byte[] BuildClientPrecacheCustomisationPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + uint[] guid, + byte customisationType, + bool localPlayer) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + bool guidPresent = guid[0] != 0u || guid[1] != 0u || guid[2] != 0u || guid[3] != 0u; + + payload.WriteBit(guidPresent); + if (guidPresent) + { + payload.WriteBits(guid[0], 32); + payload.WriteBits(guid[1], 32); + payload.WriteBits(guid[2], 32); + payload.WriteBits(guid[3], 32); + } + + // cEnums.etPlayerCustomisation: 0 character, 1 vehicle, 2 graffiti. + // Value zero is the default and is represented by a false delta bit. + payload.WriteBit(customisationType != 0u); + if (customisationType != 0u) + { + const uint kCustomisationEnumCount = 4; + payload.WriteBoundedInt(customisationType, kCustomisationEnumCount); + } + + // BoolProperty RPC parameter: its network value is the delta bit. + payload.WriteBit(localPlayer); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// APBGame.cCustomisationReplicator.ClientReceiveData( + /// int nCount, byte packet[256]) + /// + /// Reflection on build 3908 proves a 260-byte parameter struct: + /// 0x000 int32 nCount + /// 0x004 byte packet[256] + /// + /// A static array is one reflected UByteProperty with ArrayDim=256. APB's + /// RPC parameter serializer writes one non-default marker for that property + /// and then serializes all 256 array elements contiguously: + /// nCount non-default bit + int32 + /// packet property non-default bit + /// packet[256] raw bytes + /// + /// v3.7 incorrectly emitted one delta bit per byte. The crash dump showed + /// the client inside ClientReceiveData with a corrupted parameter-copy + /// source pointer, consistent with that bitstream misalignment. + /// PerElementDelta and Raw remain available only as diagnostics. + /// + public static byte[] BuildClientReceiveDataPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + int count, + byte[] packet, + FixedByteArrayWireMode wireMode) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + bool countPresent = count != 0; + payload.WriteBit(countPresent); + if (countPresent) + payload.WriteBits((uint)count, 32); + + switch (wireMode) + { + case FixedByteArrayWireMode.SinglePresenceRaw: + { + bool present = packet.Any(value => value != 0u); + + payload.WriteBit(present); + if (present) + payload.WriteBytes(packet); + break; + } + + case FixedByteArrayWireMode.PerElementDelta: + // Diagnostic only. This encoding crashed build 3908 because the + // fixed array has one property marker, not 256 element markers. + foreach (byte value in packet) + { + bool present = value != 0u; + payload.WriteBit(present); + if (present) + payload.WriteBits(value, 8); + } + break; + + case FixedByteArrayWireMode.Raw: + payload.WriteBytes(packet); + break; + } + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// cAPBPlayerController.ClientGoToSpawnZoneSelectScreen( + /// cSDD.etFaction eFaction). Reflection proves one ByteProperty + /// parameter whose UEnum contains five entries. Network serialization is + /// SerializeInt(eFaction, 5), preceded by the RPC presence bit. + /// + public static byte[] BuildClientGoToSpawnZoneSelectScreenPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + byte faction) + { + const uint kFactionEnumCount = 5; + + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + // ByteProperty RPC parameters use a leading non-default bit. The + // v1.3.0 payload [1,0] for faction Enforcer was decoded as: + // present = 1, faction = 0 + // Write the presence bit explicitly, then SerializeInt(value, 5). + payload.WriteBit(faction != 0u); + if (faction != 0u) + payload.WriteBoundedInt(faction, kFactionEnumCount); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + private static void WriteHudMarkerByte(BitWriter writer, byte value, uint valueMax, bool rawByteEncoding) + { + if (rawByteEncoding) + { + writer.WriteBits(value, 8); + return; + } + + // RPC function parameters are serialized directly through the reflected + // ByteProperty. Enum-backed bytes use SerializeInt with the enum's + // value count; there is no default-value or presence bit inside a + // StructProperty parameter. + writer.WriteBoundedInt(value, Math.Max(valueMax, 2u)); + } + + /// + /// cAPBPlayerController.ClientReplicateHUDMarker( + /// HUDMarkerData markerData, int nServerMarkerID) + /// + /// Reflected parameter size is 0x28: the first 0x20 bytes are the script + /// struct and the marker id follows at offset 0x20. Each member is written + /// through its UE3 property serializer. + /// + public static byte[] BuildClientReplicateHudMarkerPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax, + HUDMarkerWireData marker) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + // UE3 RPC parameters are delta-serialized against their zero/default + // value. Every top-level CPF_Parm begins with a one-bit "present" flag. + payload.WriteBit(true); // markerData is present/non-default + + if (marker.LinkedActorByChannel && marker.LinkedActorReference != 0u) + { + payload.WriteObjectByChannel(marker.LinkedActorReference); + } + else + { + // Package-map object zero is UObject None. This also permits a + // configured static-level export NetIndex once it is known. + payload.WriteObjectByNetIndex(marker.LinkedActorReference); + } + + if (marker.CompressedLocation) + { + payload.WriteCompressedVector(marker.LocationX, marker.LocationY, marker.LocationZ); + } + else + { + payload.WriteBits(BitConverter.SingleToUInt32Bits(marker.LocationX), 32); + payload.WriteBits(BitConverter.SingleToUInt32Bits(marker.LocationY), 32); + payload.WriteBits(BitConverter.SingleToUInt32Bits(marker.LocationZ), 32); + } + + // APBGame.u metadata for cHUDMarkerManager.HUDMarkerData: + // eOffsetOverride ByteProperty, Enum=None -> raw 8 bits + // eCSAAutoRouteData ByteProperty, Enum=None -> raw 8 bits + // eType ByteProperty, Enum=None -> raw 8 bits + // eState ByteProperty, Enum=etHUDMarkerState + // -> SerializeInt(value, 19) + // The previous all-bounded encoding shifted the archive by 13 bits and + // made the client reject the RPC as an irrational FString size. + payload.WriteBits(marker.OffsetOverride, 8); + payload.WriteBits(marker.AutoRouteData, 8); + payload.WriteBits(marker.Type, 8); + if (marker.RawByteEncoding) + payload.WriteBits(marker.State, 8); + else + payload.WriteBoundedInt(marker.State, Math.Max(marker.StateMax, 2u)); + + payload.WriteBit(marker.IsBeingModified); + payload.WriteBits((uint)marker.UserData, 32); + payload.WriteBits((uint)marker.UserData2, 32); + + // nServerMarkerID is a second top-level RPC parameter, so it has its + // own presence bit before the IntProperty payload. Omitting this bit + // left one-bit-shifted tail data that the actor channel interpreted as + // unrelated RPC fields. + bool markerIdPresent = marker.ServerMarkerId != 0; + payload.WriteBit(markerIdPresent); + if (markerIdPresent) + payload.WriteBits((uint)marker.ServerMarkerId, 32); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } + + /// + /// An RPC with no parameters: just the field index. Used for + /// ClientFlushLevelStreaming, which forces pending streaming requests to + /// commit instead of waiting for the streaming tick to pick them up. + /// + public static byte[] BuildActorVoidFieldPacket( + uint serverPacketId, + ushort channelIndex, + ushort channelSequence, + uint fieldIndex, + uint fieldMax) + { + var payload = new BitWriter(); + payload.WriteBoundedInt(fieldIndex, fieldMax); + + var writer = new BitWriter(); + WriteActorBunchHeader(writer, serverPacketId, channelIndex, channelSequence, payload.BitCount); + writer.WriteBitsFrom(payload.Snapshot(), payload.BitCount); + return writer.FinishWithTrailer(); + } +} diff --git a/DistrictServerCSharp/Protocol/PacketParser.cs b/DistrictServerCSharp/Protocol/PacketParser.cs new file mode 100644 index 0000000..63005d4 --- /dev/null +++ b/DistrictServerCSharp/Protocol/PacketParser.cs @@ -0,0 +1,383 @@ +using System.Text; +using DistrictServerCSharp.Bits; + +namespace DistrictServerCSharp.Protocol; + +/// +/// UE3 packet parsing, ported from ApbUdp.cpp. Turns a raw datagram into a +/// of bunches, and parses the text AUTH control string. +/// +public static class PacketParser +{ + /// + /// The highest set bit in the datagram is UE3's packet trailer marker; + /// the payload ends just before it. + /// + private static bool FindPayloadBitCount(byte[] data, int size, out int payloadBitCount) + { + payloadBitCount = 0; + + if (data.Length == 0 || size == 0) + return false; + + for (int reverse = size; reverse > 0; --reverse) + { + int byteIndex = reverse - 1; + byte value = data[byteIndex]; + + if (value == 0) + continue; + + for (int bit = 7; bit >= 0; --bit) + { + if ((value & (1u << bit)) != 0) + { + payloadBitCount = byteIndex * 8 + bit; + return true; + } + } + } + + return false; + } + + private static bool ExtractBits(byte[] source, int beginBit, int bitCount, out byte[] output) + { + output = new byte[(bitCount + 7) / 8]; + + for (int index = 0; index < bitCount; ++index) + { + int sourceBit = beginBit + index; + byte value = (byte)((source[sourceBit / 8] >> (sourceBit % 8)) & 1u); + + if (value != 0) + output[index / 8] |= (byte)(1u << (index % 8)); + } + + return true; + } + + private static bool ParseControlStrings(byte[] data, int beginBit, int bitCount, List strings) + { + var reader = new BitReader(data, beginBit, beginBit + bitCount); + + while (reader.Remaining >= 32) + { + if (!reader.ReadBits(32, out uint rawLength)) + return false; + + int length = (int)rawLength; + + if (length == 0) + { + strings.Add(string.Empty); + continue; + } + + if (length > 0) + { + if (length > 65536 || + (long)length * 8 > reader.Remaining) + { + return strings.Count > 0; + } + + var builder = new StringBuilder(length); + + for (int index = 0; index < length; ++index) + { + if (!reader.ReadByte(out byte character)) + return false; + + if (character != 0) + builder.Append((char)character); + } + + strings.Add(builder.ToString()); + } + else + { + long wideCount = -(long)length; + if (wideCount <= 0 || + wideCount > 32768 || + wideCount * 16 > reader.Remaining) + { + return strings.Count > 0; + } + + var wideBuilder = new StringBuilder((int)wideCount); + + for (long index = 0; index < wideCount; ++index) + { + if (!reader.ReadBits(16, out uint character)) + return false; + + if (character == 0) + continue; + + wideBuilder.Append(character <= 0x7f ? (char)character : '?'); + } + + strings.Add(wideBuilder.ToString()); + } + } + + return strings.Count > 0; + } + + private static bool IsHexCharacter(char value) + => Uri.IsHexDigit(value); + + private static byte HexNibble(char value) + { + if (value >= '0' && value <= '9') + return (byte)(value - '0'); + if (value >= 'a' && value <= 'f') + return (byte)(10 + value - 'a'); + if (value >= 'A' && value <= 'F') + return (byte)(10 + value - 'A'); + return 0xff; + } + + private static bool ReadToken(string text, string key, out string value) + { + int position = text.IndexOf(key, StringComparison.Ordinal); + if (position < 0) + { + value = string.Empty; + return false; + } + + int begin = position + key.Length; + int end = begin; + + while (end < text.Length && !char.IsWhiteSpace(text[end])) + ++end; + + value = text.Substring(begin, end - begin); + return value.Length > 0; + } + + /// + /// Parses one UE3 datagram into a . The packet id is + /// the 30-bit field beginning at bit 0 (low 16 bits were previously + /// mislabelled as "prefix"; the next 14 bits are the normally-zero high + /// part). The real client packet id is the low 16 bits. + /// + public static bool ParsePacket(byte[] data, int size, Packet packet) + { + packet.Bunches.Clear(); + packet.Valid = false; + packet.Prefix = 0; + packet.PacketId = 0; + packet.PayloadBitCount = 0; + packet.Error = null; + + if (!FindPayloadBitCount(data, size, out int payloadBitCount)) + { + packet.Error = "missing UE3 trailer marker"; + return false; + } + + packet.PayloadBitCount = payloadBitCount; + + var reader = new BitReader(data, 0, payloadBitCount); + + if (!reader.ReadBits(16, out uint value)) + { + packet.Error = "truncated packet id (low)"; + return false; + } + uint packetIdLow = value; + packet.Prefix = (ushort)value; + + if (!reader.ReadBits(14, out value)) + { + packet.Error = "truncated packet id (high)"; + return false; + } + packet.PacketId = (ushort)packetIdLow; + + while (reader.Remaining > 0) + { + var bunch = new Bunch(); + + if (!reader.ReadBit(out bool flag)) + { + packet.Error = "truncated ACK/data flag"; + return false; + } + + if (flag) + { + // Ack bunch: [IsAck=1][bHasId][if bHasId: ReadInt(0x40000000)]. + // The client's UNetConnection::ReceivedPacket reads a full + // 30-bit packet id here, not a 14-bit one; reading 14 bits + // shifted every following bunch and produced spurious + // "bunch data exceeds packet payload" errors. + bunch.Kind = BunchKind.Ack; + + if (!reader.ReadBit(out bool hasAckId)) + { + packet.Error = "truncated ACK id presence flag"; + return false; + } + + if (hasAckId) + { + if (!reader.ReadBits(30, out value)) + { + packet.Error = "truncated ACK packet id"; + return false; + } + bunch.AckPacketId = value; + } + + packet.Bunches.Add(bunch); + continue; + } + + bunch.Kind = BunchKind.Data; + + if (!reader.ReadBit(out bool hasOpenClose)) + { + packet.Error = "truncated open/close presence flag"; + return false; + } + + if (hasOpenClose) + { + if (!reader.ReadBit(out bunch.Open) || + !reader.ReadBit(out bunch.Close)) + { + packet.Error = "truncated open/close flags"; + return false; + } + } + + if (!reader.ReadBit(out bunch.Reliable)) + { + packet.Error = "truncated reliability flag"; + return false; + } + + if (!reader.ReadBits(10, out value)) + { + packet.Error = "truncated channel index"; + return false; + } + bunch.ChannelIndex = (ushort)value; + + if (bunch.Reliable) + { + if (!reader.ReadBits(10, out value)) + { + packet.Error = "truncated channel sequence"; + return false; + } + bunch.ChannelSequence = (ushort)value; + } + + if (bunch.Reliable || bunch.Open) + { + if (!reader.ReadBits(3, out value)) + { + packet.Error = "truncated channel type"; + return false; + } + bunch.ChannelType = (byte)value; + } + + if (!reader.ReadBits(12, out value)) + { + packet.Error = "truncated bunch data length"; + return false; + } + + bunch.DataBitCount = (ushort)value; + bunch.DataBitOffset = reader.Tell; + + if (bunch.DataBitCount > reader.Remaining) + { + packet.Error = "bunch data exceeds packet payload"; + return false; + } + + ExtractBits(data, bunch.DataBitOffset, bunch.DataBitCount, out bunch.RawData); + + if (bunch.ChannelType == 1 && bunch.DataBitCount >= 32) + { + ParseControlStrings(data, bunch.DataBitOffset, bunch.DataBitCount, bunch.ControlStrings); + } + + if (!reader.Skip(bunch.DataBitCount)) + { + packet.Error = "failed to advance over bunch data"; + return false; + } + + packet.Bunches.Add(bunch); + } + + packet.Valid = true; + return true; + } + + /// Parses an AUTH control string into an . + public static bool ParseAuthCommand(string text, AuthCommand auth) + { + auth.Valid = false; + auth.AccountId = 0; + auth.AuthKeyText = ""; + auth.Error = null; + + if (text.Length < 5 || !text.StartsWith("AUTH", StringComparison.Ordinal)) + { + auth.Error = "control string is not AUTH"; + return false; + } + + if (!ReadToken(text, "ACCID=", out string accountText)) + { + auth.Error = "AUTH is missing ACCID"; + return false; + } + + if (!ReadToken(text, "AUTHKEY=", out string keyText)) + { + auth.Error = "AUTH is missing AUTHKEY"; + return false; + } + + if (keyText.Length != 40 || !keyText.All(IsHexCharacter)) + { + auth.Error = "AUTHKEY is not 40 hexadecimal characters"; + return false; + } + + if (!uint.TryParse(accountText, out uint parsedAccount)) + { + auth.Error = "ACCID is not a valid uint32"; + return false; + } + + for (int index = 0; index < auth.AuthKey.Length; ++index) + { + byte high = HexNibble(keyText[index * 2]); + byte low = HexNibble(keyText[index * 2 + 1]); + + if (high > 0x0f || low > 0x0f) + { + auth.Error = "AUTHKEY contains a non-hexadecimal nibble"; + return false; + } + + auth.AuthKey[index] = (byte)((high << 4) | low); + } + + auth.AccountId = parsedAccount; + auth.AuthKeyText = keyText; + auth.Valid = true; + return true; + } +} diff --git a/DistrictServerCSharp/Protocol/SelfTest.cs b/DistrictServerCSharp/Protocol/SelfTest.cs new file mode 100644 index 0000000..8e440ba --- /dev/null +++ b/DistrictServerCSharp/Protocol/SelfTest.cs @@ -0,0 +1,171 @@ +namespace DistrictServerCSharp.Protocol; + +/// +/// Wire-layer self test, ported 1:1 from ApbUdp.cpp RunSelfTest. It parses a +/// captured AUTH datagram, round-trips the text challenge, decodes a captured +/// field-90 visibility RPC and a built ServerSelectSpawnZone, and builds a HUD +/// marker packet. Passing proves the C# bit layer produces and consumes the +/// same bits as the proven C++ implementation. +/// +public static class SelfTest +{ + public static bool Run(out string details) + { + byte[] fixture = + { + 0x00,0x00,0x00,0x80,0x05,0x20,0x80,0x60,0xC9,0x11,0x00,0x00, + 0x40,0x50,0x15,0x15,0x12,0x48,0xD0,0xD0,0x50,0x12,0x51,0x0F, + 0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x4C,0x0C,0x48,0x50, + 0x15,0x15,0xD2,0x52,0x51,0x56,0x4F,0x8E,0x91,0x0D,0x0C,0x4D, + 0x8D,0x91,0x0D,0x4E,0x4D,0xCD,0x0C,0x4E,0x4D,0x4C,0x91,0x10, + 0xD1,0xCC,0x4D,0x4E,0x8E,0x4C,0xCD,0x0C,0xCC,0x4D,0x8E,0x10, + 0x4E,0x91,0x8C,0x8D,0x4D,0x11,0xCE,0x90,0x10,0x0E,0x11,0x40 + }; + + var packet = new Packet(); + if (!PacketParser.ParsePacket(fixture, fixture.Length, packet)) + { + details = $"fixture parse failed: {packet.Error}"; + return false; + } + + if (packet.Prefix != 0 || + packet.PacketId != 0 || + packet.Bunches.Count != 1) + { + details = "fixture packet header did not match"; + return false; + } + + Bunch bunch = packet.Bunches[0]; + if (!bunch.Open || !bunch.Reliable || + bunch.ChannelIndex != 0 || + bunch.ChannelSequence != 1 || + bunch.ChannelType != 1 || + bunch.DataBitCount != 600 || + bunch.ControlStrings.Count != 1) + { + details = "fixture bunch did not match expected UE3 control framing"; + return false; + } + + var auth = new AuthCommand(); + if (!PacketParser.ParseAuthCommand(bunch.ControlStrings[0], auth)) + { + details = $"fixture AUTH parse failed: {auth.Error}"; + return false; + } + + if (auth.AccountId != 1 || + auth.AuthKeyText != "9F6045F68553851EBD3799253079B8E266E8CB8D") + { + details = "fixture AUTH values did not match"; + return false; + } + + // Ack packet: 30-bit packet id + [IsAck=1][bHasId=1][30-bit ack id] + // plus the terminator bit = 62 bits -> 8 bytes. + byte[] ack = PacketBuilders.BuildAckPacket(0, 0, 0); + if (ack.Length != 8) + { + details = "ACK builder produced unexpected byte length"; + return false; + } + + // The real handshake challenge is a TEXT control message with no ack + // bunch in front of it. Verify the FString round-trips and that the + // packet contains exactly one control bunch. + const string challengeText = "CHALLENGE VER=3908 CHALLENGE=305419896"; + byte[] challenge = PacketBuilders.BuildTextControlPacket(0, 0, 1, challengeText); + + var challengePacket = new Packet(); + if (!PacketParser.ParsePacket(challenge, challenge.Length, challengePacket) || + challengePacket.PacketId != 0 || + challengePacket.Bunches.Count != 1 || + challengePacket.Bunches[0].Kind != BunchKind.Data || + challengePacket.Bunches[0].ChannelType != 1 || + challengePacket.Bunches[0].ControlStrings.Count != 1 || + challengePacket.Bunches[0].ControlStrings[0] != challengeText) + { + details = "Text challenge builder self-test failed"; + return false; + } + + // Captured build-3908 controller field 90: + // ServerUpdateLevelVisibility(rworldsocialdistrict_beacons, true) + byte[] visibilityFixture = + { + 0x5A, 0xEC, 0x00, 0x00, 0x00, 0x90, 0xBB, 0x7B, + 0x93, 0x63, 0x23, 0x9B, 0x7B, 0x1B, 0x4B, 0x0B, + 0x63, 0x23, 0x4B, 0x9B, 0xA3, 0x93, 0x4B, 0x1B, + 0xA3, 0xFB, 0x12, 0x2B, 0x0B, 0x1B, 0x7B, 0x73, + 0x9B, 0x03, 0x08 + }; + + var visibilityBunch = new Bunch + { + Kind = BunchKind.Data, + ChannelIndex = 2, + ChannelType = 2, + DataBitCount = 276, + RawData = visibilityFixture + }; + + var visibilityFields = new List(); + if (!FieldDecoders.DecodeControllerActorFields( + visibilityBunch, 634, 90, 372, 371, visibilityFields, out string visibilityError) || + visibilityFields.Count != 1 || + visibilityFields[0].FieldIndex != 90 || + !visibilityFields[0].IsServerUpdateLevelVisibility || + visibilityFields[0].PackageName != "rworldsocialdistrict_beacons" || + !visibilityFields[0].IsVisible) + { + details = $"controller field-90 decoder self-test failed: {visibilityError}"; + return false; + } + + byte[] selectSpawnPacket = PacketBuilders.BuildActorObjectRpcPacket(6, 2, 3, 371, 634, 5); + + var parsedSelectSpawnPacket = new Packet(); + var selectSpawnFields = new List(); + string selectSpawnError = string.Empty; + if (!PacketParser.ParsePacket(selectSpawnPacket, selectSpawnPacket.Length, parsedSelectSpawnPacket) || + parsedSelectSpawnPacket.Bunches.Count != 1 || + !FieldDecoders.DecodeControllerActorFields( + parsedSelectSpawnPacket.Bunches[0], 634, 90, 372, 371, selectSpawnFields, out selectSpawnError) || + selectSpawnFields.Count != 1 || + !selectSpawnFields[0].IsServerSelectSpawnZone || + !selectSpawnFields[0].ObjectReferenceByChannel || + selectSpawnFields[0].ObjectReferenceValue != 5) + { + details = $"ServerSelectSpawnZone decoder self-test failed: {selectSpawnError}"; + return false; + } + + var markerFixture = new HUDMarkerWireData + { + LocationX = 33063.848f, + LocationY = 37346.258f, + LocationZ = 1328.0f, + Type = 31, + ServerMarkerId = 900001 + }; + + byte[] markerPacket = PacketBuilders.BuildClientReplicateHudMarkerPacket(7, 2, 4, 392, 634, markerFixture); + + var parsedMarkerPacket = new Packet(); + if (!PacketParser.ParsePacket(markerPacket, markerPacket.Length, parsedMarkerPacket) || + parsedMarkerPacket.Bunches.Count != 1 || + parsedMarkerPacket.Bunches[0].ChannelIndex != 2 || + parsedMarkerPacket.Bunches[0].DataBitCount == 0) + { + details = "HUD-marker packet builder self-test failed"; + return false; + } + + details = Diagnostics.DescribePacket(packet) + + $" | ACK={Diagnostics.Hex(ack)}" + + $" | CHALLENGE={Diagnostics.Hex(challenge)}"; + return true; + } +} diff --git a/DistrictServerCSharp/SpawnZone/SpawnZoneService.cs b/DistrictServerCSharp/SpawnZone/SpawnZoneService.cs new file mode 100644 index 0000000..9a97ac4 --- /dev/null +++ b/DistrictServerCSharp/SpawnZone/SpawnZoneService.cs @@ -0,0 +1,386 @@ +using System.Net; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Packages; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.SpawnZone; + +/// +/// Spawn-zone HUD marker publication, ported from SendSpawnZoneHudMarker in +/// DistrictServer.cpp. On Social the two faction cPlayerCharacterSpawnZone +/// volumes are bridged to synthetic actor channels and published as HUD +/// markers; on action districts the twelve captured spawn directions are used. +/// Markers are tracked reliable packets so they survive retransmission. +/// +public sealed class SpawnZoneService +{ + private readonly HandshakeService _handshake; + private readonly ReliableQueue _reliableQueue; + + /// Raised after at least one marker is sent (the possession gate reads this). + public Action? OnMarkersSent; + + public SpawnZoneService(HandshakeService handshake, ReliableQueue reliableQueue) + { + _handshake = handshake; + _reliableQueue = reliableQueue; + } + + private static float ReadFloatSetting(string environmentName, string iniKey, string fallback) + { + string value = DistrictConfig.ReadSetting(environmentName, iniKey, fallback); + return float.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, out float parsed) ? parsed : 0.0f; + } + + private sealed class SpawnZoneMarkerCandidate + { + public int Faction; + public uint LinkedReference; + public bool LinkedByChannel; + public float MarkerX; + public float MarkerY; + public float MarkerZ; + } + + public bool SendSpawnZoneHudMarker(IPEndPoint endpoint, Account account) + { + if (account == null || !DistrictConfig.ReadBool("APB_SEND_SPAWN_ZONE_HUD_MARKER", "SendSpawnZoneHUDMarker", true)) + return false; + + var marker = new HUDMarkerWireData(); + + int actorChannel = DistrictConfig.ReadInt("APB_SPAWN_ZONE_LINKED_ACTOR_CHANNEL", "SpawnZoneLinkedActorChannel", 0, 0, 1022); + int actorNetIndexOverride = DistrictConfig.ReadInt("APB_SPAWN_ZONE_LINKED_ACTOR_NET_INDEX", "SpawnZoneLinkedActorNetIndex", 0, 0, int.MaxValue); + int configuredCharacterFaction = DistrictConfig.ReadInt("APB_CHARACTER_FACTION", "CharacterFaction", 1, 0, 4); + int characterFaction = account.HasCharacterProfile() ? account.GetCharacterFaction() : configuredCharacterFaction; + + int enforcerActorNetIndexOverride = DistrictConfig.ReadInt("APB_ENFORCER_SPAWN_ZONE_LINKED_ACTOR_NET_INDEX", "EnforcerSpawnZoneLinkedActorNetIndex", 0, 0, int.MaxValue); + int criminalActorNetIndexOverride = DistrictConfig.ReadInt("APB_CRIMINAL_SPAWN_ZONE_LINKED_ACTOR_NET_INDEX", "CriminalSpawnZoneLinkedActorNetIndex", 0, 0, int.MaxValue); + + bool sendBothSocialSpawnZones = + DistrictConfig.GetConfiguredDistrictMap() == DistrictMap.Social && + DistrictConfig.ReadBool("APB_SEND_BOTH_SOCIAL_SPAWN_ZONES", "SendBothSocialSpawnZones", true); + + float fallbackMarkerX = ReadFloatSetting("APB_SPAWN_ZONE_MARKER_X", "SpawnZoneMarkerX", "33063.848"); + float fallbackMarkerY = ReadFloatSetting("APB_SPAWN_ZONE_MARKER_Y", "SpawnZoneMarkerY", "37346.258"); + float fallbackMarkerZ = ReadFloatSetting("APB_SPAWN_ZONE_MARKER_Z", "SpawnZoneMarkerZ", "1328.0"); + + float enforcerMarkerX = ReadFloatSetting("APB_ENFORCER_SPAWN_ZONE_MARKER_X", "EnforcerSpawnZoneMarkerX", "33472.0"); + float enforcerMarkerY = ReadFloatSetting("APB_ENFORCER_SPAWN_ZONE_MARKER_Y", "EnforcerSpawnZoneMarkerY", "37488.0"); + float enforcerMarkerZ = ReadFloatSetting("APB_ENFORCER_SPAWN_ZONE_MARKER_Z", "EnforcerSpawnZoneMarkerZ", "208.0"); + + float criminalMarkerX = ReadFloatSetting("APB_CRIMINAL_SPAWN_ZONE_MARKER_X", "CriminalSpawnZoneMarkerX", "33376.0"); + float criminalMarkerY = ReadFloatSetting("APB_CRIMINAL_SPAWN_ZONE_MARKER_Y", "CriminalSpawnZoneMarkerY", "37312.0"); + float criminalMarkerZ = ReadFloatSetting("APB_CRIMINAL_SPAWN_ZONE_MARKER_Z", "CriminalSpawnZoneMarkerZ", "208.0"); + + marker.OffsetOverride = (byte)DistrictConfig.ReadInt("APB_SPAWN_ZONE_MARKER_OFFSET_OVERRIDE", "SpawnZoneMarkerOffsetOverride", 0, 0, 255); + marker.AutoRouteData = (byte)DistrictConfig.ReadInt("APB_SPAWN_ZONE_MARKER_AUTO_ROUTE_DATA", "SpawnZoneMarkerAutoRouteData", 0, 0, 255); + marker.Type = (byte)DistrictConfig.ReadInt("APB_SPAWN_ZONE_MARKER_TYPE", "SpawnZoneMarkerType", 31, 0, 255); + marker.State = (byte)DistrictConfig.ReadInt("APB_SPAWN_ZONE_MARKER_STATE", "SpawnZoneMarkerState", 0, 0, 255); + marker.IsBeingModified = DistrictConfig.ReadBool("APB_SPAWN_ZONE_MARKER_BEING_MODIFIED", "SpawnZoneMarkerBeingModified", false); + marker.UserData = DistrictConfig.ReadInt("APB_SPAWN_ZONE_MARKER_USER_DATA", "SpawnZoneMarkerUserData", 0, int.MinValue, int.MaxValue); + marker.UserData2 = DistrictConfig.ReadInt("APB_SPAWN_ZONE_MARKER_USER_DATA2", "SpawnZoneMarkerUserData2", 0, int.MinValue, int.MaxValue); + + int markerIdBase = DistrictConfig.ReadInt("APB_SPAWN_ZONE_MARKER_ID", "SpawnZoneMarkerID", 900001, 1, int.MaxValue - 16); + + marker.RawByteEncoding = DistrictConfig.ReadSetting("APB_HUD_MARKER_BYTE_ENCODING", "HUDMarkerByteEncoding", "metadata-exact").ToLowerInvariant() == "raw"; + marker.CompressedLocation = DistrictConfig.ReadBool("APB_HUD_MARKER_COMPRESSED_LOCATION", "HUDMarkerCompressedLocation", true); + marker.WriteStructPresenceBit = true; + marker.OffsetOverrideMax = (uint)DistrictConfig.ReadInt("APB_HUD_MARKER_OFFSET_OVERRIDE_MAX", "HUDMarkerOffsetOverrideMax", 2, 2, 256); + marker.AutoRouteDataMax = (uint)DistrictConfig.ReadInt("APB_HUD_MARKER_AUTO_ROUTE_MAX", "HUDMarkerAutoRouteMax", 4, 2, 256); + marker.TypeMax = (uint)DistrictConfig.ReadInt("APB_HUD_MARKER_TYPE_MAX", "HUDMarkerTypeMax", 256, 32, 256); + marker.StateMax = (uint)DistrictConfig.ReadInt("APB_HUD_MARKER_STATE_MAX", "HUDMarkerStateMax", 19, 2, 256); + + var candidates = new List(); + + // Resolve a faction spawn-zone net index exactly as the C++ does: an + // explicit override wins, otherwise the exact base of the + // rworldsocialdistrict_design package is computed from the actual + // cooked package headers (TryComputeExactPackageFirstNetIndex). If the + // client root is unavailable the resolution fails and the marker is + // not sent, matching the C++ behaviour. + uint exactDesignBase = 0; + bool exactDesignBaseResolved = false; + bool ResolveFactionNetIndex(int faction, out uint result) + { + result = 0; + int explicitOverride = faction == 1 ? enforcerActorNetIndexOverride : criminalActorNetIndexOverride; + if (explicitOverride > 0) + { + result = (uint)explicitOverride; + return true; + } + + if (!exactDesignBaseResolved) + { + if (!CookedPackageResolver.TryComputeExactPackageFirstNetIndex("rworldsocialdistrict_design", out exactDesignBase)) + return false; + exactDesignBaseResolved = true; + } + + uint localNetIndex = faction == 1 + ? DistrictConfig.EnforcerSpawnZoneLocalNetIndex + : DistrictConfig.CriminalSpawnZoneLocalNetIndex; + result = exactDesignBase + localNetIndex; + return true; + } + + DistrictMap activeDistrictMap = DistrictConfig.GetConfiguredDistrictMap(); + + if (DistrictConfig.IsActionDistrict(activeDistrictMap)) + { + // Action districts: twelve captured spawn directions. The C++ + // resolves the Default__cPlayerCharacterSpawnZone archetype from + // the cooked APBGame.u; the C# port keeps the convenience override + // and falls back to the same cooked-package resolution. + uint spawnZoneArchetypeNetIndex = (uint)DistrictConfig.ReadInt( + "APB_ACTION_SPAWN_ZONE_ARCHETYPE_NET_INDEX", "ActionSpawnZoneArchetypeNetIndex", 0, 0, int.MaxValue); + + if (spawnZoneArchetypeNetIndex == 0) + { + string clientRoot = DistrictConfig.ReadSetting("APB_CLIENT_ROOT", "APBClientRoot", ""); + string apbGamePath = CookedPackageResolver.ResolveConfiguredPackagePath( + "APB_APBGAME_PACKAGE_PATH", "APBGamePackagePath", + "APBGame\\CookedPC\\APBGame.u", + new HashSet { "apbgame.u", "apbgame.upk" }, + clientRoot); + + if (string.IsNullOrEmpty(apbGamePath) || + !CookedPackageResolver.ParseCookedPackageSummary(apbGamePath, out CookedPackageSummary apbGame) || + !CookedPackageResolver.FindCookedExport(apbGame, "Default__cAPBPlayerController", out CookedExportMatch controllerExport) || + !CookedPackageResolver.FindCookedExport(apbGame, "Default__cPlayerCharacterSpawnZone", out CookedExportMatch spawnZoneArchetypeExport)) + { + DistrictLogger.Log(LogLevel.Error, "District Spawn Zone Marker", + "Could not resolve the APBGame {0} spawn-zone archetype from '{1}'.", + DistrictConfig.DistrictMapName(activeDistrictMap), apbGamePath); + return false; + } + + PackageNetIndexModel netIndexModel = CookedPackageResolver.CalibratePackageNetIndexModel(apbGame, controllerExport); + uint spawnZoneArchetypeLocalIndex = CookedPackageResolver.ApplyPackageNetIndexModel(netIndexModel, apbGame, spawnZoneArchetypeExport); + + if (netIndexModel == PackageNetIndexModel.Unknown || spawnZoneArchetypeLocalIndex == 0u) + { + DistrictLogger.Log(LogLevel.Error, "District Spawn Zone Marker", + "Could not calibrate Default__cPlayerCharacterSpawnZone NetIndex (ordinal={0}).", + spawnZoneArchetypeExport.Ordinal); + return false; + } + + spawnZoneArchetypeNetIndex = DistrictConfig.GlobalNetIndex("APBGame", spawnZoneArchetypeLocalIndex); + DistrictLogger.Log(LogLevel.Success, "District Spawn Zone Marker", + "Resolved Default__cPlayerCharacterSpawnZone ordinal={0} localNetIndex={1} globalNetIndex={2} model={3}.", + spawnZoneArchetypeExport.Ordinal, spawnZoneArchetypeLocalIndex, spawnZoneArchetypeNetIndex, + CookedPackageResolver.PackageNetIndexModelName(netIndexModel)); + } + + for (int index = 0; index < 12; ++index) + { + if (!DistrictConfig.TryGetActionSpawnDirection(activeDistrictMap, index, out float x, out float y, out float z)) + return false; + + candidates.Add(new SpawnZoneMarkerCandidate + { + Faction = characterFaction, + LinkedReference = spawnZoneArchetypeNetIndex, + MarkerX = x, + MarkerY = y, + MarkerZ = z + }); + } + } + else if (actorChannel > 0) + { + candidates.Add(new SpawnZoneMarkerCandidate + { + Faction = characterFaction, + LinkedReference = (uint)actorChannel, + LinkedByChannel = true, + MarkerX = fallbackMarkerX, + MarkerY = fallbackMarkerY, + MarkerZ = fallbackMarkerZ + }); + } + else if (actorNetIndexOverride > 0) + { + candidates.Add(new SpawnZoneMarkerCandidate + { + Faction = characterFaction, + LinkedReference = (uint)actorNetIndexOverride, + MarkerX = fallbackMarkerX, + MarkerY = fallbackMarkerY, + MarkerZ = fallbackMarkerZ + }); + } + else if (sendBothSocialSpawnZones) + { + if (!ResolveFactionNetIndex(1, out uint enforcerNetIndex) || + !ResolveFactionNetIndex(0, out uint criminalNetIndex)) + { + DistrictLogger.Log(LogLevel.Error, "District Spawn Zone Marker", + "Could not resolve both Social spawn-zone NetIndexes; markers were not sent."); + return false; + } + + candidates.Add(new SpawnZoneMarkerCandidate + { + Faction = 1, + LinkedReference = enforcerNetIndex, + MarkerX = enforcerMarkerX, + MarkerY = enforcerMarkerY, + MarkerZ = enforcerMarkerZ + }); + + candidates.Add(new SpawnZoneMarkerCandidate + { + Faction = 0, + LinkedReference = criminalNetIndex, + MarkerX = criminalMarkerX, + MarkerY = criminalMarkerY, + MarkerZ = criminalMarkerZ + }); + } + else if (characterFaction == 0 || characterFaction == 1) + { + if (!ResolveFactionNetIndex(characterFaction, out uint linkedNetIndex)) + { + DistrictLogger.Log(LogLevel.Error, "District Spawn Zone Marker", + "Could not resolve the faction spawn-zone NetIndex; marker was not sent."); + return false; + } + + candidates.Add(new SpawnZoneMarkerCandidate + { + Faction = characterFaction, + LinkedReference = linkedNetIndex, + MarkerX = characterFaction == 1 ? enforcerMarkerX : criminalMarkerX, + MarkerY = characterFaction == 1 ? enforcerMarkerY : criminalMarkerY, + MarkerZ = characterFaction == 1 ? enforcerMarkerZ : criminalMarkerZ + }); + } + else + { + DistrictLogger.Log(LogLevel.Error, "District Spawn Zone Marker", + "CharacterFaction={0} has no Social spawn-zone mapping.", characterFaction); + return false; + } + + // Bridge static spawn-zone actors to synthetic actor channels so the + // client has a resolvable object reference for each marker. + bool bridgeStaticSpawnZoneToActorChannel = + DistrictConfig.IsActionDistrict(activeDistrictMap) || + (activeDistrictMap == DistrictMap.Social && + DistrictConfig.ReadBool("APB_BRIDGE_STATIC_SPAWN_ZONE_TO_ACTOR_CHANNEL", "BridgeStaticSpawnZoneToActorChannel", true)); + + if (bridgeStaticSpawnZoneToActorChannel) + { + float bridgeActorX = ReadFloatSetting("APB_SPAWN_ZONE_BRIDGE_ACTOR_X", "SpawnZoneBridgeActorX", "33063.848"); + float bridgeActorY = ReadFloatSetting("APB_SPAWN_ZONE_BRIDGE_ACTOR_Y", "SpawnZoneBridgeActorY", "37346.258"); + float bridgeActorZ = ReadFloatSetting("APB_SPAWN_ZONE_BRIDGE_ACTOR_Z", "SpawnZoneBridgeActorZ", "1328.0"); + + bool openedAnyBridge = false; + for (int index = 0; index < candidates.Count; ++index) + { + SpawnZoneMarkerCandidate candidate = candidates[index]; + if (candidate.LinkedByChannel) + continue; + + ushort bridgeChannel = (ushort)(DistrictConfig.SpawnZoneActorChannel + index); + uint staticTemplate = candidate.LinkedReference; + uint openPacketId = account.AllocateServerPacketId(); + + bool actionDistrict = DistrictConfig.IsActionDistrict(activeDistrictMap); + + byte[] openPacket = PacketBuilders.BuildActorOpenPacket( + openPacketId, + bridgeChannel, + ChannelSequenceAllocator.Allocate(endpoint, bridgeChannel), + staticTemplate, + actionDistrict ? candidate.MarkerX : bridgeActorX, + actionDistrict ? candidate.MarkerY : bridgeActorY, + actionDistrict ? candidate.MarkerZ : bridgeActorZ); + + string label = $"SPAWN-ZONE-ACTOR-BRIDGE-OPEN-{index}"; + if (!_reliableQueue.SendTrackedReliablePacket(endpoint, account, openPacketId, openPacket, label)) + { + DistrictLogger.Log(LogLevel.Error, "District Spawn Zone Marker", + "Failed to open {0} spawn-zone bridge channel {1} from static template NetIndex={2}.", + candidate.Faction == 1 ? "Enforcer" : "Criminal", bridgeChannel, staticTemplate); + return false; + } + + DistrictLogger.Log(LogLevel.Success, "District Spawn Zone Marker", + "Opened {0} spawn-zone actor bridge channel {1} from static template NetIndex={2}.", + candidate.Faction == 1 ? "Enforcer" : "Criminal", bridgeChannel, staticTemplate); + + candidate.LinkedReference = bridgeChannel; + candidate.LinkedByChannel = true; + openedAnyBridge = true; + } + + if (openedAnyBridge) + { + int bridgeDelayMilliseconds = DistrictConfig.ReadInt("APB_SPAWN_ZONE_ACTOR_BRIDGE_DELAY_MS", "SpawnZoneActorBridgeDelayMilliseconds", 500, 50, 5000); + Thread.Sleep(bridgeDelayMilliseconds); + } + } + + bool sentAny = false; + for (int candidateIndex = 0; candidateIndex < candidates.Count; ++candidateIndex) + { + SpawnZoneMarkerCandidate candidate = candidates[candidateIndex]; + + marker.LinkedActorByChannel = candidate.LinkedByChannel; + marker.LinkedActorReference = candidate.LinkedReference; + marker.LocationX = candidate.MarkerX; + marker.LocationY = candidate.MarkerY; + marker.LocationZ = candidate.MarkerZ; + marker.ServerMarkerId = markerIdBase + candidateIndex; + + uint packetId = account.AllocateServerPacketId(); + byte[] packet = PacketBuilders.BuildClientReplicateHudMarkerPacket( + packetId, + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.ClientReplicateHudMarkerWireField(), + DistrictConfig.PlayerControllerFieldMax, + marker); + + string label = $"CLIENT-REPLICATE-HUD-MARKER-{candidateIndex}"; + bool sent = _reliableQueue.SendTrackedReliablePacket(endpoint, account, packetId, packet, label); + sentAny = sentAny || sent; + + DistrictLogger.Log(sent ? LogLevel.Success : LogLevel.Error, "District Spawn Zone Marker", + "account={0} candidate={1}/{2} zoneFaction={3} sent={4} field={5} markerId={6} type={7} state={8} location=({9:F3},{10:F3},{11:F3}) linked={12}:{13} byteEncoding={14} compressedLocation={15} rpcParamPresenceBits=1,1 clearBytes={16} clear={17}", + account.GetId(), + candidateIndex + 1, + candidates.Count, + candidate.Faction == 1 ? "Enforcer" : "Criminal", + sent ? 1 : 0, + DistrictConfig.ClientReplicateHudMarkerWireField(), + marker.ServerMarkerId, + marker.Type, + marker.State, + marker.LocationX, + marker.LocationY, + marker.LocationZ, + marker.LinkedActorByChannel ? "channel" : "netindex", + marker.LinkedActorReference, + marker.RawByteEncoding ? "all-raw" : "metadata-exact", + marker.CompressedLocation ? 1 : 0, + packet.Length, + Diagnostics.Hex(packet)); + } + + // The possession gate reads SpawnZoneMarkerSent, so publish the fact + // that the markers were actually sent here. + if (sentAny) + OnMarkersSent?.Invoke(account); + + return sentAny; + } +} diff --git a/DistrictServerCSharp/Streaming/LevelStreamingService.cs b/DistrictServerCSharp/Streaming/LevelStreamingService.cs new file mode 100644 index 0000000..5846c3a --- /dev/null +++ b/DistrictServerCSharp/Streaming/LevelStreamingService.cs @@ -0,0 +1,476 @@ +using System.Net; +using System.Text; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Gri; +using DistrictServerCSharp.Handshake; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Protocol; +using DistrictServerCSharp.SpawnZone; + +namespace DistrictServerCSharp.Streaming; + +/// +/// Level-streaming status RPCs and the pre-stream controller startup sequence, +/// ported from DistrictServer.cpp (SendLevelStreamingStatus + +/// SendPreStreamingControllerStartup). Order matters: district-enter answer, +/// tracked/retried GRI open, HUD / candidate InitialState / MapSelect, then +/// the streaming plan and a flush so the client commits the pending loads. +/// +public sealed class LevelStreamingService +{ + private readonly HandshakeService _handshake; + private readonly GriStartupService _griStartup; + private readonly SpawnZoneService _spawnZone; + + public LevelStreamingService(HandshakeService handshake, GriStartupService griStartup, SpawnZoneService spawnZone) + { + _handshake = handshake; + _griStartup = griStartup; + _spawnZone = spawnZone; + } + + /// The 13 Social district packages the client must mark visible before startup feedback. + public static readonly string[] ExpectedVisibleStreamingPackages = + { + "rworldsocialdistrict_artprops_blockout", + "rworldsocialdistrict_tile_000_000_block_250_000terrain", + "rworldsocialdistrict_design", + "rworldsocialdistrict_block01", + "rworldsocialdistrict_block02", + "rworldsocialdistrict_block03", + "rworldsocialdistrict_block04", + "rworldsocialdistrict_props_block01", + "rworldsocialdistrict_props_block02", + "rworldsocialdistrict_props_block03", + "rworldsocialdistrict_props_block04", + "rworldsocialdistrict_vista", + "rworldsocialdistrict_beacons" + }; + + public bool SendLevelStreamingStatus(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + int delayMilliseconds = DistrictConfig.ReadInt("APB_STREAM_SEND_DELAY_MS", "StreamingSendDelayMilliseconds", 750, 0, 30000); + if (delayMilliseconds > 0) + { + DistrictLogger.Log(LogLevel.Info, "District Stream RPC", + "Waiting {0} ms after the district-enter answer before continuing startup/streaming RPCs.", delayMilliseconds); + Thread.Sleep(delayMilliseconds); + } + + // The GRI should already have opened immediately after JOIN. Keep this + // idempotent ensure here so startup cannot continue without a tracked GRI. + bool griOpened = _griStartup.OpenGriBeforeStreaming(endpoint, account); + DistrictLogger.Log(griOpened ? LogLevel.Success : LogLevel.Error, "District GRI Startup", + "Early GRI availability check for account {0} completed={1}.", account.GetId(), griOpened ? 1 : 0); + if (!griOpened) + return false; + + int griSettleMilliseconds = DistrictConfig.ReadInt("APB_GRI_PRE_STREAM_SETTLE_MS", "GriPreStreamSettleMilliseconds", 500, 0, 5000); + if (griSettleMilliseconds > 0) + { + DistrictLogger.Log(LogLevel.Info, "District GRI Startup", + "Waiting {0} ms after the GRI actor open before sending level-streaming RPCs.", griSettleMilliseconds); + Thread.Sleep(griSettleMilliseconds); + } + + bool controllerStartupSent = SendPreStreamingControllerStartup(endpoint, account); + DistrictLogger.Log(controllerStartupSent ? LogLevel.Success : LogLevel.Error, "District MapSelect Startup", + "Pre-stream HUD / candidate InitialState / MapSelect sequence for account {0} completed={1}.", + account.GetId(), controllerStartupSent ? 1 : 0); + if (!controllerStartupSent) + return false; + + int mapSelectSettleMilliseconds = DistrictConfig.ReadInt("APB_MAPSELECT_PRE_STREAM_SETTLE_MS", "MapSelectPreStreamSettleMilliseconds", 250, 0, 5000); + if (mapSelectSettleMilliseconds > 0) + { + DistrictLogger.Log(LogLevel.Info, "District MapSelect Startup", + "Waiting {0} ms for the controller to enter MapSelect before level-streaming RPCs.", mapSelectSettleMilliseconds); + Thread.Sleep(mapSelectSettleMilliseconds); + } + + List plan = DistrictConfig.ReadStreamingPlan(); + int interRpcDelayMilliseconds = DistrictConfig.ReadInt("APB_STREAM_INTER_RPC_DELAY_MS", "StreamingInterRpcDelayMilliseconds", 0, 0, 1000); + + foreach (StreamingPlanEntry entry in plan) + { + string level = entry.PackageName; + + byte[] packet = PacketBuilders.BuildLevelStreamingStatusPacket( + account.AllocateServerPacketId(), + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldUpdateLevelStreaming, + DistrictConfig.PlayerControllerFieldMax, + level, + entry.ShouldBeLoaded, + entry.ShouldBeVisible, + entry.ShouldBlockOnLoad, + DistrictConfig.ReadSetting("APB_NAME_INCLUDES_NUMBER", "NameIncludesNumber", "0") == "1", + int.Parse(DistrictConfig.ReadSetting("APB_STREAM_BOOL_COUNT", "StreamBoolCount", "3"))); + + DistrictLogger.Log(LogLevel.Info, "District Stream RPC", + "ClientUpdateLevelStreamingStatus package={0} loaded={1} visible={2} block={3} clearBytes={4} clear={5}", + level, + entry.ShouldBeLoaded ? 1 : 0, + entry.ShouldBeVisible ? 1 : 0, + entry.ShouldBlockOnLoad ? 1 : 0, + packet.Length, + Diagnostics.Hex(packet, 256)); + + if (!_handshake.SendProtectedPacket(endpoint, account, packet, "LEVEL-STREAM")) + return false; + + if (interRpcDelayMilliseconds > 0) + Thread.Sleep(interRpcDelayMilliseconds); + } + + // Setting the status only marks levels as wanted. Flushing makes the + // client commit the pending loads. + if (DistrictConfig.ReadSetting("APB_FLUSH_LEVEL_STREAMING", "FlushLevelStreaming", "1") == "1") + { + byte[] flush = PacketBuilders.BuildActorVoidFieldPacket( + account.AllocateServerPacketId(), + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldFlushLevelStreaming, + DistrictConfig.PlayerControllerFieldMax); + + DistrictLogger.Log(LogLevel.Info, "District Stream RPC", + "ClientFlushLevelStreaming clearBytes={0} clear={1}", flush.Length, Diagnostics.Hex(flush, 128)); + + _handshake.SendProtectedPacket(endpoint, account, flush, "FLUSH-STREAMING"); + } + + DistrictMap activeDistrictMap = DistrictConfig.GetConfiguredDistrictMap(); + if (DistrictConfig.IsActionDistrict(activeDistrictMap)) + { + bool waterfront = activeDistrictMap == DistrictMap.Waterfront; + int markerDelayMilliseconds = DistrictConfig.ReadInt( + waterfront ? "APB_WATERFRONT_MARKER_POST_FLUSH_DELAY_MS" : "APB_FINANCIAL_MARKER_POST_FLUSH_DELAY_MS", + waterfront ? "WaterfrontMarkerPostFlushDelayMilliseconds" : "FinancialMarkerPostFlushDelayMilliseconds", + 8000, 1000, 30000); + + DistrictLogger.Log(LogLevel.Info, "District Enter Lifecycle", + "{0} profile: waiting {1} ms after streaming flush before opening spawn-zone actors and publishing markers.", + DistrictConfig.DistrictMapName(activeDistrictMap), markerDelayMilliseconds); + Thread.Sleep(markerDelayMilliseconds); + + if (!_spawnZone.SendSpawnZoneHudMarker(endpoint, account)) + { + DistrictLogger.Log(LogLevel.Error, "District Enter Lifecycle", + "{0} post-flush spawn-zone marker publication failed.", DistrictConfig.DistrictMapName(activeDistrictMap)); + return false; + } + } + + DistrictLogger.Log(LogLevel.Success, "District Handshake", + "Requested {0} streaming sublevel(s) for account {1}.", plan.Count, account.GetId()); + + return true; + } + + // ------------------------------------------------------------------ + // Pre-stream controller startup (HUD / initial state / MapSelect) + // ------------------------------------------------------------------ + + public bool SendPreStreamingControllerStartup(IPEndPoint endpoint, Account account) + { + if (account == null) + return false; + + if (!DistrictConfig.ReadBool("APB_ENABLE_GRI_STARTUP", "EnableGriStartup", true)) + { + DistrictLogger.Log(LogLevel.Info, "District MapSelect Startup", "Controller startup disabled because EnableGriStartup=0."); + return false; + } + + int delayMilliseconds = DistrictConfig.ReadInt("APB_GRI_INITIAL_STATE_DELAY_MS", "GriInitialStateDelayMilliseconds", 250, 0, 5000); + if (delayMilliseconds > 0) + { + DistrictLogger.Log(LogLevel.Info, "District MapSelect Startup", + "Waiting {0} ms after the accepted GRI open before controller startup RPCs.", delayMilliseconds); + Thread.Sleep(delayMilliseconds); + } + + int configuredCharacterUid = DistrictConfig.ReadInt("APB_CHARACTER_UID", "CharacterUID", 1, 1, int.MaxValue); + byte configuredFaction = (byte)DistrictConfig.ReadInt("APB_CHARACTER_FACTION", "CharacterFaction", 1, 0, 4); + byte configuredGender = (byte)DistrictConfig.ReadInt("APB_CHARACTER_GENDER", "CharacterGender", 1, 0, 4); + + bool hasDatabaseCharacter = account.HasCharacterProfile(); + uint databaseCharacterId = hasDatabaseCharacter ? account.GetCharacterId() : 0u; + + int characterUid = hasDatabaseCharacter && databaseCharacterId <= int.MaxValue + ? (int)databaseCharacterId + : configuredCharacterUid; + + byte faction = hasDatabaseCharacter ? account.GetCharacterFaction() : configuredFaction; + byte gender = hasDatabaseCharacter ? account.GetCharacterGender() : configuredGender; + + DistrictLogger.Log(hasDatabaseCharacter ? LogLevel.Success : LogLevel.Warn, "District Character Handoff", + "Controller startup character source={0} account={1} CharacterUID={2} faction={3} gender={4} characterName='{5}' clanName='{6}' appearanceVersion={7} appearanceBytes={8}.", + hasDatabaseCharacter ? "WorldServer/SQL" : "HandshakeProbe.ini", + account.GetId(), + characterUid, + faction, + gender, + hasDatabaseCharacter ? account.GetCharacterName() : DistrictConfig.ReadSetting("APB_CHARACTER_NAME", "CharacterName", "Reborn"), + hasDatabaseCharacter ? account.GetClanName() : DistrictConfig.ReadSetting("APB_CHARACTER_CLAN_NAME", "CharacterClanName", ""), + hasDatabaseCharacter ? account.GetAppearanceVersion() : 0u, + hasDatabaseCharacter ? account.GetAppearanceSize() : 0u); + + bool sentAnything = false; + + // ClientSetHUD: spawn the cHUDBase class so the HUD exists before MapSelect. + if (DistrictConfig.ReadBool("APB_SEND_CLIENT_SET_HUD", "SendClientSetHUD", true)) + { + uint hudPacketId = account.AllocateServerPacketId(); + uint hudClassNetIndex = DistrictConfig.GlobalNetIndex("APBGame", DistrictConfig.HudClassObjectIndex); + + byte[] hudPacket = PacketBuilders.BuildClientSetHudPacket( + hudPacketId, + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldClientSetHud, + DistrictConfig.PlayerControllerFieldMax, + hudClassNetIndex, + 0u); + + bool sentHud = _handshake.SendProtectedPacket(endpoint, account, hudPacket, "CLIENT-SET-HUD"); + DistrictLogger.Log(sentHud ? LogLevel.Success : LogLevel.Error, "District HUD Startup", + "Sent corrected ClientSetHUD field={0} serverPacketId={1} cHUDBase objectIndex={2} globalNetIndex={3} scoringClass=None presenceBits=corrected before MapSelect. Confirm ACK({1}) and watch for Spawning actor class 'cHUDBase'.", + DistrictConfig.FieldClientSetHud, hudPacketId, DistrictConfig.HudClassObjectIndex, hudClassNetIndex); + + if (!sentHud) + return false; + + sentAnything = true; + + int hudSettleMilliseconds = DistrictConfig.ReadInt("APB_HUD_PRE_MAPSELECT_SETTLE_MS", "HudPreMapSelectSettleMilliseconds", 250, 0, 5000); + if (hudSettleMilliseconds > 0) + Thread.Sleep(hudSettleMilliseconds); + } + + // ClientSetInitialState: give the local player CharacterUID, faction and + // gender before ClientRestart starts character streaming. + if (DistrictConfig.ReadBool("APB_SEND_CLIENT_INITIAL_STATE", "SendClientInitialState", false)) + { + uint initialStateField = DistrictConfig.ClientSetInitialStateWireField(); + uint initialStatePacketId = account.AllocateServerPacketId(); + + byte[] initialState = PacketBuilders.BuildClientSetInitialStatePacket( + initialStatePacketId, + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + initialStateField, + DistrictConfig.PlayerControllerFieldMax, + characterUid, + faction, + gender); + + bool sentInitialState = _handshake.SendProtectedPacket(endpoint, account, initialState, "CLIENT-SET-INITIAL-STATE-PRE-STREAM"); + DistrictLogger.Log(sentInitialState ? LogLevel.Success : LogLevel.Error, "District MapSelect Startup", + "Sent ClientSetInitialState candidateField={0} serverPacketId={1} CharacterUID={2} Faction={3}/5 Gender={4}/5 rpcPresenceBits=corrected clearBytes={5} clear={6} before level streaming. Confirm ACK({1}).", + initialStateField, initialStatePacketId, characterUid, faction, gender, initialState.Length, Diagnostics.Hex(initialState, 128)); + + if (!sentInitialState) + return false; + + sentAnything = true; + } + + // ClientReceiveCharacterInfo: register the local character so the client + // can request field 489 ServerRequestCharacterData. + if (DistrictConfig.ReadBool("APB_SEND_CLIENT_RECEIVE_CHARACTER_INFO", "SendClientReceiveCharacterInfo", false)) + { + string characterName = hasDatabaseCharacter && account.GetCharacterName().Length > 0 + ? account.GetCharacterName() + : DistrictConfig.ReadSetting("APB_CHARACTER_NAME", "CharacterName", "Reborn"); + string clanName = hasDatabaseCharacter + ? account.GetClanName() + : DistrictConfig.ReadSetting("APB_CHARACTER_CLAN_NAME", "CharacterClanName", ""); + + int clanUid = DistrictConfig.ReadInt("APB_CHARACTER_CLAN_UID", "CharacterClanUID", 0, 0, int.MaxValue); + int groupId = DistrictConfig.ReadInt("APB_CHARACTER_GROUP_ID", "CharacterGroupID", 0, 0, int.MaxValue); + int sideId = DistrictConfig.ReadInt("APB_CHARACTER_SIDE_ID", "CharacterSideID", 0, 0, int.MaxValue); + + uint characterInfoField = DistrictConfig.ClientReceiveCharacterInfoWireField(); + uint packetId = account.AllocateServerPacketId(); + + byte[] characterInfo = PacketBuilders.BuildClientReceiveCharacterInfoPacket( + packetId, + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + characterInfoField, + DistrictConfig.PlayerControllerFieldMax, + (int)account.GetId(), + characterUid, + clanUid, + groupId, + sideId, + characterName, + clanName, + faction, + 5u); + + bool sentCharacterInfo = _handshake.SendProtectedPacket(endpoint, account, characterInfo, "CLIENT-RECEIVE-CHARACTER-INFO"); + DistrictLogger.Log(sentCharacterInfo ? LogLevel.Success : LogLevel.Error, "District Character Info", + "Sent ClientReceiveCharacterInfo field={0} serverPacketId={1} accountUID={2} characterUID={3} clanUID={4} groupID={5} sideID={6} characterName='{7}' clanName='{8}' faction={9}/5 clearBytes={10} clear={11}. Local identity registration RPC.", + characterInfoField, packetId, account.GetId(), characterUid, clanUid, groupId, sideId, characterName, clanName, faction, + characterInfo.Length, Diagnostics.Hex(characterInfo, 256)); + + if (!sentCharacterInfo) + return false; + + sentAnything = true; + + int characterInfoSettleMilliseconds = DistrictConfig.ReadInt("APB_CHARACTER_INFO_SETTLE_MS", "CharacterInfoSettleMilliseconds", 100, 0, 5000); + if (characterInfoSettleMilliseconds > 0) + Thread.Sleep(characterInfoSettleMilliseconds); + } + + // ClientPrecacheCustomisation: cache the local character's descriptor GUID. + if (DistrictConfig.ReadBool("APB_SEND_CLIENT_PRECACHE_CUSTOMISATION", "SendClientPrecacheCustomisation", true)) + { + byte[] appearance = account.GetAppearance(); + + if (TryExtractCharacterCustomisationGuid(appearance, out uint[] characterGuid)) + { + uint precachePacketId = account.AllocateServerPacketId(); + + byte[] precachePacket = PacketBuilders.BuildClientPrecacheCustomisationPacket( + precachePacketId, + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + DistrictConfig.FieldClientPrecacheCustomisation, + DistrictConfig.PlayerControllerFieldMax, + characterGuid, + (byte)0, // character customisation + true); // local player + + bool sentPrecache = _handshake.SendProtectedPacket(endpoint, account, precachePacket, "CLIENT-PRECACHE-CHARACTER-CUSTOMISATION"); + DistrictLogger.Log(sentPrecache ? LogLevel.Success : LogLevel.Error, "District Character Customisation", + "Sent ClientPrecacheCustomisation field={0} serverPacketId={1} guid={2} type=character(0) localPlayer=1 appearanceBytes={3} clearBytes={4} clear={5}. The client should log 'Caching m_LocalCharacterPrecacheGUID'.", + DistrictConfig.FieldClientPrecacheCustomisation, precachePacketId, FormatGuid(characterGuid), + appearance.Length, precachePacket.Length, Diagnostics.Hex(precachePacket, 128)); + + if (!sentPrecache) + return false; + + sentAnything = true; + + int precacheSettleMilliseconds = DistrictConfig.ReadInt("APB_CHARACTER_PRECACHE_SETTLE_MS", "CharacterPrecacheSettleMilliseconds", 100, 0, 5000); + if (precacheSettleMilliseconds > 0) + Thread.Sleep(precacheSettleMilliseconds); + } + else + { + DistrictLogger.Log(LogLevel.Warn, "District Character Customisation", + "ClientPrecacheCustomisation was enabled, but the database appearance blob is too short or has no GUID at native descriptor offset 16. account={0} appearanceBytes={1}.", + account.GetId(), appearance.Length); + } + } + + int interRpcDelayMilliseconds = DistrictConfig.ReadInt("APB_MAPSELECT_INTER_RPC_DELAY_MS", "MapSelectInterRpcDelayMilliseconds", 50, 0, 2000); + if (interRpcDelayMilliseconds > 0) + Thread.Sleep(interRpcDelayMilliseconds); + + // ClientGoToSpawnZoneSelectScreen: the spawn-map handoff that closes the + // HostingOpProgress "Loading district" overlay. + if (DistrictConfig.ReadBool("APB_SEND_MAPSELECT_RPC", "SendClientGoToSpawnZoneSelectScreen", true)) + { + uint mapSelectField = DistrictConfig.ClientGoToSpawnZoneSelectScreenWireField(); + uint mapSelectPacketId = account.AllocateServerPacketId(); + + byte[] mapSelect = PacketBuilders.BuildClientGoToSpawnZoneSelectScreenPacket( + mapSelectPacketId, + DistrictConfig.ControllerChannel, + ChannelSequenceAllocator.Allocate(endpoint, DistrictConfig.ControllerChannel), + mapSelectField, + DistrictConfig.PlayerControllerFieldMax, + faction); + + bool sentMapSelect = _handshake.SendProtectedPacket(endpoint, account, mapSelect, "CLIENT-GO-TO-SPAWN-ZONE-SELECT"); + DistrictLogger.Log(sentMapSelect ? LogLevel.Success : LogLevel.Error, "District MapSelect Startup", + "Sent corrected ClientGoToSpawnZoneSelectScreen liveWireField={0} serverPacketId={1} Faction={2}/5 presenceBit=1 before level streaming. Confirm ACK({1}). The client should now log 'BeginState() - Map Select'.", + mapSelectField, mapSelectPacketId, faction); + + if (!sentMapSelect) + return false; + + sentAnything = true; + } + + return sentAnything; + } + + // ------------------------------------------------------------------ + // Appearance helpers (ported from BuildCharacterCustomisationTransferPayload + // + ExtractCharacterCustomisationGuid) + // ------------------------------------------------------------------ + + private const uint CompressedAssetCustomisationMinimumVersion = 0xAB900010u; + + /// + /// Strips the WorldServer handoff blob's outer four-byte prefix when it + /// precedes a valid cCompressedAssetCustomisation version DWORD. + /// + public static byte[] BuildCharacterCustomisationTransferPayload(byte[] appearance) + { + if (appearance.Length == 0) + return Array.Empty(); + + int strippedPrefixBytes = 0; + + if (appearance.Length >= 8) + { + uint firstValue = ReadLittleEndianUInt32(appearance, 0); + uint shiftedValue = ReadLittleEndianUInt32(appearance, 4); + + if (firstValue < CompressedAssetCustomisationMinimumVersion && + shiftedValue >= CompressedAssetCustomisationMinimumVersion) + { + strippedPrefixBytes = 4; + } + } + + var result = new byte[appearance.Length - strippedPrefixBytes]; + Array.Copy(appearance, strippedPrefixBytes, result, 0, result.Length); + return result; + } + + /// + /// Reads the first FGuid from a cCompressedAssetCustomisation stream, + /// which begins at raw offset 16 (four little-endian DWORDs, not an + /// RFC-4122 GUID). + /// + public static bool TryExtractCharacterCustomisationGuid(byte[] appearance, out uint[] guid) + { + guid = new uint[4]; + + const int guidOffset = 16; + const int guidBytes = 16; + + if (appearance.Length < guidOffset + guidBytes) + return false; + + guid[0] = ReadLittleEndianUInt32(appearance, guidOffset + 0); + guid[1] = ReadLittleEndianUInt32(appearance, guidOffset + 4); + guid[2] = ReadLittleEndianUInt32(appearance, guidOffset + 8); + guid[3] = ReadLittleEndianUInt32(appearance, guidOffset + 12); + + return guid[0] != 0u || guid[1] != 0u || guid[2] != 0u || guid[3] != 0u; + } + + private static uint ReadLittleEndianUInt32(byte[] bytes, int offset) + => (uint)bytes[offset] | + ((uint)bytes[offset + 1] << 8) | + ((uint)bytes[offset + 2] << 16) | + ((uint)bytes[offset + 3] << 24); + + public static string FormatGuid(uint[] guid) + => $"{guid[0]:X8}{guid[1]:X8}{guid[2]:X8}{guid[3]:X8}"; +} diff --git a/DistrictServerCSharp/WorldControl/WorldControl.cs b/DistrictServerCSharp/WorldControl/WorldControl.cs new file mode 100644 index 0000000..83057d3 --- /dev/null +++ b/DistrictServerCSharp/WorldControl/WorldControl.cs @@ -0,0 +1,349 @@ +using System.Text; +using DistrictServerCSharp.Accounts; +using DistrictServerCSharp.Config; +using DistrictServerCSharp.Core; +using DistrictServerCSharp.Logging; +using DistrictServerCSharp.Net; +using DistrictServerCSharp.Protocol; + +namespace DistrictServerCSharp.World; + +/// +/// The district's TCP control-link conversation with the WorldServer, ported +/// from the WorldControl block of DistrictServer.cpp. Covers registration +/// (including the UDP-port append) and the character-handoff records +/// (phase 2 / 3 / 4 + legacy). +/// +public sealed class WorldControl +{ + private const int MaxAppearanceBytes = 1024 * 1024; + private const ushort MaxCharacterTextBytes = 4096; + + private readonly Network _network; + + public WorldControl(Network network) => _network = network; + + // ------------------------------------------------------------------ + // Registration + // ------------------------------------------------------------------ + + /// + /// Builds the 7-byte registration datagram. WorldServer's legacy listener + /// reads exactly four fields and decodes each as (byte - 0x30); new-style + /// multi-digit district types are sent as one wire byte (Waterfront 21 = + /// 0x30 + 21 = 0x45). Bytes 4..5 carry our UDP listen port (raw + /// little-endian) so one World Server can host several districts on + /// distinct ports. Older World Servers ignore it and fall back to their + /// configured advertised port. + /// + public static byte[] BuildRegistration() + { + int udpPort = DistrictConfig.ConfiguredDistrictUdpPort(); + + var registration = new byte[7]; + registration[0] = (byte)(0x30 + 0); + registration[1] = (byte)(0x30 + DistrictConfig.DistrictType(DistrictConfig.GetConfiguredDistrictMap())); + registration[2] = (byte)(0x30 + DistrictConfig.ConfiguredDistrictId()); + registration[3] = (byte)(0x30 + DistrictConfig.ConfiguredDistrictLanguage()); + registration[4] = (byte)(udpPort & 0xFF); + registration[5] = (byte)((udpPort >> 8) & 0xFF); + return registration; + } + + public bool ProcessRegistrationResponse(byte[] buffer) + { + if (buffer.Length < 2 || buffer[0] != (byte)'0') + return false; + + DistrictLogger.Log(LogLevel.Info, "WorldControl", "Received district registration response"); + + switch (buffer[1]) + { + case (byte)'0': + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Not allowed to host a district"); + return false; + case (byte)'1': + // WorldServer reply 0x31 = same-IP re-registration REPLACED the + // previous entry (older builds send 0x31 then 0x33; the replace + // is real and the district is registered). Treat as success so + // a district restart can re-register without a world restart. + DistrictLogger.Log(LogLevel.Success, "WorldControl", "Registered at World Server (replaced previous entry)"); + return true; + case (byte)'2': + DistrictLogger.Log(LogLevel.Error, "WorldControl", "District already exists (different host)"); + return false; + case (byte)'3': + DistrictLogger.Log(LogLevel.Success, "WorldControl", "Registered at World Server"); + return true; + case (byte)'4': + DistrictLogger.Log(LogLevel.Error, "WorldControl", "District ID may not be zero"); + return false; + default: + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Unknown registration response 0x{0:X2}", buffer[1]); + return false; + } + } + + // ------------------------------------------------------------------ + // World-control records + // ------------------------------------------------------------------ + + public bool ProcessWorldControlRecord(byte prefix) + { + return prefix switch + { + 0x34 => ReceivePhase4CharacterHandoff(), + 0x33 => ReceivePhase3CharacterHandoff(), + 0x32 => ReceivePhase2Handoff(), + 0x31 => ReceiveLegacyHandoff(), + _ => ReportUnknownPrefix(prefix) + }; + } + + private bool ReportUnknownPrefix(byte prefix) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Unknown WorldServer control prefix 0x{0:X2}", prefix); + return false; + } + + private static uint ReadU32(byte[] bytes, int offset) + => (uint)bytes[offset] | + ((uint)bytes[offset + 1] << 8) | + ((uint)bytes[offset + 2] << 16) | + ((uint)bytes[offset + 3] << 24); + + private static ushort ReadU16(byte[] bytes, int offset) + => (ushort)(bytes[offset] | (bytes[offset + 1] << 8)); + + private bool ReceivePhase4CharacterHandoff() + { + const int fixedPayloadBytes = 55; + + byte[]? payload = _network.Receive(fixedPayloadBytes); + if (payload == null) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Truncated phase-4 character handoff header"); + return false; + } + + uint accountId = ReadU32(payload, 0); + byte[] authToken = payload[4..24]; + byte[] encryptionKey = payload[24..40]; + uint characterId = ReadU32(payload, 40); + byte faction = payload[44]; + byte gender = payload[45]; + byte appearanceVersion = payload[46]; + ushort characterNameLength = ReadU16(payload, 47); + ushort clanNameLength = ReadU16(payload, 49); + uint appearanceLength = ReadU32(payload, 51); + + if (accountId == 0 || characterId == 0) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Invalid phase-4 character handoff: account={0} character={1}", accountId, characterId); + return false; + } + + if (characterNameLength > MaxCharacterTextBytes || clanNameLength > MaxCharacterTextBytes) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", + "Rejected phase-4 character text lengths for account {0}: name={1} clan={2} maximum={3}", + accountId, characterNameLength, clanNameLength, MaxCharacterTextBytes); + return false; + } + + if (appearanceLength > MaxAppearanceBytes) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", + "Rejected phase-4 appearance length {0} for account {1} (maximum={2})", + appearanceLength, accountId, MaxAppearanceBytes); + return false; + } + + long variableLength = (long)characterNameLength + clanNameLength + appearanceLength; + if (variableLength > int.MaxValue) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", + "Rejected phase-4 variable payload length {0} for account {1}", variableLength, accountId); + return false; + } + + byte[] variableBytes = Array.Empty(); + if (variableLength > 0) + { + byte[]? variablePayload = _network.Receive((int)variableLength); + if (variablePayload == null) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", + "Truncated phase-4 variable payload for account {0}: expected={1}", accountId, variableLength); + return false; + } + + variableBytes = variablePayload; + } + + int offset = 0; + string characterName = Encoding.ASCII.GetString(variableBytes, offset, characterNameLength); + offset += characterNameLength; + string clanName = Encoding.ASCII.GetString(variableBytes, offset, clanNameLength); + offset += clanNameLength; + byte[] appearance = variableBytes[offset..]; + + Account account = AccountManager.AddOrUpdate(accountId, authToken, encryptionKey, out bool replaced); + + account.SetCharacterProfile(characterId, faction, gender, appearanceVersion, characterName, clanName, appearance); + + Lifecycle.ResetGriStartup(accountId); + Lifecycle.ResetPawnAckGated(accountId); + + DistrictLogger.Log( + replaced ? LogLevel.Warn : LogLevel.Success, + "WorldControl", + "{0} phase-4 character handoff: account={1} character={2} faction={3} gender={4} appearanceVersion={5} characterName='{6}' clanName='{7}' appearanceBytes={8} preview={9} AUTHKEY={10} XTEA={11}", + replaced ? "Replaced" : "Received", + accountId, + characterId, + faction, + gender, + appearanceVersion, + characterName, + clanName, + appearance.Length, + appearance.Length == 0 ? "" : Diagnostics.Hex(appearance, 24), + Diagnostics.Hex(authToken, 20), + Diagnostics.Hex(encryptionKey, 16)); + + return true; + } + + private bool ReceivePhase3CharacterHandoff() + { + const int fixedPayloadBytes = 51; + + byte[]? payload = _network.Receive(fixedPayloadBytes); + if (payload == null) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Truncated phase-3 character handoff header"); + return false; + } + + uint accountId = ReadU32(payload, 0); + byte[] authToken = payload[4..24]; + byte[] encryptionKey = payload[24..40]; + uint characterId = ReadU32(payload, 40); + byte faction = payload[44]; + byte gender = payload[45]; + byte appearanceVersion = payload[46]; + uint appearanceLength = ReadU32(payload, 47); + + if (accountId == 0 || characterId == 0) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Invalid phase-3 character handoff: account={0} character={1}", accountId, characterId); + return false; + } + + if (appearanceLength > MaxAppearanceBytes) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", + "Rejected phase-3 appearance length {0} for account {1} (maximum={2})", + appearanceLength, accountId, MaxAppearanceBytes); + return false; + } + + byte[] appearance = Array.Empty(); + if (appearanceLength > 0) + { + byte[]? appearancePayload = _network.Receive((int)appearanceLength); + if (appearancePayload == null) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", + "Truncated phase-3 appearance payload for account {0}: expected={1}", accountId, appearanceLength); + return false; + } + + appearance = appearancePayload; + } + + Account account = AccountManager.AddOrUpdate(accountId, authToken, encryptionKey, out bool replaced); + + account.SetCharacterProfile(characterId, faction, gender, appearanceVersion, string.Empty, string.Empty, appearance); + + Lifecycle.ResetGriStartup(accountId); + Lifecycle.ResetPawnAckGated(accountId); + + DistrictLogger.Log( + replaced ? LogLevel.Warn : LogLevel.Success, + "WorldControl", + "{0} phase-3 character handoff: account={1} character={2} faction={3} gender={4} appearanceVersion={5} appearanceBytes={6} preview={7} AUTHKEY={8} XTEA={9}", + replaced ? "Replaced" : "Received", + accountId, + characterId, + faction, + gender, + appearanceVersion, + appearance.Length, + appearance.Length == 0 ? "" : Diagnostics.Hex(appearance, 24), + Diagnostics.Hex(authToken, 20), + Diagnostics.Hex(encryptionKey, 16)); + + return true; + } + + private bool ReceivePhase2Handoff() + { + byte[]? payload = _network.Receive(40); + if (payload == null) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Truncated phase-2 account handoff"); + return false; + } + + uint accountId = ReadU32(payload, 0); + byte[] authToken = payload[4..24]; + byte[] encryptionKey = payload[24..40]; + + Account account = AccountManager.AddOrUpdate(accountId, authToken, encryptionKey, out bool replaced); + account.ClearCharacterProfile(); + + Lifecycle.ResetGriStartup(accountId); + Lifecycle.ResetPawnAckGated(accountId); + + DistrictLogger.Log( + replaced ? LogLevel.Warn : LogLevel.Info, + "WorldControl", + "{0} account handoff: account={1} AUTHKEY={2} XTEA={3}", + replaced ? "Replaced" : "Received", + accountId, + Diagnostics.Hex(authToken, 20), + Diagnostics.Hex(encryptionKey, 16)); + + return true; + } + + private bool ReceiveLegacyHandoff() + { + byte[]? payload = _network.Receive(17); + if (payload == null) + { + DistrictLogger.Log(LogLevel.Error, "WorldControl", "Truncated legacy account handoff"); + return false; + } + + byte accountId = payload[0]; + byte[] unknownAuth = new byte[20]; + byte[] encryptionKey = payload[1..17]; + + Account account = AccountManager.AddOrUpdate(accountId, unknownAuth, encryptionKey, out bool replaced); + account.ClearCharacterProfile(); + + Lifecycle.ResetGriStartup(accountId); + Lifecycle.ResetPawnAckGated(accountId); + + DistrictLogger.Log( + LogLevel.Warn, + "WorldControl", + "Received legacy account handoff for {0} without expected AUTHKEY; authentication comparison will be unavailable.", + accountId); + + return true; + } +} diff --git a/Tools/check_deploy_sync.py b/Tools/check_deploy_sync.py new file mode 100644 index 0000000..3d2aef6 --- /dev/null +++ b/Tools/check_deploy_sync.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""check_deploy_sync.py - live-vs-source DistrictServer deployment drift detector. + +Prevents the live-server-lagging-source problem from recurring: the live +districts run the C# port copied into the instance folders +(APB SERVER/Districts/{Social,Financial,Waterfront}/), and that copy can go +stale when the DistrictServerCSharp source changes but nobody redeploys (the +2026-08-17 12:11 -> 12:47 gap). This script compares each instance's +DistrictServer.dll sha256 against the current built output and reports any +instance still running an older build. + +Buckets: + MATCH - instance dll sha equals the fresh build output (in sync). + STALE - instance dll differs from the fresh build output (live server is + running an older build than the source). + MISSING - the instance dll (or the reference build output) does not exist. + +Exit code: 0 = all instances match; 1 = any instance is stale/missing or the +reference build output is missing. + +Usage: py -3.11 Tools/check_deploy_sync.py [--build] [--verbose] + --build run `dotnet build -c Release` in DistrictServerCSharp first so + the reference output is freshly built (recommended before a + deploy). + --verbose also print the full sha256 values, not just the short prefix. + +Deploy recipe after a green run: dotnet publish -c Release -f net8.0 -o +_build_scratch/ds_cs_publish -p:UseAppHost=true, then copy +DistrictServer.exe/.dll/.deps.json/.runtimeconfig.json/.pdb into all three +instance folders and restart each district (see PORTING.md §6). +""" + +import argparse +import hashlib +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # _apbemu_ref +CSHARP_DIR = os.path.join(ROOT, "DistrictServerCSharp") +REFERENCE = os.path.join(CSHARP_DIR, "bin", "Release", "net8.0", "DistrictServer.dll") + +# (instance folder name, advertised UDP port) +INSTANCES = [("Social", 6969), ("Financial", 6970), ("Waterfront", 6971)] + + +def sha256_short(path, full=False): + """Returns (hexdigest or short prefix, error) for a file, or (None, msg).""" + if not os.path.isfile(path): + return None, "file not found" + try: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + except OSError as exc: + return None, "unreadable: %s" % exc + hexdigest = digest.hexdigest() + return (hexdigest if full else hexdigest[:16]), None + + +def build_reference(): + print("Building DistrictServerCSharp (Release)...") + result = subprocess.run( + ["dotnet", "build", "-c", "Release"], + cwd=CSHARP_DIR, capture_output=True, text=True) + if result.returncode != 0: + print(" build FAILED:") + for line in result.stdout.splitlines() + result.stderr.splitlines(): + if "error" in line.lower() or "warning" in line.lower(): + print(" %s" % line) + return False + print(" build OK (0 errors)") + return True + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build", action="store_true", + help="run `dotnet build -c Release` first so the " + "reference output is freshly built") + parser.add_argument("--verbose", action="store_true", + help="print full sha256 values, not short prefixes") + args = parser.parse_args() + + if args.build and not build_reference(): + return 1 + + reference_sha, reference_error = sha256_short(REFERENCE, args.verbose) + if reference_sha is None: + print("Reference build output missing: %s (%s)" % (REFERENCE, reference_error)) + print("Run `dotnet build -c Release` in DistrictServerCSharp first, " + "or pass --build.") + return 1 + + rows = [] + ok = True + for folder, port in INSTANCES: + path = os.path.join(ROOT, "APB SERVER", "Districts", folder, "DistrictServer.dll") + instance_sha, error = sha256_short(path, args.verbose) + if instance_sha is None: + status = "MISSING (%s)" % error + ok = False + elif instance_sha == reference_sha: + status = "MATCH %s" % instance_sha + else: + status = ("STALE deployed=%s expected=%s " + "(live server lags the source - redeploy!)" + % (instance_sha, reference_sha)) + ok = False + rows.append((folder, port, status)) + + width = max(len(r[0]) for r in rows) + bar = "-" * 60 + print(bar) + print("DistrictServer deployment sync - %s" % REFERENCE) + print(" reference: %s" % reference_sha) + print(bar) + for folder, port, status in rows: + print(" %-*s (UDP %d) %s" % (width, folder, port, status)) + print(bar) + print(" overall: %s" % ("all instances match the source" if ok + else "DRIFT - at least one instance is not the " + "current build")) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Tools/check_port_sync.py b/Tools/check_port_sync.py new file mode 100644 index 0000000..4d28b98 --- /dev/null +++ b/Tools/check_port_sync.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""check_port_sync.py — C++ -> C# DistrictServer port drift detector. + +Extracts the function inventory from the C++ DistrictServer source and diffs it +against DistrictServerCSharp/PORTING.md (the §3 function map + §7 not-ported +list). This is the automated half of PORTING.md's §8 mirror checklist: run it +after any C++ DistrictServer change to see which functions have no documented +C# home. + +Buckets: + MAPPED - listed in PORTING.md §3 with a C# home. + NOT-PORTED - listed in PORTING.md §7 (or §3 with a ❌ C# cell): explicitly + not ported yet - expected, informational. + UNMAPPED - in the C++ source but absent from both. Each is annotated: + * "traces in C# code" -> likely ported but PORTING.md is + missing a row (documentation gap). + * "no C# trace" -> genuinely no C# home (port gap). + +Exit code: 0 = no port gaps; 1 = at least one C++ function with no C# trace. + +Usage: py -3.11 Tools/check_port_sync.py [--all] [--verbose] + --all also inventory ApbUdp.cpp (the wire layer). Default is + DistrictServer.cpp only, which matches PORTING.md's §3 + granularity. + --verbose dump the full MAPPED / NOT-PORTED lists too. +""" + +import argparse +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # _apbemu_ref +CPP_DIR = os.path.join(ROOT, "DistrictServer") +PORTING = os.path.join(ROOT, "DistrictServerCSharp", "PORTING.md") +CSHARP_DIR = os.path.join(ROOT, "DistrictServerCSharp") + +# DistrictServer.cpp is the monolith the §3 function map enumerates. ApbUdp.cpp +# (the wire layer) is covered by the §2 file map instead. +DEFAULT_CPP_FILES = ["DistrictServer.cpp"] +ALL_CPP_FILES = DEFAULT_CPP_FILES + ["ApbUdp.cpp"] + +# Known renames where the C# name differs from the C++ name (so the C#-trace +# hint does not false-flag them as port gaps). +RENAMES = { + "AllocateChannelSequence": "ChannelSequenceAllocator", + "BteaEncrypt": "DistrictCrypto.Encrypt", + "BteaDecrypt": "DistrictCrypto.Decrypt", + "BteaLoadWords": "DistrictCrypto.LoadWords", + "BteaStoreWords": "DistrictCrypto.StoreWords", + # The port lives on HandshakeService, not DistrictCrypto: the C++ oracle + # takes its packet BY VALUE and pads it, so the port has to allocate the + # padded copy itself. DistrictCrypto only exposes the in-place primitive. + "ProtectOutgoingPacket": "HandshakeService.ProtectOutgoingPacket", + # Account registry + lookup (AccountManager uses different names). + "AddOrUpdateAccount": "AccountManager.AddOrUpdate", + "FindAccount": "AccountManager.Find", + "FindAccountByEndpoint": "AccountManager.FindByEndpoint", + "EndpointForAccount": "AccountManager.FindByEndpoint", + # Post-possession locomotion unlock (renamed Arm/MaybeSend). + "ArmPostPossessionMovementUnlock": "PostPossessionMovementUnlock.Arm", + "MaybeSendPostPossessionMovementUnlock": "PostPossessionMovementUnlock.MaybeSend", + # Config helpers renamed onto DistrictConfig. + "ReadEnvironment": "DistrictConfig.ReadSetting", + "ReadHandshakeSetting": "DistrictConfig.ReadSetting", + "ReadLittleEndianUInt16": "WorldControl.ReadU16", + "ReadLittleEndianUInt32": "WorldControl.ReadU32", + "ParseExactHexBytes": "TryParseExactHexBytes", + "PacketCaptureEnabled": "DistrictConfig.ReadBool", + "PacketHexLogEnabled": "DistrictConfig.ReadBool", +} + +CONTROL_KEYWORDS = { + "if", "for", "while", "switch", "return", "else", "do", "case", "sizeof", + "new", "delete", "catch", "throw", "static_cast", "dynamic_cast", + "const_cast", "reinterpret_cast", "decltype", "alignof", "offsetof", +} + + +# --------------------------------------------------------------------------- +# C++ function inventory +# --------------------------------------------------------------------------- + +def strip_comments_strings(text): + text = re.sub(r"//[^\n]*", "", text) + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + text = re.sub(r'"(?:\\.|[^"\\])*"', '""', text) + text = re.sub(r"'(?:\\.|[^'\\])*'", "''", text) + return text + + +def is_plausible_return_type(prefix): + """True when the text before NAME( looks like a return type, not a call.""" + prefix = prefix.strip() + if not prefix: + return False # no return type on the line (not a free-function def here) + prefix = re.sub(r"^(static|const|inline|extern|__stdcall|__cdecl)\s+", "", prefix) + if re.search(r"[=;()+.\-/%\[\]{}]", prefix): + return False # expression / call context + if re.search(r"(return|else|case|do|sizeof|new|delete|throw)\s*$", prefix): + return False + return bool(re.search(r"[A-Za-z_]", prefix)) + + +def extract_cpp_functions(path): + text = strip_comments_strings(open(path, encoding="utf-8", errors="replace").read()) + funcs = set() + for match in re.finditer(r"\b([A-Za-z_]\w*)\s*\(", text): + name = match.group(1) + if name in CONTROL_KEYWORDS: + continue + line_start = text.rfind("\n", 0, match.start()) + 1 + before = text[line_start:match.start()] + if re.search(r"(\.|->|::)\s*$", before): + continue # member call / qualified call + if not is_plausible_return_type(before): + continue + # Find the matching close paren, then require '{' (definition, not ';'). + depth = 0 + index = match.end() - 1 # position of '(' + while index < len(text): + char = text[index] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + break + index += 1 + index += 1 + while index < len(text) and text[index] in " \t\r\n": + index += 1 + if index < len(text) and text[index] == "{": + funcs.add(name) + return funcs + + +# --------------------------------------------------------------------------- +# PORTING.md parsing +# --------------------------------------------------------------------------- + +def normalize_name(token): + token = token.strip() + token = re.sub(r"\(\)$", "", token) # 'main()' -> 'main' + return token + + +def parse_porting(path): + """Returns (mapped, not_ported) where mapped is a set of names/patterns.""" + mapped = set() + not_ported = set() + section = "" + for raw in open(path, encoding="utf-8").read().splitlines(): + line = raw.strip() + if line.startswith("## "): + section = line[3:].strip() + continue + if section.startswith("3.") and line.startswith("|") and "`" in line: + cells = [cell.strip() for cell in line.strip("|").split("|")] + if len(cells) < 2: + continue + cpp_cell, csharp_cell = cells[0], cells[1] + for token in re.findall(r"`([^`]+)`", cpp_cell): + name = normalize_name(token) + if not name: + continue + if "❌" in csharp_cell: + not_ported.add(name) + else: + mapped.add(name) + elif section.startswith("7."): # "## 7. NOT yet ported ..." + for token in re.findall(r"`([A-Za-z_]\w*)`", line): + not_ported.add(token) + return mapped, not_ported + + +def matches_any(name, names): + for candidate in names: + if "*" in candidate: + pattern = "^" + re.escape(candidate).replace(r"\*", ".*") + "$" + if re.match(pattern, name): + return True + elif name == candidate: + return True + return False + + +# --------------------------------------------------------------------------- +# C# trace hint +# --------------------------------------------------------------------------- + +def csharp_trace(name): + """True when the name (or a known rename) appears anywhere in the C# tree. + + Renames may point at a qualified "Class.Method"; C# definitions are bare + method names, so also search the part after the last dot. + """ + needle = RENAMES.get(name, name) + candidates = {needle} + if "." in needle: + candidates.add(needle.rsplit(".", 1)[1]) + for root, _dirs, files in os.walk(CSHARP_DIR): + if os.sep + "obj" in root or os.sep + "bin" in root: + continue + for filename in files: + if not filename.endswith(".cs"): + continue + path = os.path.join(root, filename) + try: + text = open(path, encoding="utf-8", errors="replace").read() + except OSError: + continue + for candidate in candidates: + if re.search(r"\b" + re.escape(candidate) + r"\b", text): + return True + return False + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--all", action="store_true", help="also inventory ApbUdp.cpp") + parser.add_argument("--verbose", action="store_true", help="dump MAPPED/NOT-PORTED too") + args = parser.parse_args() + + files = ALL_CPP_FILES if args.all else DEFAULT_CPP_FILES + inventory = set() + for filename in files: + inventory |= extract_cpp_functions(os.path.join(CPP_DIR, filename)) + + mapped, not_ported = parse_porting(PORTING) + + mapped_found = sorted(f for f in inventory if matches_any(f, mapped)) + not_ported_found = sorted(f for f in inventory if matches_any(f, not_ported)) + unmapped = sorted(f for f in inventory if not matches_any(f, mapped) and not matches_any(f, not_ported)) + + port_gaps = [f for f in unmapped if not csharp_trace(f)] + doc_gaps = [f for f in unmapped if csharp_trace(f)] + + print(f"C++ inventory ({', '.join(files)}): {len(inventory)} functions") + print(f"PORTING.md: {len(mapped)} mapped entries, {len(not_ported)} not-ported entries") + print(f" MAPPED {len(mapped_found)}") + print(f" NOT-PORTED {len(not_ported_found)}") + print(f" UNMAPPED {len(unmapped)} ({len(doc_gaps)} doc gaps, {len(port_gaps)} port gaps)") + print() + + if args.verbose: + print("--- MAPPED ---") + for name in mapped_found: + print(f" {name}") + print("--- NOT-PORTED (expected) ---") + for name in not_ported_found: + print(f" {name}") + print() + + if unmapped: + print("--- UNMAPPED ---") + if doc_gaps: + print(" [doc gap] name traces in C# code -> ported but PORTING.md is missing a row:") + for name in doc_gaps: + print(f" {name}") + if port_gaps: + print(" [port gap] no C# trace -> genuinely no C# home (real drift):") + for name in port_gaps: + print(f" {name}") + print() + print("Fix: add a §3 row for doc gaps, or a §7 note for port gaps.") + else: + print("No unmapped C++ functions. C++ and PORTING.md are in sync.") + + return 1 if port_gaps else 0 + + +if __name__ == "__main__": + sys.exit(main())