diff --git a/DistrictServer/ApbUdp.cpp b/DistrictServer/ApbUdp.cpp index 9d6ea15..7aae05a 100644 --- a/DistrictServer/ApbUdp.cpp +++ b/DistrictServer/ApbUdp.cpp @@ -294,6 +294,44 @@ namespace maximum); } + // FRotator::SerializeCompressed (UnMath.cpp:84), the format the + // client's UActorChannel property reader expects for Rotator fields. + // Per component: 1 bit "high byte nonzero?" then, if nonzero, the + // 8-bit high byte (value >> 8). This is NOT the width-field format + // FVector uses; the previous implementation mirrored + // WriteCompressedVector and the client misparsed every rotation + // bunch (the remote pawn never rotated). Verified round-trip in + // rAPB/Emulator/DistrictServer/test_u3_primitives.cpp (yaw 16384 + // reconstructs to 16384; a pure-yaw rotation writes only the yaw + // byte: pitch/roll each write a single 0 bit). + void WriteCompressedRotator( + std::int32_t pitch, + std::int32_t yaw, + std::int32_t roll) + { + const std::uint8_t bytePitch = + static_cast( + (static_cast(pitch) & 0xFFFFu) >> 8); + const std::uint8_t byteYaw = + static_cast( + (static_cast(yaw) & 0xFFFFu) >> 8); + const std::uint8_t byteRoll = + static_cast( + (static_cast(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. @@ -1257,6 +1295,69 @@ namespace ApbUdp return writer.FinishWithTrailer(); } + // High-frequency replicated property updates (remote-pawn position/ + // rotation/velocity) use UNRELIABLE bunches: each update self-corrects on + // the next tick, and reliable bunches at ~30 Hz per property overflow the + // client's reliable channel and wedge the actor channel (the remote pawn + // silently disappears). Header mirrors the ClientAckGoodMove path: + // PacketId, IsAck=0, no open/close flags, Reliable=0, ChannelIndex, + // DataBits, payload. No channel sequence for unreliable bunches. + std::vector BuildUnreliableActorVectorFieldPacket( + std::uint32_t serverPacketId, + std::uint16_t channelIndex, + std::uint32_t fieldIndex, + std::uint32_t fieldMax, + float x, + float y, + float z) + { + BitWriter payload; + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteCompressedVector(x, y, z); + + BitWriter writer; + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + writer.WriteBit(false); + writer.WriteBit(false); + writer.WriteBit(false); + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt( + static_cast(payload.BitCount()), + 512u * 8u); + writer.WriteBitsFrom( + payload.Snapshot(), + payload.BitCount()); + return writer.FinishWithTrailer(); + } + + std::vector BuildUnreliableActorRotatorFieldPacket( + std::uint32_t serverPacketId, + std::uint16_t channelIndex, + std::uint32_t fieldIndex, + std::uint32_t fieldMax, + std::int32_t pitch, + std::int32_t yaw, + std::int32_t roll) + { + BitWriter payload; + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteCompressedRotator(pitch, yaw, roll); + + BitWriter writer; + writer.WriteBits(serverPacketId & 0x3FFFFFFFu, 30); + writer.WriteBit(false); + writer.WriteBit(false); + writer.WriteBit(false); + writer.WriteBoundedInt(channelIndex, 0x3FFu); + writer.WriteBoundedInt( + static_cast(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). std::vector BuildActorIntFieldPacket( @@ -1464,6 +1565,28 @@ namespace ApbUdp return writer.FinishWithTrailer(); } + std::vector BuildActorRotatorFieldPacket( + std::uint32_t serverPacketId, + std::uint16_t channelIndex, + std::uint16_t channelSequence, + std::uint32_t fieldIndex, + std::uint32_t fieldMax, + std::int32_t pitch, + std::int32_t yaw, + std::int32_t roll) + { + BitWriter payload; + payload.WriteBoundedInt(fieldIndex, fieldMax); + payload.WriteCompressedRotator(pitch, yaw, roll); + + BitWriter writer; + WriteActorBunchHeader( + writer, serverPacketId, channelIndex, channelSequence, + payload.BitCount()); + 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 diff --git a/DistrictServer/ApbUdp.h b/DistrictServer/ApbUdp.h index 772053a..8120c4c 100644 --- a/DistrictServer/ApbUdp.h +++ b/DistrictServer/ApbUdp.h @@ -230,6 +230,40 @@ namespace ApbUdp float z, bool compressed); + // FRotator property update (Pitch/Yaw/Roll in UE3 rotation units, + // 65536 per full turn), serialized with the compressed-rotator format + // (FRotator::SerializeCompressed family). + std::vector BuildActorRotatorFieldPacket( + std::uint32_t serverPacketId, + std::uint16_t channelIndex, + std::uint16_t channelSequence, + std::uint32_t fieldIndex, + std::uint32_t fieldMax, + std::int32_t pitch, + std::int32_t yaw, + std::int32_t roll); + + // Unreliable variants for high-frequency property updates (remote-pawn + // position/rotation/velocity). No channel sequence; the client does not + // ACK them and missed updates self-correct on the next tick. + std::vector BuildUnreliableActorVectorFieldPacket( + std::uint32_t serverPacketId, + std::uint16_t channelIndex, + std::uint32_t fieldIndex, + std::uint32_t fieldMax, + float x, + float y, + float z); + + std::vector BuildUnreliableActorRotatorFieldPacket( + std::uint32_t serverPacketId, + std::uint16_t channelIndex, + std::uint32_t fieldIndex, + std::uint32_t fieldMax, + std::int32_t pitch, + std::int32_t yaw, + std::int32_t roll); + std::vector BuildLevelStreamingStatusPacket( std::uint32_t serverPacketId, std::uint16_t channelIndex, diff --git a/DistrictServer/DistrictServer.cpp b/DistrictServer/DistrictServer.cpp index ac81741..f1b67fd 100644 --- a/DistrictServer/DistrictServer.cpp +++ b/DistrictServer/DistrictServer.cpp @@ -107,6 +107,17 @@ namespace std::set g_griMatchStartSentAccounts; std::map g_griOpenAckTicks; + // Defined after ControllerStreamingFeedbackState (see below); used by + // SendSpawnZoneHudMarker to publish marker readiness independently of the + // client's ServerNotifyClientLoaded (field 372) RPC, which can be delayed + // or dropped on the client's reliable channel. + void MarkSpawnZoneMarkersSent(Account* account); + + // Remote pawn replication (multiplayer visibility). Definitions follow + // ControllerMovementFeedbackState; the entry point is called from the + // pawn ACK-gate completion and the direct possession path. + void NotifyAccountPossessed(SOCKET socket, Account* account); + void ResetGriStartupState(std::uint32_t accountId) { std::lock_guard guard( @@ -394,36 +405,6 @@ namespace return true; } - bool TryGetSpawnZoneLocation( - DistrictMap map, - std::size_t zoneIndex, - float& x, - float& y, - float& z) - { - if (IsActionDistrict(map)) - return TryGetActionSpawnDirection(map, zoneIndex, x, y, z); - - // 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. - static const float social[][3] = - { - {33472.0f, 37488.0f, 208.0f}, - {33376.0f, 37312.0f, 208.0f} - }; - - if (zoneIndex >= 2u) - return false; - - x = social[zoneIndex][0]; - y = social[zoneIndex][1]; - z = social[zoneIndex][2]; - return true; - } - AckMode ReadAckMode() { const std::string configured = Lower(ReadHandshakeSetting("APB_ACK_MODE", "AckMode", "plain")); @@ -643,8 +624,17 @@ namespace Logger(lERROR, "WorldControl", "Not allowed to host a district"); return false; case '1': + // WorldServer reply 0x31 = same-IP re-registration REPLACED the + // previous entry (it sends 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. + Logger( + lSUCCESS, + "WorldControl", + "Registered at World Server (replaced previous entry)"); + return true; case '2': - Logger(lERROR, "WorldControl", "District already exists"); + Logger(lERROR, "WorldControl", "District already exists (different host)"); return false; case '3': Logger(lSUCCESS, "WorldControl", "Registered at World Server"); @@ -1157,12 +1147,42 @@ namespace return difference == 0; } + // Packet tracing gates, cached ONCE at boot (main) instead of being + // re-read from the INI/environment on every packet: the UDP thread + // calls these for every RX and TX packet, and a per-packet + // GetPrivateProfileStringA/_dupenv_s would defeat the whole point of + // this performance pass. + bool g_capturePackets = false; + bool g_logPacketHex = false; + + // Per-packet binary captures write one .bin file PER packet to the + // Packets\ folder. At the remote-pawn push rate (~90 packets/sec with + // two players) that is a file-create storm that stalls the UDP thread; + // OFF by default, APB_CAPTURE_PACKETS=1 re-enables it for forensics. + bool PacketCaptureEnabled() + { + return g_capturePackets; + } + + // Full hex dumps on the per-packet RX/TX log lines. Building the hex + // string for every packet is cheap, but combined with the console + // printf per line it multiplied the log volume ~3x; OFF by default. + // The packet SUMMARY lines (label, byte count, bunch description) + // always log so drives stay debuggable. + bool PacketHexLogEnabled() + { + return g_logPacketHex; + } + void SaveCapture( const char* direction, const sockaddr_in& endpoint, const std::uint8_t* data, std::size_t size) { + if (!PacketCaptureEnabled()) + return; + if (direction == nullptr || data == nullptr || size == 0) return; @@ -1217,14 +1237,27 @@ namespace packet.data(), packet.size()); - Logger( - lSUCCESS, - "District UDP", - "TX %s %d bytes to %s | %s", - label, - sent, - EndpointText(endpoint).c_str(), - ApbUdp::Hex(packet.data(), packet.size()).c_str()); + if (PacketHexLogEnabled()) + { + Logger( + lSUCCESS, + "District UDP", + "TX %s %d bytes to %s | %s", + label, + sent, + EndpointText(endpoint).c_str(), + ApbUdp::Hex(packet.data(), packet.size()).c_str()); + } + else + { + Logger( + lSUCCESS, + "District UDP", + "TX %s %d bytes to %s", + label, + sent, + EndpointText(endpoint).c_str()); + } return true; } @@ -1981,16 +2014,33 @@ namespace // until it is acked, which is why it kept repeating AUTH and LOGIN. // Ack format (from UNetConnection::ReceivedPacket): // [IsAck=1][bHasId=1][ReadInt(0x40000000) = 30-bit packet id] - // Reliable sequence numbers are tracked per channel by the client - // (UNetConnection::InReliable[ChIndex]), so each channel needs its own - // counter starting at 1. - std::uint16_t AllocateChannelSequence(std::uint16_t channelIndex) + // Reliable sequence numbers are tracked per channel per CONNECTION by the + // client (UNetConnection::InReliable[ChIndex] is connection-local), so + // each channel needs its own counter starting at 1 for every connection. + // Keying by channel index alone shared one counter across concurrent + // players: the second client's channel opens arrived stamped with the + // first client's sequence numbers, the client dropped them as + // out-of-order/duplicate ("InReliable < ChSequence" test), and the + // replicated controller/GRI actors never spawned -- the client sat at + // "Connected district" forever while keepalives kept flowing. + std::uint16_t AllocateChannelSequence( + const sockaddr_in& endpoint, + std::uint16_t channelIndex) { static std::mutex sequenceLock; - static std::map sequences; + static std::map< + std::pair< + std::pair, + std::uint16_t>, + std::uint16_t> sequences; std::lock_guard guard(sequenceLock); - return ++sequences[channelIndex]; + const auto key = std::make_pair( + std::make_pair( + endpoint.sin_addr.s_addr, + ntohs(endpoint.sin_port)), + channelIndex); + return ++sequences[key]; } bool SendAck( @@ -2510,7 +2560,7 @@ namespace // The client had already advanced InReliable[2] to 1, so the // second bunch failed the "InReliable < ChSequence" test and was // dropped as a duplicate -- sent, acked, never dispatched. - AllocateChannelSequence(actorChannelIndex), + AllocateChannelSequence(endpoint, actorChannelIndex), archetype, controllerX, controllerY, controllerZ); @@ -2680,6 +2730,12 @@ namespace constexpr std::uint32_t kFieldHoldableOwningPawn = 30; constexpr std::uint32_t kFieldLocation = 6; + // Actor base net-cache fields, live-verified 2026-08-17 (cache chain dump): + // 0 APBCollision 1 DrawScale 2 RelativeRotation 3 RelativeLocation + // 4 Velocity 5 Rotation 6 Location 7 Instigator 8 Base 9 Owner + // 10 Role 11 RemoteRole 12 Physics ... + constexpr std::uint32_t kFieldVelocity = 4; + constexpr std::uint32_t kFieldRotation = 5; // Client -> server cAPBPlayerController fields observed on the live // build-3908 ClassNetCache / wire: @@ -3408,7 +3464,7 @@ namespace ApbUdp::BuildLevelStreamingStatusPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldUpdateLevelStreaming, kPlayerControllerFieldMax, level, @@ -3466,7 +3522,7 @@ namespace ApbUdp::BuildActorVoidFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldFlushLevelStreaming, kPlayerControllerFieldMax); @@ -3551,7 +3607,7 @@ namespace ApbUdp::BuildActorVectorFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldLocation, kPlayerControllerFieldMax, x, @@ -3604,7 +3660,7 @@ namespace ApbUdp::BuildActorIntFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldAnsDistrictEnter, kPlayerControllerFieldMax, values, @@ -4028,7 +4084,7 @@ namespace ApbUdp::BuildActorIntFieldPacket( account->AllocateServerPacketId(), kPawnChannel, - AllocateChannelSequence(kPawnChannel), + AllocateChannelSequence(endpoint, kPawnChannel), kFieldPawnControllerCharacterUid, kPawnFieldMax, &characterUid, @@ -4049,7 +4105,7 @@ namespace ApbUdp::BuildActorEnumByteFieldPacket( account->AllocateServerPacketId(), kPawnChannel, - AllocateChannelSequence(kPawnChannel), + AllocateChannelSequence(endpoint, kPawnChannel), kFieldPawnGender, kPawnFieldMax, gender, @@ -4070,7 +4126,7 @@ namespace ApbUdp::BuildActorEnumByteFieldPacket( account->AllocateServerPacketId(), kPawnChannel, - AllocateChannelSequence(kPawnChannel), + AllocateChannelSequence(endpoint, kPawnChannel), kFieldPawnFaction, kPawnFieldMax, faction, @@ -4099,7 +4155,7 @@ namespace ApbUdp::BuildActorCompactGolemDescriptorFieldPacket( account->AllocateServerPacketId(), kPawnChannel, - AllocateChannelSequence(kPawnChannel), + AllocateChannelSequence(endpoint, kPawnChannel), kFieldPawnCustomisationGuids, kPawnFieldMax, descriptor); @@ -4577,7 +4633,7 @@ namespace ApbUdp::BuildClientGotoStatePacket( walkingPacketId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientGotoState, kPlayerControllerFieldMax, @@ -4605,7 +4661,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( moveInputPacketId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientIgnoreMoveInput, kPlayerControllerFieldMax, @@ -4633,7 +4689,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( windedPacketId, kPawnChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kPawnChannel), pawnIsWindedField, kPawnFieldMax, @@ -4754,6 +4810,11 @@ namespace static_cast( elapsedMilliseconds)); + // 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. + NotifyAccountPossessed(socket, account); + // The retail state machine normally leaves // PlayerSpawnWaitOnStreaming/PlayerImmobile after restart. // On the emulator this transition races the final possession @@ -4869,7 +4930,7 @@ namespace account->GetCharacterId()); channelIndex = kPawnChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp::BuildActorIntFieldPacket( serverPacketId, @@ -4885,7 +4946,7 @@ namespace case PawnAckGatedStage::Gender: channelIndex = kPawnChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp::BuildActorEnumByteFieldPacket( serverPacketId, @@ -4900,7 +4961,7 @@ namespace case PawnAckGatedStage::Faction: channelIndex = kPawnChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp::BuildActorEnumByteFieldPacket( serverPacketId, @@ -4933,7 +4994,7 @@ namespace channelIndex = kPawnChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp:: BuildActorCompactGolemDescriptorFieldPacket( @@ -4960,7 +5021,7 @@ namespace case PawnAckGatedStage::GivePawn: channelIndex = kControllerChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp::BuildActorObjectRpcPacket( serverPacketId, @@ -4974,7 +5035,7 @@ namespace case PawnAckGatedStage::ClientRestart: channelIndex = kControllerChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp::BuildActorObjectRpcPacket( serverPacketId, @@ -5000,7 +5061,7 @@ namespace channelIndex = kControllerChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp::BuildActorBoolFieldPacket( serverPacketId, @@ -5023,7 +5084,7 @@ namespace case PawnAckGatedStage::ClientSetViewTarget: channelIndex = kControllerChannel; channelSequence = - AllocateChannelSequence(channelIndex); + AllocateChannelSequence(endpoint, channelIndex); packet = ApbUdp::BuildActorObjectRpcPacket( serverPacketId, @@ -5350,7 +5411,7 @@ namespace ApbUdp::BuildActorOpenPacket( account->AllocateServerPacketId(), kPawnChannel, - AllocateChannelSequence(kPawnChannel), + AllocateChannelSequence(endpoint, kPawnChannel), pawnArchetype, spawnX, spawnY, @@ -5387,7 +5448,7 @@ namespace ApbUdp::BuildActorOpenPacket( account->AllocateServerPacketId(), kPlayerReplicationInfoChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kPlayerReplicationInfoChannel), playerReplicationInfoArchetype, spawnX, @@ -5409,7 +5470,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldControllerPlayerReplicationInfo, kPlayerControllerFieldMax, @@ -5427,7 +5488,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), kPawnChannel, - AllocateChannelSequence(kPawnChannel), + AllocateChannelSequence(endpoint, kPawnChannel), kFieldPawnPlayerReplicationInfo, kPawnFieldMax, kPlayerReplicationInfoChannel); @@ -5465,7 +5526,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), kPawnChannel, - AllocateChannelSequence(kPawnChannel), + AllocateChannelSequence(endpoint, kPawnChannel), kFieldPawnController, kPawnFieldMax, kControllerChannel); @@ -5483,7 +5544,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldPawn, kPlayerControllerFieldMax, kPawnChannel); @@ -5625,7 +5686,7 @@ namespace ApbUdp::BuildActorObjectRpcPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldGivePawn, kPlayerControllerFieldMax, kPawnChannel); @@ -5660,7 +5721,7 @@ namespace ApbUdp::BuildActorObjectRpcPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientRestart, kPlayerControllerFieldMax, kPawnChannel); @@ -5723,7 +5784,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), deadField, kPlayerControllerFieldMax, @@ -5778,7 +5839,7 @@ namespace ApbUdp::BuildActorObjectRpcPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientSetViewTarget, kPlayerControllerFieldMax, kPawnChannel, @@ -5870,7 +5931,7 @@ namespace ApbUdp::BuildClientGotoStatePacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientGotoState, kPlayerControllerFieldMax, "PlayerWalking"); @@ -5893,7 +5954,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientIgnoreMoveInput, kPlayerControllerFieldMax, false); @@ -5995,6 +6056,12 @@ namespace forceWalking ? 1 : 0, clearMoveInput ? 1 : 0); + // Multiplayer visibility (direct, non-ACK-gated possession path). + if (possessionSent) + { + NotifyAccountPossessed(socket, account); + } + return possessionSent; } @@ -7259,7 +7326,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), holdableChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, holdableChannel), kFieldHoldableOwningPawn, kHoldableItemManagerFieldMax, @@ -7401,7 +7468,7 @@ namespace const std::uint32_t serverPacketId = account->AllocateServerPacketId(); const std::uint16_t channelSequence = - AllocateChannelSequence(inventoryChannel); + AllocateChannelSequence(endpoint, inventoryChannel); const std::vector packet = ApbUdp::BuildActorRawFieldPacket( @@ -7476,7 +7543,7 @@ namespace const std::uint32_t diagnosticPacketId = account->AllocateServerPacketId(); const std::uint16_t diagnosticSequence = - AllocateChannelSequence(inventoryChannel); + AllocateChannelSequence(endpoint, inventoryChannel); const std::vector diagnosticPacket = ApbUdp::BuildActorIntFieldPacket( diagnosticPacketId, @@ -7646,7 +7713,7 @@ namespace const std::uint32_t holdableOpenPacketId = account->AllocateServerPacketId(); const std::uint16_t holdableOpenSequence = - AllocateChannelSequence(result.HoldableChannel); + AllocateChannelSequence(endpoint, result.HoldableChannel); const std::vector holdableOpen = ApbUdp::BuildActorOpenPacket( holdableOpenPacketId, @@ -7671,7 +7738,7 @@ namespace const std::uint32_t inventoryOpenPacketId = account->AllocateServerPacketId(); const std::uint16_t inventoryOpenSequence = - AllocateChannelSequence(result.InventoryChannel); + AllocateChannelSequence(endpoint, result.InventoryChannel); const std::vector inventoryOpen = ApbUdp::BuildActorOpenPacket( inventoryOpenPacketId, @@ -7711,7 +7778,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldControllerHoldableItemManager, kPlayerControllerFieldMax, @@ -7735,7 +7802,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldControllerInventory, kPlayerControllerFieldMax, @@ -8730,7 +8797,7 @@ namespace ApbUdp::BuildActorOpenPacket( openPacketId, bridgeChannel, - AllocateChannelSequence(bridgeChannel), + AllocateChannelSequence(endpoint, bridgeChannel), staticTemplate, IsActionDistrict(GetConfiguredDistrictMap()) ? candidate.MarkerX : bridgeActorX, @@ -8808,7 +8875,7 @@ namespace ApbUdp::BuildClientReplicateHudMarkerPacket( packetId, kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), ClientReplicateHudMarkerWireField(), kPlayerControllerFieldMax, marker); @@ -8859,6 +8926,18 @@ namespace packet.data(), packet.size()).c_str()); } + // The client's ServerNotifyClientLoaded (field 372) RPC is queued in + // the same reliable channel as its spawn-zone click and can be delayed + // or dropped (client reliable-buffer overflow, or the give-up timer + // firing at the same moment as the click). The possession gate below + // reads SpawnZoneMarkerSent, so publish the fact that the markers were + // actually sent here -- covering both the 372-driven path and the + // action-district post-flush path (SendLevelStreamingStatus). + if (sentAny) + { + MarkSpawnZoneMarkersSent(account); + } + return sentAny; } @@ -8934,7 +9013,7 @@ namespace ApbUdp::BuildActorOpenPacket( griOpenPacketId, kGriChannel, - AllocateChannelSequence(kGriChannel), + AllocateChannelSequence(endpoint, kGriChannel), griArchetype, 0.0f, 0.0f, @@ -9063,7 +9142,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( packetId, kGriChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kGriChannel), fieldIndex, fieldMax, @@ -9266,7 +9345,7 @@ namespace ApbUdp::BuildClientSetHudPacket( hudPacketId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientSetHud, kPlayerControllerFieldMax, @@ -9341,7 +9420,7 @@ namespace ApbUdp::BuildClientSetInitialStatePacket( initialStatePacketId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), initialStateField, kPlayerControllerFieldMax, @@ -9450,7 +9529,7 @@ namespace ApbUdp::BuildClientReceiveCharacterInfoPacket( packetId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), characterInfoField, kPlayerControllerFieldMax, @@ -9541,7 +9620,7 @@ namespace ApbUdp::BuildClientPrecacheCustomisationPacket( precachePacketId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientPrecacheCustomisation, kPlayerControllerFieldMax, @@ -9648,7 +9727,7 @@ namespace BuildClientGoToSpawnZoneSelectScreenPacket( mapSelectPacketId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), mapSelectField, kPlayerControllerFieldMax, @@ -9730,6 +9809,19 @@ namespace std::map g_controllerFeedbackStates; + void MarkSpawnZoneMarkersSent(Account* account) + { + if (account == nullptr) + return; + + std::lock_guard guard( + g_controllerFeedbackMutex); + + ControllerStreamingFeedbackState& state = + g_controllerFeedbackStates[account->GetId()]; + state.SpawnZoneMarkerSent = true; + } + ApbUdp::FixedByteArrayWireMode ReadCustomisationByteArrayWireMode() { @@ -9857,7 +9949,7 @@ namespace ApbUdp::BuildClientReceiveDataPacket( packetId, replicatorChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, replicatorChannel), kFieldReplicatorClientReceiveData, kCustomisationReplicatorFieldMax, @@ -9937,7 +10029,7 @@ namespace ApbUdp::BuildActorObjectRpcPacket( completePacketId, replicatorChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, replicatorChannel), kFieldReplicatorClientNotifyTransferComplete, kCustomisationReplicatorFieldMax, @@ -10033,7 +10125,7 @@ namespace ApbUdp::BuildActorOpenPacket( openPacketId, replicatorChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, replicatorChannel), archetype, actorX, @@ -10098,7 +10190,7 @@ namespace ApbUdp::BuildActorObjectFieldPacket( account->AllocateServerPacketId(), replicatorChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, replicatorChannel), kFieldReplicatorOwner, kCustomisationReplicatorFieldMax, @@ -10130,7 +10222,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( account->AllocateServerPacketId(), replicatorChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, replicatorChannel), kFieldReplicatorNetOwner, kCustomisationReplicatorFieldMax, @@ -10506,7 +10598,9 @@ namespace state.CustomisationTransferGeneration; markerReady = - state.SpawnZoneMarkerSent; + state.SpawnZoneMarkerSent || + (IsActionDistrict(GetConfiguredDistrictMap()) && + state.ServerSelectSpawnZoneSeen); if (!state.CustomisationTransferCompleted) { @@ -10663,6 +10757,27 @@ namespace std::int32_t LastLocationX = 0; std::int32_t LastLocationY = 0; std::int32_t LastLocationZ = 0; + std::int32_t PrevLocationX = 0; + std::int32_t PrevLocationY = 0; + std::int32_t PrevLocationZ = 0; + // 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. + float VelocityX = 0.0f; + float VelocityY = 0.0f; + float VelocityZ = 0.0f; + // Last non-zero horizontal heading (UE3 yaw units, 65536/turn), + // derived from the movement direction as a fallback. + std::int32_t LastYaw = 0; + // 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 (strafe/backpedal/idle-turn all work). + bool HasViewYaw = false; + std::int32_t LastViewYaw = 0; + // Tick of the last accepted motion sample; used to decay the + // replicated velocity to zero once the owner stops moving. + ULONGLONG LastMotionSampleTick = 0; float LastEstimatedVelocityZ = 0.0f; bool HardFallArmed = false; @@ -10683,150 +10798,1020 @@ namespace std::mutex g_controllerMovementMutex; std::map g_controllerMovementStates; + // ------------------------------------------------------------------ + // Remote pawn replication: make 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, so remote pawns track the moving owner. + // ------------------------------------------------------------------ + constexpr std::uint16_t kRemotePawnChannelBase = 30; - bool ProcessControllerMovementRpc( - SOCKET socket, - const sockaddr_in& endpoint, - Account* account, - const ApbUdp::Packet& packet, - const ApbUdp::Bunch& bunch) + struct RemotePawnLink { - if (account == nullptr) - return false; - - ApbUdp::ControllerMovementRpc movement{}; - std::string decodeError; + bool Opened = false; + std::uint16_t PawnChannel = 0; + std::uint16_t PriChannel = 0; + ULONGLONG LastLocationPushTick = 0; + }; - const bool decoded = - ApbUdp::DecodeControllerMovementRpc( - bunch, - kPlayerControllerFieldMax, - kFieldDualServerMove, - kFieldOldServerMove, - kFieldServerMove, - movement, - decodeError); + std::mutex g_remotePawnMutex; + std::map, RemotePawnLink> + g_remotePawnLinks; + std::map g_remotePawnNextChannel; + std::set g_inWorldAccounts; - if (!movement.Matched) - return false; + // ------------------------------------------------------------------ + // Dead-peer cleanup for the remote-pawn push path. + // + // A viewer whose socket has died (client dropped its district + // connection without a clean teardown -- e.g. a district swap or a + // client GPF) is never removed from g_inWorldAccounts / + // g_remotePawnLinks, so the push loop targets its stale endpoint + // forever; every sendto then surfaces as a recvfrom WSAECONNRESET + // (10054) each cycle. Windows does not report WHICH destination an + // ICMP port-unreachable refers to on an unconnected UDP socket, so a + // reset is attributed to the viewers pushed in the most recent batch. + // That is safe because RX + pushes all run on the UDP listener thread, + // and peers that have provably sent traffic recently (LastSeenTick + // within kDeadPeerLiveWindowMs) are skipped -- a live peer sharing a + // batch with a dead one is never penalized. + // + // Lifecycle: kDeadPeerErrorThreshold consecutive attributed errors -> + // suspend (stop pushing); re-probe (resume one push batch) every + // kDeadPeerProbeIntervalMs; any packet from the peer restores it + // instantly (MarkRemotePawnPeerAlive); after kDeadPeerProbeFailLimit + // failed probes AND kDeadPeerRemoveSilenceMs without any traffic, + // remove the peer from the push list for good. + constexpr std::uint32_t kDeadPeerErrorThreshold = 3; + constexpr ULONGLONG kDeadPeerLiveWindowMs = 30000; + constexpr ULONGLONG kDeadPeerProbeIntervalMs = 10000; + constexpr int kDeadPeerProbeFailLimit = 3; + constexpr ULONGLONG kDeadPeerRemoveSilenceMs = 60000; + + struct DeadPeerState + { + int ConsecutiveErrors = 0; + int FailedProbes = 0; + ULONGLONG NextProbeTick = 0; + ULONGLONG LastSeenTick = 0; + ULONGLONG CreatedTick = 0; + bool Suspended = false; + }; - if (!decoded) - { - Logger( - lWARN, - "District Movement RX", - "account=%u firstField=%u packetId=%u bits=%u " - "decode failed after rpcCount=%u consumedBits=%u: %s", - static_cast(account->GetId()), - static_cast(movement.FieldIndex), - static_cast(packet.PacketId), - static_cast(bunch.DataBitCount), - static_cast(movement.RpcCount), - static_cast(movement.ConsumedBits), - decodeError.c_str()); - return true; - } + std::mutex g_deadPeerMutex; + std::map g_deadPeers; - const bool movementAckEnabled = - ReadHandshakeBool( - "APB_ENABLE_MOVEMENT_ACK", - "EnableMovementAck", - true); + // Viewers pushed in the most recent push batch; the attribution target + // for the next WSAECONNRESET. Touched only on the UDP listener thread + // (the push loop writes it, the recvfrom error branch reads+clears it). + std::vector g_lastPushedViewers; - // Acknowledge the newest processable move in the bunch: - // ServerMove.TimeStamp - // DualServerMove.TimeStamp (or TimeStamp0 when the second is default) - // - // OldServerMove is historical context and is only acknowledged when a - // newer ServerMove/DualServerMove in the same bunch supplies the ACK - // timestamp. This prevents the saved-move queue from growing without - // falsely accepting an isolated historical move. - const bool shouldAck = - movementAckEnabled && - movement.HasTimeStamp; + bool RemotePawnReplicationEnabled() + { + return ReadHandshakeBool( + "APB_REMOTE_PAWN_REPLICATION", + "RemotePawnReplication", + true); + } - bool ackSent = false; + bool IsAccountInWorld(std::uint32_t accountId) + { + std::lock_guard guard( + g_remotePawnMutex); + return g_inWorldAccounts.find(accountId) != + g_inWorldAccounts.end(); + } - if (shouldAck) + std::vector GetInWorldViewersOf( + std::uint32_t targetId) + { + std::vector viewers; + std::lock_guard lock( + g_accountsMutex); + for (Account* candidate : g_accounts) { - std::vector ack = - ApbUdp::BuildUnreliableActorFloatFieldPacket( - account->AllocateServerPacketId(), - kControllerChannel, - kFieldClientAckGoodMove, - kPlayerControllerFieldMax, - movement.TimeStamp); - - ackSent = - SendProtectedPacket( - socket, - endpoint, - account, - ack, - "CLIENT-ACK-GOOD-MOVE"); + if (candidate == nullptr || + candidate->GetId() == targetId || + !candidate->HasEndpoint()) + { + continue; + } + if (IsAccountInWorld(candidate->GetId())) + viewers.push_back(candidate); } + return viewers; + } - std::uint64_t receivedCount = 0; - std::uint64_t ackedCount = 0; + bool GetAccountCurrentLocation( + Account* account, + float& x, + float& y, + float& z) + { + if (account == nullptr) + return false; { std::lock_guard guard( g_controllerMovementMutex); + const auto iterator = + g_controllerMovementStates.find( + account->GetId()); + if (iterator != + g_controllerMovementStates.end() && + iterator->second.HasMotionSample) + { + x = static_cast( + iterator->second.LastLocationX); + y = static_cast( + iterator->second.LastLocationY); + z = static_cast( + iterator->second.LastLocationZ); + return true; + } + } - ControllerMovementFeedbackState& state = - g_controllerMovementStates[ - static_cast( - account->GetId())]; + { + std::lock_guard guard( + g_selectedSpawnMutex); + const auto selected = + g_selectedSpawnLocations.find( + account->GetId()); + if (selected != + g_selectedSpawnLocations.end()) + { + x = selected->second.X; + y = selected->second.Y; + z = selected->second.Z; + return true; + } + } - ++state.Received; - if (ackSent) - ++state.Acked; + ReadControllerLocation(x, y, z); + return true; + } - state.Endpoint = endpoint; + sockaddr_in EndpointForAccount(Account* account) + { + sockaddr_in endpoint{}; + if (account != nullptr && account->HasEndpoint()) + { + endpoint.sin_family = AF_INET; + endpoint.sin_addr.s_addr = + account->GetEndpointAddress(); + endpoint.sin_port = htons( + static_cast( + account->GetEndpointPort())); + } + return endpoint; + } - const bool hardLandingRecoveryEnabled = - ReadHandshakeBool( - "APB_ENABLE_HARD_LANDING_WINDED_TIMER", - "EnableHardLandingWindedTimer", - true); + bool SendRemotePawnToViewer( + SOCKET socket, + Account* viewer, + Account* target) + { + if (viewer == nullptr || target == nullptr || + viewer->GetId() == target->GetId() || + !viewer->HasEndpoint() || + !target->HasCharacterProfile()) + { + return false; + } - if (hardLandingRecoveryEnabled && - movement.HasTimeStamp && - movement.ClientLocationPresent) - { - const std::int32_t currentZ = - movement.ClientLocationZ; + RemotePawnLink link{}; + { + std::lock_guard guard( + g_remotePawnMutex); - if (state.HasMotionSample) - { - const float deltaTime = - movement.TimeStamp - - state.LastTimeStamp; + const auto key = std::make_pair( + viewer->GetId(), target->GetId()); + const auto existing = + g_remotePawnLinks.find(key); + if (existing != g_remotePawnLinks.end() && + existing->second.Opened) + { + return true; + } - // Ignore timestamp resets, duplicate samples, and long - // gaps that cannot provide a useful velocity estimate. - if (deltaTime > 0.001f && - deltaTime < 0.500f) - { - const std::int32_t deltaZ = - currentZ - - state.LastLocationZ; + std::uint16_t& nextChannel = + g_remotePawnNextChannel[ + viewer->GetId()]; + if (nextChannel < kRemotePawnChannelBase) + nextChannel = kRemotePawnChannelBase; - const float estimatedVelocityZ = - static_cast( - deltaZ) / - deltaTime; + link.PawnChannel = nextChannel++; + link.PriChannel = nextChannel++; + link.Opened = true; + g_remotePawnLinks[key] = link; + } - state.LastEstimatedVelocityZ = - estimatedVelocityZ; + const sockaddr_in viewerEndpoint = + EndpointForAccount(viewer); - const int windedSpeedThreshold = - ReadHandshakeInt( - "APB_WINDED_FALL_SPEED_THRESHOLD", - "WindedFallSpeedThreshold", - 1050, - 100, - 5000); + float x = 0.0f; + float y = 0.0f; + float z = 500.0f; + GetAccountCurrentLocation(target, x, y, z); + + const std::uint32_t pawnArchetype = + GlobalNetIndex( + "APBGame", + kPawnArchetypeObjectIndex); + + const std::uint32_t priArchetype = + GlobalNetIndex( + "APBGame", + kPlayerReplicationInfoArchetypeObjectIndex); + + const std::vector pawnOpen = + ApbUdp::BuildActorOpenPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + AllocateChannelSequence( + viewerEndpoint, link.PawnChannel), + pawnArchetype, + x, y, z); + + if (!SendProtectedPacket( + socket, viewerEndpoint, viewer, + pawnOpen, "REMOTE-PAWN-OPEN")) + { + return false; + } + + const bool sendPri = + ReadHandshakeBool( + "APB_REMOTE_PAWN_SEND_PRI", + "RemotePawnSendPRI", + true); + + if (sendPri) + { + const std::vector priOpen = + ApbUdp::BuildActorOpenPacket( + viewer->AllocateServerPacketId(), + link.PriChannel, + AllocateChannelSequence( + viewerEndpoint, link.PriChannel), + priArchetype, + x, y, z); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + priOpen, "REMOTE-PRI-OPEN"); + + // Pawn.PlayerReplicationInfo -> remote PRI channel. + const std::vector pawnPri = + ApbUdp::BuildActorObjectFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + AllocateChannelSequence( + viewerEndpoint, link.PawnChannel), + kFieldPawnPlayerReplicationInfo, + kPawnFieldMax, + link.PriChannel); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + pawnPri, "REMOTE-PAWN-PRI"); + } + + // Character identity fields so the remote pawn renders with the + // owner's character build (same fields the owning connection gets). + const std::int32_t characterUid = + static_cast( + target->GetCharacterId()); + + const std::vector uidPacket = + ApbUdp::BuildActorIntFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + AllocateChannelSequence( + viewerEndpoint, link.PawnChannel), + kFieldPawnControllerCharacterUid, + kPawnFieldMax, + &characterUid, + 1u); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + uidPacket, "REMOTE-PAWN-UID"); + + const std::vector genderPacket = + ApbUdp::BuildActorEnumByteFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + AllocateChannelSequence( + viewerEndpoint, link.PawnChannel), + kFieldPawnGender, + kPawnFieldMax, + target->GetCharacterGender(), + 5u); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + genderPacket, "REMOTE-PAWN-GENDER"); + + const std::vector factionPacket = + ApbUdp::BuildActorEnumByteFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + AllocateChannelSequence( + viewerEndpoint, link.PawnChannel), + kFieldPawnFaction, + kPawnFieldMax, + target->GetCharacterFaction(), + 5u); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + factionPacket, "REMOTE-PAWN-FACTION"); + + const bool sendDescriptor = + ReadHandshakeBool( + "APB_REMOTE_PAWN_SEND_DESCRIPTOR", + "RemotePawnSendDescriptor", + true); + + std::array descriptor{}; + std::string descriptorWireMode = "disabled"; + if (sendDescriptor && + BuildPawnCompactGolemDescriptor( + target, + descriptor, + descriptorWireMode)) + { + const std::vector descriptorPacket = + ApbUdp::BuildActorCompactGolemDescriptorFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + AllocateChannelSequence( + viewerEndpoint, link.PawnChannel), + kFieldPawnCustomisationGuids, + kPawnFieldMax, + descriptor); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + descriptorPacket, "REMOTE-PAWN-CUSTOMISATION-GUIDS"); + } + + Logger( + lSUCCESS, + "District Remote Pawn", + "Opened remote pawn of account=%u on viewer=%u " + "channels pawn=%u pri=%u at (%.1f, %.1f, %.1f).", + static_cast(target->GetId()), + static_cast(viewer->GetId()), + static_cast(link.PawnChannel), + static_cast(link.PriChannel), + x, y, z); + + return true; + } + + void ReplicatePawnToAllViewers( + SOCKET socket, + Account* target) + { + if (target == nullptr || + !RemotePawnReplicationEnabled()) + { + return; + } + + const std::vector viewers = + GetInWorldViewersOf(target->GetId()); + + for (Account* viewer : viewers) + { + SendRemotePawnToViewer( + socket, viewer, target); + } + } + + void ReplicateExistingPawnsToViewer( + SOCKET socket, + Account* viewer) + { + if (viewer == nullptr || + !RemotePawnReplicationEnabled()) + { + return; + } + + std::vector targets; + { + std::lock_guard lock( + g_accountsMutex); + for (Account* candidate : g_accounts) + { + if (candidate == nullptr || + candidate->GetId() == + viewer->GetId() || + !IsAccountInWorld( + candidate->GetId())) + { + continue; + } + targets.push_back(candidate); + } + } + + for (Account* target : targets) + { + SendRemotePawnToViewer( + socket, viewer, target); + } + } + + void NotifyAccountPossessed( + SOCKET socket, + Account* account) + { + if (account == nullptr || + !RemotePawnReplicationEnabled()) + { + return; + } + + { + std::lock_guard guard( + g_remotePawnMutex); + g_inWorldAccounts.insert( + 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. + const int openDelayMilliseconds = + ReadHandshakeInt( + "APB_REMOTE_PAWN_OPEN_DELAY_MS", + "RemotePawnOpenDelayMilliseconds", + 4000, + 0, + 20000); + + if (openDelayMilliseconds > 0) + { + Sleep(static_cast( + openDelayMilliseconds)); + } + + // Newcomer sees everyone already in-world. + ReplicateExistingPawnsToViewer( + socket, account); + + // Everyone already in-world sees the newcomer's pawn. + ReplicatePawnToAllViewers(socket, account); + } + + // Any traffic from a remote-pawn participant proves it is alive: clear + // its error counters and lift a suspension immediately. Called on the + // UDP listener thread whenever a parsed packet resolves to an account. + void MarkRemotePawnPeerAlive(std::uint32_t accountId) + { + std::lock_guard guard(g_deadPeerMutex); + const auto iterator = g_deadPeers.find(accountId); + if (iterator == g_deadPeers.end()) + return; + + DeadPeerState& state = iterator->second; + const bool wasSuspended = state.Suspended; + state.ConsecutiveErrors = 0; + state.FailedProbes = 0; + state.Suspended = false; + state.LastSeenTick = GetTickCount64(); + + if (wasSuspended) + { + Logger( + lSUCCESS, + "District Remote Pawn", + "viewer=%u restored (traffic received).", + static_cast(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 kDeadPeerLiveWindowMs 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. + void AttributeRemotePawnResetErrors() + { + std::lock_guard guard(g_deadPeerMutex); + + if (g_lastPushedViewers.empty()) + return; + + const ULONGLONG now = GetTickCount64(); + + for (std::uint32_t viewerId : g_lastPushedViewers) + { + if (viewerId == 0) + continue; + + const auto created = + g_deadPeers.emplace( + viewerId, + DeadPeerState{}); + if (created.second) + created.first->second.CreatedTick = now; + + DeadPeerState& state = + created.first->second; + + if (state.LastSeenTick != 0 && + now - state.LastSeenTick < kDeadPeerLiveWindowMs) + { + continue; // provably alive; reset came from another peer + } + + ++state.ConsecutiveErrors; + + if (!state.Suspended && + state.ConsecutiveErrors >= + static_cast(kDeadPeerErrorThreshold)) + { + state.Suspended = true; + state.FailedProbes = 0; + state.NextProbeTick = + now + kDeadPeerProbeIntervalMs; + Logger( + lWARN, + "District Remote Pawn", + "viewer=%u marked dead (%d consecutive ICMP errors); " + "pushes paused, re-probing every %llu ms.", + static_cast(viewerId), + state.ConsecutiveErrors, + static_cast( + kDeadPeerProbeIntervalMs)); + } + else if (state.Suspended) + { + ++state.FailedProbes; + Logger( + lWARN, + "District Remote Pawn", + "viewer=%u re-probe %d failed.", + static_cast(viewerId), + state.FailedProbes); + } + } + + g_lastPushedViewers.clear(); + } + + // Remove peers that are gone for good from the push list + // (g_inWorldAccounts + g_remotePawnLinks) and prune stale benign + // dead-peer bookkeeping. Called from the push path on the UDP thread. + void CleanupDeadRemotePawnPeers() + { + std::vector toRemove; + std::vector toPrune; + + { + std::lock_guard guard(g_deadPeerMutex); + const ULONGLONG now = GetTickCount64(); + + for (const auto& entry : g_deadPeers) + { + const DeadPeerState& state = entry.second; + if (state.Suspended) + { + if (state.FailedProbes < + kDeadPeerProbeFailLimit) + { + continue; + } + if (state.LastSeenTick != 0 && + now - state.LastSeenTick < + kDeadPeerRemoveSilenceMs) + { + continue; + } + toRemove.push_back(entry.first); + } + 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. + const ULONGLONG idleSince = + state.LastSeenTick != 0 + ? state.LastSeenTick + : state.CreatedTick; + if (now - idleSince >= + kDeadPeerRemoveSilenceMs) + { + toPrune.push_back(entry.first); + } + } + } + } + + if (toRemove.empty() && toPrune.empty()) + return; + + for (std::uint32_t accountId : toRemove) + { + { + std::lock_guard guard( + g_remotePawnMutex); + g_inWorldAccounts.erase(accountId); + + for (auto iterator = g_remotePawnLinks.begin(); + iterator != g_remotePawnLinks.end();) + { + if (iterator->first.first == accountId) + iterator = + g_remotePawnLinks.erase(iterator); + else + ++iterator; + } + } + + { + std::lock_guard guard( + g_deadPeerMutex); + g_deadPeers.erase(accountId); + } + + Logger( + lWARN, + "District Remote Pawn", + "viewer=%u removed from push list (dead peer, %d failed " + "re-probes, no traffic for %llu ms).", + static_cast(accountId), + kDeadPeerProbeFailLimit, + static_cast( + kDeadPeerRemoveSilenceMs)); + } + + if (!toPrune.empty()) + { + std::lock_guard guard(g_deadPeerMutex); + for (std::uint32_t accountId : toPrune) + g_deadPeers.erase(accountId); + } + } + + void MaybePushRemotePawnLocations( + SOCKET socket, + Account* source) + { + if (source == nullptr || + !RemotePawnReplicationEnabled() || + !ReadHandshakeBool( + "APB_REMOTE_PAWN_SEND_LOCATION", + "RemotePawnSendLocation", + true) || + !IsAccountInWorld(source->GetId())) + { + 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). + g_lastPushedViewers.clear(); + + float x = 0.0f; + float y = 0.0f; + float z = 500.0f; + if (!GetAccountCurrentLocation(source, x, y, z)) + return; + + const ULONGLONG now = GetTickCount64(); + // Track the source's ServerMove cadence (~30 Hz) so the remote pawn + // moves smoothly without relying on the engine's interpolation. + constexpr ULONGLONG kRemotePawnPushIntervalMs = 33; + + float velocityX = 0.0f; + float velocityY = 0.0f; + float velocityZ = 0.0f; + std::int32_t yaw = 0; + { + std::lock_guard guard( + g_controllerMovementMutex); + const auto iterator = + g_controllerMovementStates.find( + source->GetId()); + if (iterator != + g_controllerMovementStates.end()) + { + // 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 (GetTickCount64() - + iterator->second.LastMotionSampleTick < + 250) + { + velocityX = iterator->second.VelocityX; + velocityY = iterator->second.VelocityY; + velocityZ = iterator->second.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 (strafe/backpedal/idle-turn). + yaw = iterator->second.HasViewYaw + ? iterator->second.LastViewYaw + : iterator->second.LastYaw; + } + } + + const bool sendVelocity = + ReadHandshakeBool( + "APB_REMOTE_PAWN_SEND_VELOCITY", + "RemotePawnSendVelocity", + true); + + const bool sendRotation = + ReadHandshakeBool( + "APB_REMOTE_PAWN_SEND_ROTATION", + "RemotePawnSendRotation", + true); + + const std::vector viewers = + GetInWorldViewersOf(source->GetId()); + + for (Account* viewer : viewers) + { + // Dead-peer gate: suspended viewers are not pushed until their + // next re-probe window; a re-probe resumes one push batch (a + // live peer's own traffic then restores it immediately). + bool pushAllowed = true; + { + std::lock_guard guard(g_deadPeerMutex); + const auto deadIterator = + g_deadPeers.find(viewer->GetId()); + if (deadIterator != + g_deadPeers.end() && + deadIterator->second.Suspended) + { + DeadPeerState& deadState = + deadIterator->second; + if (now < deadState.NextProbeTick) + { + pushAllowed = false; + } + else + { + deadState.NextProbeTick = + now + kDeadPeerProbeIntervalMs; + } + } + } + if (!pushAllowed) + continue; + + RemotePawnLink link{}; + { + std::lock_guard guard( + g_remotePawnMutex); + const auto iterator = + g_remotePawnLinks.find( + std::make_pair( + viewer->GetId(), + source->GetId())); + if (iterator == + g_remotePawnLinks.end() || + !iterator->second.Opened) + { + continue; + } + if (now - iterator->second.LastLocationPushTick < + kRemotePawnPushIntervalMs) + { + continue; + } + link = iterator->second; + iterator->second.LastLocationPushTick = now; + } + + const sockaddr_in viewerEndpoint = + EndpointForAccount(viewer); + + // Velocity first (Actor field 4, live cache) so the AnimTree + // blends walk/run before the position steps. All three pushes + // are UNRELIABLE: at the ServerMove cadence reliable bunches + // overflow the client's channel and the remote pawn vanishes. + if (sendVelocity) + { + const std::vector velocity = + ApbUdp::BuildUnreliableActorVectorFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + kFieldVelocity, + kPawnFieldMax, + velocityX, velocityY, velocityZ); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + velocity, "REMOTE-PAWN-VELOCITY"); + } + + if (sendRotation) + { + const std::vector rotation = + ApbUdp::BuildUnreliableActorRotatorFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + kFieldRotation, + kPawnFieldMax, + 0, yaw, 0); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + rotation, "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: the + // first float's low bit becomes the width field and the decoded + // "location" lands outside the district grid, which GPFs the + // client ("Illegal location ... outside grid"). + const std::vector location = + ApbUdp::BuildUnreliableActorVectorFieldPacket( + viewer->AllocateServerPacketId(), + link.PawnChannel, + kFieldLocation, + kPawnFieldMax, + x, y, z); + + SendProtectedPacket( + socket, viewerEndpoint, viewer, + location, "REMOTE-PAWN-LOCATION"); + + // This viewer received packets this batch; a WSAECONNRESET on + // the next recvfrom may be the ICMP echo from one of them. + g_lastPushedViewers.push_back( + viewer->GetId()); + } + } + + bool ProcessControllerMovementRpc( + SOCKET socket, + const sockaddr_in& endpoint, + Account* account, + const ApbUdp::Packet& packet, + const ApbUdp::Bunch& bunch) + { + if (account == nullptr) + return false; + + ApbUdp::ControllerMovementRpc movement{}; + std::string decodeError; + + const bool decoded = + ApbUdp::DecodeControllerMovementRpc( + bunch, + kPlayerControllerFieldMax, + kFieldDualServerMove, + kFieldOldServerMove, + kFieldServerMove, + movement, + decodeError); + + if (!movement.Matched) + return false; + + if (!decoded) + { + Logger( + lWARN, + "District Movement RX", + "account=%u firstField=%u packetId=%u bits=%u " + "decode failed after rpcCount=%u consumedBits=%u: %s", + static_cast(account->GetId()), + static_cast(movement.FieldIndex), + static_cast(packet.PacketId), + static_cast(bunch.DataBitCount), + static_cast(movement.RpcCount), + static_cast(movement.ConsumedBits), + decodeError.c_str()); + return true; + } + + const bool movementAckEnabled = + ReadHandshakeBool( + "APB_ENABLE_MOVEMENT_ACK", + "EnableMovementAck", + true); + + // Acknowledge the newest processable move in the bunch: + // ServerMove.TimeStamp + // DualServerMove.TimeStamp (or TimeStamp0 when the second is default) + // + // OldServerMove is historical context and is only acknowledged when a + // newer ServerMove/DualServerMove in the same bunch supplies the ACK + // timestamp. This prevents the saved-move queue from growing without + // falsely accepting an isolated historical move. + const bool shouldAck = + movementAckEnabled && + movement.HasTimeStamp; + + bool ackSent = false; + + if (shouldAck) + { + std::vector ack = + ApbUdp::BuildUnreliableActorFloatFieldPacket( + account->AllocateServerPacketId(), + kControllerChannel, + kFieldClientAckGoodMove, + kPlayerControllerFieldMax, + movement.TimeStamp); + + ackSent = + SendProtectedPacket( + socket, + endpoint, + account, + ack, + "CLIENT-ACK-GOOD-MOVE"); + } + + std::uint64_t receivedCount = 0; + std::uint64_t ackedCount = 0; + + { + std::lock_guard guard( + g_controllerMovementMutex); + + ControllerMovementFeedbackState& state = + g_controllerMovementStates[ + static_cast( + account->GetId())]; + + ++state.Received; + if (ackSent) + ++state.Acked; + + state.Endpoint = endpoint; + + const bool hardLandingRecoveryEnabled = + ReadHandshakeBool( + "APB_ENABLE_HARD_LANDING_WINDED_TIMER", + "EnableHardLandingWindedTimer", + true); + + if (hardLandingRecoveryEnabled && + movement.HasTimeStamp && + movement.ClientLocationPresent) + { + const std::int32_t currentZ = + movement.ClientLocationZ; + + if (state.HasMotionSample) + { + const float deltaTime = + movement.TimeStamp - + state.LastTimeStamp; + + // Ignore timestamp resets, duplicate samples, and long + // gaps that cannot provide a useful velocity estimate. + if (deltaTime > 0.001f && + deltaTime < 0.500f) + { + const std::int32_t deltaZ = + currentZ - + state.LastLocationZ; + + const float estimatedVelocityZ = + static_cast( + deltaZ) / + deltaTime; + + state.LastEstimatedVelocityZ = + estimatedVelocityZ; + + const int windedSpeedThreshold = + ReadHandshakeInt( + "APB_WINDED_FALL_SPEED_THRESHOLD", + "WindedFallSpeedThreshold", + 1050, + 100, + 5000); const int landingVelocityTolerance = ReadHandshakeInt( @@ -10989,20 +11974,125 @@ namespace deltaTime >= 0.500f) { // Start a clean velocity baseline after a timestamp - // reset or a long packet gap. + // 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). + const 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 ("walking = running" after a lag gap). + constexpr float kVelocityBlend = 0.5f; + const float rawVelocityX = + static_cast( + movement.ClientLocationX - + state.PrevLocationX) / + sampleDeltaTime; + const float rawVelocityY = + static_cast( + movement.ClientLocationY - + state.PrevLocationY) / + sampleDeltaTime; + const float rawVelocityZ = + static_cast( + 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. + constexpr float kMaxHorizontalSpeed = 1500.0f; + const float horizontalSpeed = + std::sqrt( + state.VelocityX * state.VelocityX + + state.VelocityY * state.VelocityY); + if (horizontalSpeed > kMaxHorizontalSpeed) + { + const float scale = + kMaxHorizontalSpeed / + horizontalSpeed; + state.VelocityX *= scale; + state.VelocityY *= scale; + } + constexpr float kMaxVerticalSpeed = 2500.0f; + state.VelocityZ = (std::max)( + -kMaxVerticalSpeed, + (std::min)(kMaxVerticalSpeed, state.VelocityZ)); + + const float horizontalSpeedSquared = + state.VelocityX * state.VelocityX + + state.VelocityY * state.VelocityY; + if (horizontalSpeedSquared > 25.0f) + { + const double heading = std::atan2( + static_cast( + state.VelocityY), + static_cast( + state.VelocityX)); + state.LastYaw = + static_cast( + 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 = + static_cast( + movement.View & 0xFFFFu); + } + state.HasMotionSample = true; state.LastLocationX = movement.ClientLocationX; state.LastLocationY = movement.ClientLocationY; state.LastLocationZ = currentZ; state.LastTimeStamp = movement.TimeStamp; + state.LastMotionSampleTick = + GetTickCount64(); } else if (movement.HasTimeStamp) { @@ -11014,6 +12104,13 @@ namespace ackedCount = state.Acked; } + // Multiplayer visibility: publish the moved player's pawn location + // to every other in-world player (throttled per remote link). + if (movement.ClientLocationPresent) + { + MaybePushRemotePawnLocations(socket, account); + } + const bool logSummary = ReadHandshakeBool( "APB_LOG_MOVEMENT_SUMMARY", @@ -11207,7 +12304,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( moveInputPacketId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), kFieldClientIgnoreMoveInput, kPlayerControllerFieldMax, @@ -11249,7 +12346,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( windedPacketId, kPawnChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kPawnChannel), windedField, kPawnFieldMax, @@ -11459,7 +12556,7 @@ namespace ApbUdp::BuildClientReceiveCharacterDataPacket( packetId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), responseField, kPlayerControllerFieldMax, @@ -11669,7 +12766,7 @@ namespace ApbUdp::BuildClientReceiveCharacterStatsPacket( packetId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), responseField, kPlayerControllerFieldMax, @@ -11805,7 +12902,7 @@ namespace ApbUdp::BuildClientReceiveCharacterRolesDataPacket( packetId, kControllerChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kControllerChannel), responseField, kPlayerControllerFieldMax, @@ -11935,7 +13032,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( wantsPacketId, kPawnChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kPawnChannel), wantsField, kPawnFieldMax, @@ -11980,7 +13077,7 @@ namespace ApbUdp::BuildActorBoolFieldPacket( isPacketId, kPawnChannel, - AllocateChannelSequence( + AllocateChannelSequence(endpoint, kPawnChannel), isField, kPawnFieldMax, @@ -12160,7 +13257,7 @@ namespace ApbUdp::BuildActorDefaultRpcPacket( resetPacketId, kControllerChannel, - AllocateChannelSequence(kControllerChannel), + AllocateChannelSequence(endpoint, kControllerChannel), 344u, kPlayerControllerFieldMax, 1u), @@ -12465,53 +13562,31 @@ namespace static_cast(field.EndBit), firstReceipt ? " (first receipt)" : " (duplicate)"); - // Record the picked spawn location for every district so - // the pawn opens at a location the client accepts. - // Action-district clients reference the bridge channel - // (kSpawnZoneActorChannel + zone index); the social - // client references the static spawn-zone template NetIndex - // (72913 Enforcer / 72914 Criminal) instead. - if (firstReceipt) + if (firstReceipt && + IsActionDistrict(GetConfiguredDistrictMap())) { - std::size_t zoneIndex = static_cast(-1); + CancelPendingReliablesByLabelPrefix( + account->GetId(), + { "SPAWN-ZONE-ACTOR-BRIDGE-OPEN-", + "CLIENT-REPLICATE-HUD-MARKER-" }); + if (field.ObjectReferenceByChannel && field.ObjectReferenceValue >= kSpawnZoneActorChannel && field.ObjectReferenceValue < kSpawnZoneActorChannel + 12u) { - zoneIndex = static_cast( + const std::size_t index = static_cast( field.ObjectReferenceValue - kSpawnZoneActorChannel); - } - else if (!field.ObjectReferenceByChannel && - field.ObjectReferenceValue >= 72913u && - field.ObjectReferenceValue <= 72914u) - { - zoneIndex = static_cast( - field.ObjectReferenceValue - 72913u); - } - - if (zoneIndex != static_cast(-1)) - { float x = 0.0f; float y = 0.0f; float z = 0.0f; - if (TryGetSpawnZoneLocation( - GetConfiguredDistrictMap(), - zoneIndex, x, y, z)) + if (TryGetActionSpawnDirection( + GetConfiguredDistrictMap(), index, x, y, z)) { std::lock_guard guard(g_selectedSpawnMutex); g_selectedSpawnLocations[account->GetId()] = { x, y, z + 100.0f }; } } - } - - if (firstReceipt && - IsActionDistrict(GetConfiguredDistrictMap())) - { - CancelPendingReliablesByLabelPrefix( - account->GetId(), - { "SPAWN-ZONE-ACTOR-BRIDGE-OPEN-", - "CLIENT-REPLICATE-HUD-MARKER-" }); // Marker actors are selection-screen scaffolding. They // must not remain as live synthetic world actors. @@ -12524,7 +13599,7 @@ namespace socket, endpoint, account, closePacketId, ApbUdp::BuildActorClosePacket( closePacketId, channel, - AllocateChannelSequence(channel)), + AllocateChannelSequence(endpoint, channel)), "SPAWN-ZONE-ACTOR-CLOSE"); } } @@ -13590,7 +14665,35 @@ namespace if (received == SOCKET_ERROR) { - Logger(lERROR, "District UDP", "recvfrom() failed: %d", WSAGetLastError()); + const int socketError = WSAGetLastError(); + + // 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). It is benign for UDP but + // used to spam the log on every send cycle; log it at most + // once per 10 seconds. + if (socketError == WSAECONNRESET) + { + // Attribute the ICMP echo to the viewers pushed in the + // previous batch for dead-peer cleanup, then log at + // most once per 10 seconds. + AttributeRemotePawnResetErrors(); + + static ULONGLONG lastResetLogTick = 0; + const ULONGLONG now = GetTickCount64(); + if (now - lastResetLogTick > 10000) + { + lastResetLogTick = now; + Logger( + lWARN, + "District UDP", + "recvfrom() WSAECONNRESET (dead-peer ICMP); " + "subsequent resets suppressed for 10 s."); + } + continue; + } + + Logger(lERROR, "District UDP", "recvfrom() failed: %d", socketError); Sleep(100); continue; } @@ -13601,13 +14704,25 @@ namespace receiveBuffer.data(), static_cast(received)); - Logger( - lINFO, - "District UDP", - "RX %d bytes from %s | %s", - received, - EndpointText(remoteAddress).c_str(), - ApbUdp::Hex(receiveBuffer.data(), static_cast(received), 512).c_str()); + if (PacketHexLogEnabled()) + { + Logger( + lINFO, + "District UDP", + "RX %d bytes from %s | %s", + received, + EndpointText(remoteAddress).c_str(), + ApbUdp::Hex(receiveBuffer.data(), static_cast(received), 512).c_str()); + } + else + { + Logger( + lINFO, + "District UDP", + "RX %d bytes from %s", + received, + EndpointText(remoteAddress).c_str()); + } ApbUdp::Packet packet; @@ -13693,6 +14808,15 @@ namespace Account* endpointAccount = FindAccountByEndpoint(remoteAddress); + // 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 != nullptr) + { + 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, not sent @@ -13776,15 +14900,24 @@ namespace } } + // Keyed by the client endpoint, not the account id. The + // old global account-id key was never cleared, so a + // reconnect of the same account from a new socket had its + // ASK received (field 132, seq 1) 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. static std::mutex possessionLock; - static std::set possessed; + static std::set< + std::pair> possessed; if (sawControllerChannel) { bool doIt = false; { std::lock_guard guard(possessionLock); - doIt = possessed.insert( - endpointAccount->GetId()).second; + doIt = possessed.insert(std::make_pair( + remoteAddress.sin_addr.s_addr, + ntohs(remoteAddress.sin_port))).second; } if (doIt) @@ -13948,6 +15081,25 @@ int main() { Log_Clear(); + // When APB_LOG_CONSOLE=1 the per-line console printf is suppressed + // (DistrictLog.txt always receives the line). Console writes are slow, + // especially when the DS is launched with redirected stdout. + g_logQuietConsole = ReadHandshakeBool( + "APB_LOG_CONSOLE", + "LogQuietConsole", + false); + + // Packet tracing gates (see PacketHexLogEnabled/PacketCaptureEnabled). + // Read once here, before the UDP thread starts. + g_logPacketHex = ReadHandshakeBool( + "APB_LOG_PACKET_HEX", + "LogPacketHex", + false); + g_capturePackets = ReadHandshakeBool( + "APB_CAPTURE_PACKETS", + "CapturePackets", + false); + std::string selfTest; if (!ApbUdp::RunSelfTest(selfTest)) { @@ -14083,7 +15235,33 @@ int main() Logger(lINFO, "Network::Send()", "Initial district registration data sent"); std::unique_ptr initial(network.Receive(2)); - if (!initial || !ProcessRegistrationResponse(initial.get())) + if (!initial) + { + Logger(lERROR, "WorldControl", "Initial packet failed to process"); + return 1; + } + + // The WorldServer's same-IP REPLACE reply is TWO 2-byte replies for one + // registration: a '1' prelude (old entry replaced) followed by the real + // reply '3' (registered). The '1' is not a final reply -- consume it and + // process the actual reply so no message is discarded and the trailing + // bytes cannot leak into the control-record stream below. + if (initial[1] == '1') + { + std::unique_ptr replaced( + network.Receive(2)); + if (!replaced) + { + Logger( + lERROR, + "WorldControl", + "Replace reply truncated; aborting."); + return 1; + } + initial = std::move(replaced); + } + + if (!ProcessRegistrationResponse(initial.get())) { Logger(lERROR, "WorldControl", "Initial packet failed to process"); return 1; diff --git a/DistrictServer/stdafx.cpp b/DistrictServer/stdafx.cpp index 0c9720a..0268ffa 100644 --- a/DistrictServer/stdafx.cpp +++ b/DistrictServer/stdafx.cpp @@ -5,6 +5,10 @@ #include #include #include +#include +#include + +bool g_logQuietConsole = false; void setColor(unsigned int color) { @@ -12,8 +16,85 @@ void setColor(unsigned int color) SetConsoleTextAttribute(screen, color); } +void EnsureLogDirectory() +{ + if (CreateDirectoryA("Logs", nullptr) == 0 && + GetLastError() != ERROR_ALREADY_EXISTS) + { + // Best effort: calls into the directory would otherwise fail + // silently (fopen returns null when the parent folder is missing). + } +} + +// 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 today's failure mode where a district restart +// truncated the evidence of an in-progress session. +void RotatePreviousLog() +{ + EnsureLogDirectory(); + + const char* logName = "Logs\\DistrictLog.txt"; + + if (GetFileAttributesA(logName) == INVALID_FILE_ATTRIBUTES) + return; // no previous run + + SYSTEMTIME st; + GetLocalTime(&st); + + char baseName[64]; + sprintf_s( + baseName, + "DistrictLog-%04u%02u%02u-%02u%02u%02u", + st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond); + + // 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) + { + char backup[MAX_PATH]; + if (attempt == 0) + { + snprintf(backup, sizeof(backup), + "Logs\\%s.txt", baseName); + } + else + { + snprintf(backup, sizeof(backup), + "Logs\\%s-%03d.txt", baseName, attempt); + } + + if (MoveFileA(logName, backup) != 0) + return; + + if (GetLastError() != ERROR_ALREADY_EXISTS) + return; // rename failed for a real reason; keep the old log + } +} + +namespace +{ + std::mutex g_logMutex; + FILE* g_logFile = nullptr; +} + void Log_Clear() { + std::lock_guard lock(g_logMutex); + + // Drop the persistent handle first so the rotation below can rename the + // file while the handle is still open. + if (g_logFile != nullptr) + { + fclose(g_logFile); + g_logFile = nullptr; + } + + RotatePreviousLog(); + + EnsureLogDirectory(); + FILE *file = nullptr; if (fopen_s(&file, "Logs\\DistrictLog.txt", "w") == 0 && file != nullptr) { @@ -21,32 +102,56 @@ void Log_Clear() } } +// Logger keeps ONE persistent handle to Logs\DistrictLog.txt for the whole +// process instead of fopen/fclose per line, and skips console writes when +// g_logQuietConsole is set. The old implementation opened+closed the file AND +// printf'ed to the console on every line; at the district push rate +// (~90+ packets/sec while remote pawns are replicated, plus per-packet RX +// hex dumps) that was a file+console I/O storm that made the server lag +// behind the clients (the "can't catch up" / connection-issue symptoms). void Logger(unsigned int lvl, const char* caller, const char* logline, ...) { - FILE *file = nullptr; - if (fopen_s(&file, "Logs\\DistrictLog.txt", "a+") != 0 || file == nullptr) - { - return; - } char timeStr[9]; char logOut[1024]; _strtime_s(timeStr); + + va_list argList; + va_start(argList, logline); + vsnprintf(logOut, sizeof(logOut), logline, argList); + va_end(argList); + + std::lock_guard lock(g_logMutex); + + if (g_logFile == nullptr) + { + EnsureLogDirectory(); + // Open with explicit deny-none sharing: the drive/launch scripts + // tail and poll DistrictLog.txt while the DS is running. A default + // fopen_s "a+" keeps the handle open for the whole process, which + // locks the file against concurrent readers. + g_logFile = _fsopen("Logs\\DistrictLog.txt", "a+", _SH_DENYNO); + } + + if (g_logFile != nullptr) + { + fprintf(g_logFile, "[%s] %s: %s\n", timeStr, caller, logOut); + // Flush per line so log tails (the drive scripts) see the line + // immediately. A buffered fflush is a single write syscall -- orders + // of magnitude cheaper than the old fopen/fclose cycle. + fflush(g_logFile); + } + + if (g_logQuietConsole) + return; + setColor(DARKGREY); printf("[%s] ", timeStr); - fprintf(file, "[%s] ", timeStr); setColor(LIGHTCYAN); printf("%s: ", caller); - fprintf(file, "%s: ", caller); if (lvl == lINFO) setColor(WHITE); else if (lvl == lWARN) setColor(YELLOW); else if (lvl == lSUCCESS) setColor(GREEN); else if (lvl == lERROR) setColor(RED); else if (lvl == lDEBUG) setColor(BLUE); - va_list argList; - va_start(argList, logline); - vsnprintf(logOut, 1024, logline, argList); - va_end(argList); printf("%s\n", logOut); - fprintf(file, "%s\n", logOut); - fclose(file); } \ No newline at end of file diff --git a/DistrictServer/stdafx.h b/DistrictServer/stdafx.h index 7bb084e..0bb6430 100644 --- a/DistrictServer/stdafx.h +++ b/DistrictServer/stdafx.h @@ -37,3 +37,10 @@ using namespace std; void Logger(unsigned int lvl, const char* caller, const char* logline, ...); void Log_Clear(); + +// Set from configuration at boot (APB_LOG_CONSOLE). When true the console +// output is suppressed; the DistrictLog.txt file always receives the line. +// The old per-line fopen/fclose plus console printf was a hard bottleneck at +// high packet rates (the district pushes ~90 packets/sec while streaming), +// which made the server "lag behind" the clients. +extern bool g_logQuietConsole;