From 06dcebcb9a2eaa592a98f6d2aed91b9b26530ae5 Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 13:44:56 +0200 Subject: [PATCH 1/9] scaffold: C# DistrictServer project skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the .NET 8 project file, entry point (Program.cs with --selftest / --drive / --resolver-check modes), the configuration layer (DistrictConfig, env + INI settings), the rotating logger, and the account model + registry. This is the first commit of the wire-identical C# port of DistrictServer.cpp. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- DistrictServerCSharp/.gitignore | 10 + DistrictServerCSharp/Accounts/Account.cs | 291 +++++++ .../Accounts/AccountManager.cs | 79 ++ DistrictServerCSharp/Config/DistrictConfig.cs | 801 ++++++++++++++++++ .../DistrictServerCSharp.csproj | 15 + .../Logging/DistrictLogger.cs | 204 +++++ DistrictServerCSharp/Program.cs | 201 +++++ 7 files changed, 1601 insertions(+) create mode 100644 DistrictServerCSharp/.gitignore create mode 100644 DistrictServerCSharp/Accounts/Account.cs create mode 100644 DistrictServerCSharp/Accounts/AccountManager.cs create mode 100644 DistrictServerCSharp/Config/DistrictConfig.cs create mode 100644 DistrictServerCSharp/DistrictServerCSharp.csproj create mode 100644 DistrictServerCSharp/Logging/DistrictLogger.cs create mode 100644 DistrictServerCSharp/Program.cs diff --git a/DistrictServerCSharp/.gitignore b/DistrictServerCSharp/.gitignore new file mode 100644 index 0000000..b3a8958 --- /dev/null +++ b/DistrictServerCSharp/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +Logs/ +_scratch_verify/ +_scratch_drive/ +_build_scratch_pub/ +_ds_cs_publish/ +_map_ds.py +_bracecheck.py +_verify_ds.py 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/Config/DistrictConfig.cs b/DistrictServerCSharp/Config/DistrictConfig.cs new file mode 100644 index 0000000..a23aef7 --- /dev/null +++ b/DistrictServerCSharp/Config/DistrictConfig.cs @@ -0,0 +1,801 @@ +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; + } + + // ------------------------------------------------------------------ + // 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/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/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/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; + } +} From 4c9bd0b8e2e2144bb6b33fd122aa10fff762450c Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 13:45:36 +0200 Subject: [PATCH 2/9] wire layer: UE3 bit primitives, XTEA/BTEA crypto, packet parse/build, field decoders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the ApbUdp.cpp wire layer: LSB-first BitReader/BitWriter, the XTEA handshake cipher (ECB/CBC x endianness), the BTEA/XXTEA district cipher (DistrictCrypto), the UE3 packet parser + all packet/field builders, the controller/actor field decoders, and the TCP control-link Network class. Includes the byte-identical self-test vectors (SelfTest.cs). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- DistrictServerCSharp/Bits/BitReader.cs | 170 +++ DistrictServerCSharp/Bits/BitWriter.cs | 219 +++ DistrictServerCSharp/Bits/RWBits.cs | 67 + DistrictServerCSharp/Crypto/Xtea.cs | 177 +++ DistrictServerCSharp/Net/DistrictCrypto.cs | 153 ++ DistrictServerCSharp/Net/EndpointAddress.cs | 34 + DistrictServerCSharp/Net/Network.cs | 196 +++ DistrictServerCSharp/Protocol/Diagnostics.cs | 72 + .../Protocol/FieldDecoders.cs | 849 +++++++++++ DistrictServerCSharp/Protocol/Models.cs | 246 ++++ .../Protocol/PacketBuilders.cs | 1240 +++++++++++++++++ DistrictServerCSharp/Protocol/PacketParser.cs | 383 +++++ DistrictServerCSharp/Protocol/SelfTest.cs | 171 +++ 13 files changed, 3977 insertions(+) create mode 100644 DistrictServerCSharp/Bits/BitReader.cs create mode 100644 DistrictServerCSharp/Bits/BitWriter.cs create mode 100644 DistrictServerCSharp/Bits/RWBits.cs create mode 100644 DistrictServerCSharp/Crypto/Xtea.cs create mode 100644 DistrictServerCSharp/Net/DistrictCrypto.cs create mode 100644 DistrictServerCSharp/Net/EndpointAddress.cs create mode 100644 DistrictServerCSharp/Net/Network.cs create mode 100644 DistrictServerCSharp/Protocol/Diagnostics.cs create mode 100644 DistrictServerCSharp/Protocol/FieldDecoders.cs create mode 100644 DistrictServerCSharp/Protocol/Models.cs create mode 100644 DistrictServerCSharp/Protocol/PacketBuilders.cs create mode 100644 DistrictServerCSharp/Protocol/PacketParser.cs create mode 100644 DistrictServerCSharp/Protocol/SelfTest.cs 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..f858eb7 --- /dev/null +++ b/DistrictServerCSharp/Bits/BitWriter.cs @@ -0,0 +1,219 @@ +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). Verified round-trip + /// against the C++ (yaw 16384 reconstructs to 16384). + /// + 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/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/Net/DistrictCrypto.cs b/DistrictServerCSharp/Net/DistrictCrypto.cs new file mode 100644 index 0000000..3e89a45 --- /dev/null +++ b/DistrictServerCSharp/Net/DistrictCrypto.cs @@ -0,0 +1,153 @@ +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; + } + + /// + /// Pads the packet to a 32-bit word boundary and encrypts the whole thing + /// as one BTEA block with the account's session key. Returns false with no + /// protection when AckMode is None (legacy plaintext experiments). + /// + public static bool ProtectOutgoingPacket(Span packet, byte[] encryptionKey, bool protectionEnabled) + { + if (!protectionEnabled) + return true; + + if (packet.Length < 8) + { + // Caller must have allocated a writable buffer of at least 8 bytes. + return false; + } + + return Encrypt(packet[..((packet.Length + 3) & ~3)], encryptionKey); + } +} 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/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; + } +} From 52130de77f6a00efcf7a9476d93e81562bec935f Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 13:45:56 +0200 Subject: [PATCH 3/9] core services: handshake, reliable queue, channel sequences, transport state, diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the DistrictServer.cpp core: the AUTH/USES/WELCOME/JOIN/ANS handshake (HandshakeService), per-endpoint channel-sequence allocation, the reliable TX queue with retry + ACK gating, binary NMT control, transport-state tracking with keep-alive ACK throttling, the possession/lifecycle reset hooks, and the packet diagnostics (SaveCapture + XTEA-mode forensics). Also the world registration + character-handoff control link (WorldControl). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- DistrictServerCSharp/Core/Lifecycle.cs | 25 + .../Core/PacketDiagnostics.cs | 101 ++++ DistrictServerCSharp/Core/ReliableQueue.cs | 191 +++++++ .../Core/SelectedSpawnLocations.cs | 40 ++ DistrictServerCSharp/Core/TransportState.cs | 137 +++++ .../Handshake/BinaryControlService.cs | 81 +++ .../Handshake/ChannelSequenceAllocator.cs | 32 ++ .../Handshake/HandshakeService.cs | 511 ++++++++++++++++++ .../WorldControl/WorldControl.cs | 349 ++++++++++++ 9 files changed, 1467 insertions(+) create mode 100644 DistrictServerCSharp/Core/Lifecycle.cs create mode 100644 DistrictServerCSharp/Core/PacketDiagnostics.cs create mode 100644 DistrictServerCSharp/Core/ReliableQueue.cs create mode 100644 DistrictServerCSharp/Core/SelectedSpawnLocations.cs create mode 100644 DistrictServerCSharp/Core/TransportState.cs create mode 100644 DistrictServerCSharp/Handshake/BinaryControlService.cs create mode 100644 DistrictServerCSharp/Handshake/ChannelSequenceAllocator.cs create mode 100644 DistrictServerCSharp/Handshake/HandshakeService.cs create mode 100644 DistrictServerCSharp/WorldControl/WorldControl.cs 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/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..6166f63 --- /dev/null +++ b/DistrictServerCSharp/Handshake/HandshakeService.cs @@ -0,0 +1,511 @@ +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; + byte[] buffer = packet; + if (padded != packet.Length) + { + 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()); + + 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/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; + } +} From 67e69d9ee4ffcb63a8f0250edac13dba72f594af Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 13:46:48 +0200 Subject: [PATCH 4/9] subsystems: GRI match-start, level streaming, spawn zones, pawn lifecycle, remote-pawn, controller, package resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the district subsystems: GRI match-start replication, the level-streaming plan, spawn-zone markers + selection, the ACK-gated pawn bootstrap + possession, remote-pawn multiplayer with the dead-peer lifecycle, controller field decoding (movement / 371 / visibility / customisation replicator dispatch), and the cooked-package net-index resolver that parses cooked .u packages directly. Also re-includes DistrictServerCSharp/Packages in .gitignore (the root .gitignore's NuGet `packages/` pattern matched it case-insensitively). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- DistrictServerCSharp/.gitignore | 3 + .../Controller/ControllerFeedbackService.cs | 1519 +++++++++++++++++ DistrictServerCSharp/Gri/GriStartupService.cs | 189 ++ .../Packages/CookedPackageResolver.cs | 1089 ++++++++++++ .../Pawn/PawnLifecycleService.cs | 1102 ++++++++++++ .../Pawn/PostPossessionMovementUnlock.cs | 181 ++ .../Pawn/RemotePawnReplication.cs | 567 ++++++ .../SpawnZone/SpawnZoneService.cs | 386 +++++ .../Streaming/LevelStreamingService.cs | 476 ++++++ 9 files changed, 5512 insertions(+) create mode 100644 DistrictServerCSharp/Controller/ControllerFeedbackService.cs create mode 100644 DistrictServerCSharp/Gri/GriStartupService.cs create mode 100644 DistrictServerCSharp/Packages/CookedPackageResolver.cs create mode 100644 DistrictServerCSharp/Pawn/PawnLifecycleService.cs create mode 100644 DistrictServerCSharp/Pawn/PostPossessionMovementUnlock.cs create mode 100644 DistrictServerCSharp/Pawn/RemotePawnReplication.cs create mode 100644 DistrictServerCSharp/SpawnZone/SpawnZoneService.cs create mode 100644 DistrictServerCSharp/Streaming/LevelStreamingService.cs diff --git a/DistrictServerCSharp/.gitignore b/DistrictServerCSharp/.gitignore index b3a8958..65f5d08 100644 --- a/DistrictServerCSharp/.gitignore +++ b/DistrictServerCSharp/.gitignore @@ -8,3 +8,6 @@ _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/Controller/ControllerFeedbackService.cs b/DistrictServerCSharp/Controller/ControllerFeedbackService.cs new file mode 100644 index 0000000..e8384ef --- /dev/null +++ b/DistrictServerCSharp/Controller/ControllerFeedbackService.cs @@ -0,0 +1,1519 @@ +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. + _spawnZone.OnMarkersSent += account => + { + lock (_feedbackGate) + { + _feedbackStates[account.GetId()].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)"); + + if (firstReceipt && DistrictConfig.IsActionDistrict(DistrictConfig.GetConfiguredDistrictMap())) + { + _reliableQueue.CancelPendingReliablesByLabelPrefix(account.GetId(), + new[] { "SPAWN-ZONE-ACTOR-BRIDGE-OPEN-", "CLIENT-REPLICATE-HUD-MARKER-" }); + + if (field.ObjectReferenceByChannel && + field.ObjectReferenceValue >= DistrictConfig.SpawnZoneActorChannel && + field.ObjectReferenceValue < DistrictConfig.SpawnZoneActorChannel + 12u) + { + int index = (int)(field.ObjectReferenceValue - DistrictConfig.SpawnZoneActorChannel); + if (DistrictConfig.TryGetActionSpawnDirection(DistrictConfig.GetConfiguredDistrictMap(), index, out float x, out float y, out float z)) + { + SelectedSpawnLocations.Set(account.GetId(), x, y, z + 100.0f); + } + } + + // 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/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/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/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}"; +} From c48183b6c0e56793501b0aa8da5efe8a86484e4f Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 13:47:10 +0200 Subject: [PATCH 5/9] drive harness: in-process two-player end-to-end verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the --drive harness: a fake WorldServer (scratch TCP) + two fake clients (scratch UDP) that run the full join path AUTH -> WELCOME -> JOIN -> 371 spawn-zone -> district-enter answer -> pawn-open -> all 8 ACK-gated possession stages, verify each player sees the other's remote pawn, and drive the customisation replicator to ClientNotifyTransferComplete. Scratch ports only; never touches the live instances. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- DistrictServerCSharp/Drive/DriveHarness.cs | 526 +++++++++++++++++++++ 1 file changed, 526 insertions(+) create mode 100644 DistrictServerCSharp/Drive/DriveHarness.cs 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); + } + } +} From a28cd8d0f73724c57d3131fd807a86e7dd599f14 Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 13:47:24 +0200 Subject: [PATCH 6/9] docs/tools: PORTING.md function map + drift-gate and deploy-sync checkers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PORTING.md (the authoritative C++ -> C# function map, §3/§7, verification, deploy + rollback §6) and the two gates that keep the port honest: check_port_sync.py inventories every C++ function and asserts it has a C# home (exits 1 on any port gap), and check_deploy_sync.py compares the deployed DistrictServer.dll sha in the three instance folders against the build output. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- DistrictServerCSharp/PORTING.md | 362 ++++++++++++++++++++++++++++++++ Tools/check_deploy_sync.py | 129 ++++++++++++ Tools/check_port_sync.py | 275 ++++++++++++++++++++++++ 3 files changed, 766 insertions(+) create mode 100644 DistrictServerCSharp/PORTING.md create mode 100644 Tools/check_deploy_sync.py create mode 100644 Tools/check_port_sync.py diff --git a/DistrictServerCSharp/PORTING.md b/DistrictServerCSharp/PORTING.md new file mode 100644 index 0000000..daaf1d8 --- /dev/null +++ b/DistrictServerCSharp/PORTING.md @@ -0,0 +1,362 @@ +# 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` | +| `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` | +| `BuildUnreliableActorVectorFieldPacket`, `BuildUnreliableActorRotatorFieldPacket` | `Protocol.PacketBuilders.*` (remote-pawn path) | +| `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** — `BuildActorRotatorFieldPacket` (zero + C++ callers), `FloatToWireBits`, `WriteFloatBits`. The reliable + rotator/raw-float paths are unused by the port; the remote-pawn path uses + the ported unreliable rotator/vector builders (see §3.8) and + `BitWriter.WriteCompressedRotator`. `FloatToWireBits`/`WriteFloatBits` are + covered semantically by `BitConverter.SingleToUInt32Bits`. + +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 ~68 **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/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..02f5ad5 --- /dev/null +++ b/Tools/check_port_sync.py @@ -0,0 +1,275 @@ +#!/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", + "ProtectOutgoingPacket": "DistrictCrypto.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()) From 3c9fd1fdcf20e1f6e12e4bf78a3c5a7a637d263f Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 14:43:50 +0200 Subject: [PATCH 7/9] net: add the district UDP listener (completes the C# port) Net/UdpListener.cs is the top-level receive loop, ported from UdpListenerThread in DistrictServer.cpp. It was the one file still missing from the port branch, and it is load-bearing: Program.cs (Startup/Main) and Drive/DriveHarness.cs both construct it, so the branch did not compile without it. Responsibilities: - Binds the configured UDP port and owns the recvfrom loop. - Three-pass decrypt/parse mirroring the C++ order. The first AUTH bunch is plaintext; everything after is BTEA-encrypted with the account session key. Decrypt-then-parse runs FIRST for a keyed endpoint, because a ciphertext datagram can decode as a plausible plaintext packet by chance and would silently discard a real message. - Constructs and wires the service graph (handshake, reliable queue, GRI startup, spawn zone, streaming, pawn lifecycle, controller feedback, remote pawn, binary control, transport state), including the two cycles that need post-construction injection: PawnLifecycleService.RemotePawnReplication and the Lifecycle reset delegates. - Fixed dispatch order per received packet: AUTH, reliable-ACK retirement, post-possession unlock, hard-landing recovery, bMatchHasBegun, reliable retry, transport ACK, customisation replicator, controller actor feedback, possession gate, text control, binary control. - Possession gate keyed by client endpoint (address, port) rather than account id. The C++ used a global account-id set that was never cleared, so a reconnect of the same account from a new socket had its district-enter ASK received but never answered (insert returned false) and the client parked at "Entering district" forever. A fresh connection now gets its own entry and re-runs the answer. - WSAECONNRESET (dead-peer ICMP port-unreachable) is attributed to the last-pushed remote-pawn viewers for dead-peer cleanup and logged at most once per 10 s instead of once per send cycle. Also ignore _drive_scratch/ for parity with _scratch_drive/; both directory spellings have been used by local drive runs. Verified in a clean worktree: dotnet build -c Release reports 0 warnings / 0 errors, and all three offline modes pass without a client or a live stack -- --selftest SELF-TEST PASS (AUTH bunch round-trip, ACK, challenge) --resolver-check RESOLVER-CHECK PASS (controller ordinal 12426, holdable 15985, inventory 25478) --drive DRIVE PASS (registered at World Server, WELCOME, two-account sequences complete, remote pawn opened both directions, ClientNotifyTransferComplete) Co-Authored-By: Claude Opus 5 --- DistrictServerCSharp/.gitignore | 1 + DistrictServerCSharp/Net/UdpListener.cs | 370 ++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 DistrictServerCSharp/Net/UdpListener.cs diff --git a/DistrictServerCSharp/.gitignore b/DistrictServerCSharp/.gitignore index 65f5d08..fd75041 100644 --- a/DistrictServerCSharp/.gitignore +++ b/DistrictServerCSharp/.gitignore @@ -3,6 +3,7 @@ obj/ Logs/ _scratch_verify/ _scratch_drive/ +_drive_scratch/ _build_scratch_pub/ _ds_cs_publish/ _map_ds.py 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); + } + } +} From 75f7d5ce129f71690dd360bd5804a91cf7bae36e Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 16:37:25 +0200 Subject: [PATCH 8/9] port: fix four C++/C# fidelity divergences found by audit + drift gate All four are silent behaviour differences against the C++ oracle, not compile errors. Verified against DistrictServer.cpp line by line. 1. HandshakeService.ProtectOutgoingPacket aliased the caller's buffer when the packet was already word-aligned and >= 8 bytes, so DistrictCrypto.Encrypt (in place) turned the caller's plaintext into ciphertext. The oracle takes its packet BY VALUE (DistrictServer.cpp:1412), so the caller's buffer is never touched there. ReliableQueue stores that same array as PendingServerReliable.ClearPacket (ReliableQueue.cs:50) and re-sends it on retry (:158), which then encrypted already-encrypted bytes. Deterministic for spawn-zone / HUD-marker labels, which this build never ACKs. Fix: copy unconditionally. 2. JOIN never cleared per-account controller feedback state, so a reconnect reusing the same account id inside one process kept the previous startup/marker barrier: field 372 sent no fresh marker and the player was never possessed. The oracle erases it on JOIN (DistrictServer.cpp:13137-13142). Lifecycle.ResetControllerFeedback was already wired (Lifecycle.cs:24, UdpListener.cs:83) but had no call site; this adds it. 3. ControllerFeedbackService's OnMarkersSent handler indexed _feedbackStates directly. Unlike std::map::operator[], the .NET indexer getter throws KeyNotFoundException -- straight out of the UDP receive loop -- when no controller field has been decoded yet. Fix 2 makes that window reachable, so both ship together. Now goes through the existing GetOrCreateState. 4. The spawn-location latch was gated on IsActionDistrict and handled only the by-channel reference form. The oracle gates it on firstReceipt alone and accepts two forms: the bridge channel (action) and the static spawn-zone template NetIndex 72913/72914 (Social) -- DistrictServer.cpp:12474-12506. On Social the latch therefore never ran, so PawnLifecycleService/RemotePawnReplication found no selected location and the pawn ignored the chosen zone. Ports TryGetSpawnZoneLocation (the oracle's Social branch, cpp:397) to DistrictConfig and restores the oracle's structure. Also drops the dead DistrictCrypto.ProtectOutgoingPacket duplicate, which had zero callers and diverged twice from the oracle (rejected < 8 bytes the oracle zero-pads; its slice threw on unaligned input), and repoints Tools/check_port_sync.py at the real port in HandshakeService so the drift gate stops crediting the dead copy. Divergence 4 was found by that gate once it pointed at the right function: it now reports 160/160 mapped, zero doc gaps, zero port gaps. Build is clean (0 warnings). Not yet exercised against a live client. Refs #12 Co-Authored-By: Claude Opus 5 --- DistrictServerCSharp/Config/DistrictConfig.cs | 34 ++++++++++++++ .../Controller/ControllerFeedbackService.cs | 46 +++++++++++++++---- .../Handshake/HandshakeService.cs | 21 ++++++--- DistrictServerCSharp/Net/DistrictCrypto.cs | 19 -------- DistrictServerCSharp/PORTING.md | 1 + Tools/check_port_sync.py | 5 +- 6 files changed, 90 insertions(+), 36 deletions(-) diff --git a/DistrictServerCSharp/Config/DistrictConfig.cs b/DistrictServerCSharp/Config/DistrictConfig.cs index a23aef7..67b67dc 100644 --- a/DistrictServerCSharp/Config/DistrictConfig.cs +++ b/DistrictServerCSharp/Config/DistrictConfig.cs @@ -373,6 +373,40 @@ public static bool TryGetActionSpawnDirection(DistrictMap map, int index, out fl 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 // ------------------------------------------------------------------ diff --git a/DistrictServerCSharp/Controller/ControllerFeedbackService.cs b/DistrictServerCSharp/Controller/ControllerFeedbackService.cs index e8384ef..8b3c035 100644 --- a/DistrictServerCSharp/Controller/ControllerFeedbackService.cs +++ b/DistrictServerCSharp/Controller/ControllerFeedbackService.cs @@ -163,11 +163,16 @@ public ControllerFeedbackService( // 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) { - _feedbackStates[account.GetId()].SpawnZoneMarkerSent = true; + state.SpawnZoneMarkerSent = true; } }; } @@ -623,22 +628,43 @@ private void HandleServerSelectSpawnZone(IPEndPoint endpoint, Account account, P packet.PacketId, bunch.ChannelSequence, field.BeginBit, field.EndBit, firstReceipt ? " (first receipt)" : " (duplicate)"); - if (firstReceipt && DistrictConfig.IsActionDistrict(DistrictConfig.GetConfiguredDistrictMap())) + // 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) { - _reliableQueue.CancelPendingReliablesByLabelPrefix(account.GetId(), - new[] { "SPAWN-ZONE-ACTOR-BRIDGE-OPEN-", "CLIENT-REPLICATE-HUD-MARKER-" }); - + int zoneIndex = -1; if (field.ObjectReferenceByChannel && field.ObjectReferenceValue >= DistrictConfig.SpawnZoneActorChannel && field.ObjectReferenceValue < DistrictConfig.SpawnZoneActorChannel + 12u) { - int index = (int)(field.ObjectReferenceValue - DistrictConfig.SpawnZoneActorChannel); - if (DistrictConfig.TryGetActionSpawnDirection(DistrictConfig.GetConfiguredDistrictMap(), index, out float x, out float y, out float z)) - { - SelectedSpawnLocations.Set(account.GetId(), x, y, z + 100.0f); - } + 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) { diff --git a/DistrictServerCSharp/Handshake/HandshakeService.cs b/DistrictServerCSharp/Handshake/HandshakeService.cs index 6166f63..1b994e4 100644 --- a/DistrictServerCSharp/Handshake/HandshakeService.cs +++ b/DistrictServerCSharp/Handshake/HandshakeService.cs @@ -74,12 +74,15 @@ public bool SendPacket(IPEndPoint endpoint, byte[] packet, string label) return packet; int padded = packet.Length < 8 ? 8 : (packet.Length + 3) & ~3; - byte[] buffer = packet; - if (padded != packet.Length) - { - buffer = new byte[padded]; - Array.Copy(packet, buffer, packet.Length); - } + + // 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; } @@ -476,6 +479,12 @@ public bool ProcessTextControlPacket(IPEndPoint endpoint, Account account, Packe "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)) diff --git a/DistrictServerCSharp/Net/DistrictCrypto.cs b/DistrictServerCSharp/Net/DistrictCrypto.cs index 3e89a45..5dd5ebb 100644 --- a/DistrictServerCSharp/Net/DistrictCrypto.cs +++ b/DistrictServerCSharp/Net/DistrictCrypto.cs @@ -131,23 +131,4 @@ public static bool Decrypt(Span data, ReadOnlySpan key) StoreWords(v, data); return true; } - - /// - /// Pads the packet to a 32-bit word boundary and encrypts the whole thing - /// as one BTEA block with the account's session key. Returns false with no - /// protection when AckMode is None (legacy plaintext experiments). - /// - public static bool ProtectOutgoingPacket(Span packet, byte[] encryptionKey, bool protectionEnabled) - { - if (!protectionEnabled) - return true; - - if (packet.Length < 8) - { - // Caller must have allocated a writable buffer of at least 8 bytes. - return false; - } - - return Encrypt(packet[..((packet.Length + 3) & ~3)], encryptionKey); - } } diff --git a/DistrictServerCSharp/PORTING.md b/DistrictServerCSharp/PORTING.md index daaf1d8..a65721b 100644 --- a/DistrictServerCSharp/PORTING.md +++ b/DistrictServerCSharp/PORTING.md @@ -83,6 +83,7 @@ The C++ side's own behaviour is authoritative; the C# port must match it byte-fo | `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()` | diff --git a/Tools/check_port_sync.py b/Tools/check_port_sync.py index 02f5ad5..4d28b98 100644 --- a/Tools/check_port_sync.py +++ b/Tools/check_port_sync.py @@ -48,7 +48,10 @@ "BteaDecrypt": "DistrictCrypto.Decrypt", "BteaLoadWords": "DistrictCrypto.LoadWords", "BteaStoreWords": "DistrictCrypto.StoreWords", - "ProtectOutgoingPacket": "DistrictCrypto.ProtectOutgoingPacket", + # 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", From 048552eb3751aa79d7e3b7fbebab6e110e9f2871 Mon Sep 17 00:00:00 2001 From: eaxiumnet Date: Mon, 17 Aug 2026 16:47:44 +0200 Subject: [PATCH 9/9] docs: correct three false provenance claims in the port docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the C++ oracle turned up three documentation claims that assert a C++ counterpart which does not exist. None of them changes shipped behaviour, but each one would have mislead the next person diffing the two implementations. 1. BitWriter.WriteCompressedRotator said "Verified round-trip against the C++ (yaw 16384 reconstructs to 16384)". There is no C++ rotator writer to round-trip against: grep for "Rotator" across DistrictServer/ returns nothing. The oracle has WriteCompressedVector (ApbUdp.cpp:262) and a single AimRotation int READ (ApbUdp.cpp:2410), and neither side of the port has a rotator reader, so no round-trip harness exists anywhere. Relabelled PORT-ORIGINAL: the encoding is client-RE-derived and hand-checked only, not yet exercised against a live client. The encoding description itself is unchanged. 2. PORTING.md §3.8 listed BuildUnreliableActorVectorFieldPacket and BuildUnreliableActorRotatorFieldPacket in the C++ column. Both are phantoms. The oracle has exactly one unreliable field builder, BuildUnreliableActorFloatFieldPacket (ApbUdp.cpp:1221, called from DistrictServer.cpp:10753) — which had no row at all. Now mapped, with the vector/rotator builders marked as port additions for the remote-pawn path. 3. PORTING.md §7 item 2 listed BuildActorRotatorFieldPacket "(zero C++ callers)" — also a phantom — and claimed the raw-float paths are "unused by the port". FloatToWireBits (:1828) and WriteFloatBits (:2185) both have live C++ callers (PRI stats :1867/:1873, non-compressed HUD-marker location :2264-:2266) and both ARE mirrored, inline via BitConverter.SingleToUInt32Bits at four C# call sites. Reworded to say so, and to explain that they stay in §7 only because there is no named C# home to point a §3 row at. Removing the phantom from §7 makes the gate's own arithmetic add up: it listed 8 not-ported entries but only ever matched 7. Gate after: default MAPPED 160, NOT-PORTED 0, UNMAPPED 0 (0 doc, 0 port gaps) --all MAPPED 161, NOT-PORTED 7, UNMAPPED 61 (61 doc, 0 port gaps) MAPPED 160 -> 161 and doc gaps 62 -> 61 are the newly-documented float builder; the stale "~68" figure in §7 is now the measured 61. Build clean (0 warnings, 0 errors). Docs and comments only — no code paths touched. Refs #12 Co-Authored-By: Claude Opus 5 --- DistrictServerCSharp/Bits/BitWriter.cs | 11 +++++++++-- DistrictServerCSharp/PORTING.md | 18 ++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/DistrictServerCSharp/Bits/BitWriter.cs b/DistrictServerCSharp/Bits/BitWriter.cs index f858eb7..ab1c456 100644 --- a/DistrictServerCSharp/Bits/BitWriter.cs +++ b/DistrictServerCSharp/Bits/BitWriter.cs @@ -103,8 +103,15 @@ public void WriteCompressedVector(float x, float y, float z) /// 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). Verified round-trip - /// against the C++ (yaw 16384 reconstructs to 16384). + /// 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) { diff --git a/DistrictServerCSharp/PORTING.md b/DistrictServerCSharp/PORTING.md index a65721b..039ad87 100644 --- a/DistrictServerCSharp/PORTING.md +++ b/DistrictServerCSharp/PORTING.md @@ -178,7 +178,7 @@ can tell a genuinely new C++ function from one that already has a C# home. | `PacketCaptureEnabled`, `PacketHexLogEnabled` | `DistrictConfig.ReadBool` | | `SaveCapture`, `DiagnoseEncryptedPacket` | `Core.PacketDiagnostics.*` | | `LowerAscii` | inlined as `ToLowerInvariant` | -| `BuildUnreliableActorVectorFieldPacket`, `BuildUnreliableActorRotatorFieldPacket` | `Protocol.PacketBuilders.*` (remote-pawn path) | +| `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` | @@ -299,17 +299,19 @@ 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** — `BuildActorRotatorFieldPacket` (zero - C++ callers), `FloatToWireBits`, `WriteFloatBits`. The reliable - rotator/raw-float paths are unused by the port; the remote-pawn path uses - the ported unreliable rotator/vector builders (see §3.8) and - `BitWriter.WriteCompressedRotator`. `FloatToWireBits`/`WriteFloatBits` are - covered semantically by `BitConverter.SingleToUInt32Bits`. +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 ~68 **doc gaps** (`ReadBit`, +`--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