fix: construct a player in jetpack mode with its jetpack

LWOControllablePhysComponent::Deserialize (0x00c45770) reads the jetpack
mode, effect, flying and bypass checks bits on construction, and
LWOControllablePhysComponent::SendMessage (0x00d2f020) keeps the same
from SetJetPackMode. DLU sent the message but never kept it, so a player
who came into range of someone flying was told they had no jetpack.

Live player constructions in jetpack mode carried effect 167, flying 0
and bypass checks 1, and a 0 after the pickup radius (the client reads
that bit into the flying flag). DLU wrote the jetpack mode there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 13:07:28 -06:00
parent 102abc32f5
commit f4d3d794fd
7 changed files with 64 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
#include "JetPackBehavior.h"
#include "ControllablePhysicsComponent.h"
#include "BehaviorBranchContext.h"
#include "GameMessages.h"
@@ -21,6 +22,7 @@ void JetPackBehavior::Handle(BehaviorContext* context, RakNet::BitStream& bit_st
jetPackMode.fVertVel = this->m_VerticalVelocity;
jetPackMode.iWarningEffectID = this->m_WarningEffectID;
jetPackMode.Send(UNASSIGNED_SYSTEM_ADDRESS);
if (auto* const physics = entity->GetComponent<ControllablePhysicsComponent>()) physics->SetJetPackMode(jetPackMode.bUse, jetPackMode.effectID, jetPackMode.bBypassChecks);
if (entity->IsPlayer()) {
auto* character = entity->GetCharacter();
@@ -39,6 +41,7 @@ void JetPackBehavior::UnCast(BehaviorContext* context, BehaviorBranchContext bra
jetPackMode.target = entity->GetObjectID();
jetPackMode.bUse = false;
jetPackMode.Send(UNASSIGNED_SYSTEM_ADDRESS);
if (auto* const physics = entity->GetComponent<ControllablePhysicsComponent>()) physics->SetJetPackMode(jetPackMode.bUse, jetPackMode.effectID, jetPackMode.bBypassChecks);
if (entity->IsPlayer()) {
auto* character = entity->GetCharacter();

View File

@@ -3,6 +3,7 @@
*/
#include "RacingControlComponent.h"
#include "ControllablePhysicsComponent.h"
#include "CharacterComponent.h"
#include "DestroyableComponent.h"
@@ -276,6 +277,7 @@ void RacingControlComponent::LoadPlayerVehicle(Entity* player,
jetPackMode.target = player->GetObjectID();
jetPackMode.bUse = false;
jetPackMode.Send(UNASSIGNED_SYSTEM_ADDRESS);
if (auto* const physics = player->GetComponent<ControllablePhysicsComponent>()) physics->SetJetPackMode(jetPackMode.bUse, jetPackMode.effectID, jetPackMode.bBypassChecks);
// Set the vehicle's state.
GameMessages::NotifyVehicleOfRacingObject notifyVehicle;

View File

@@ -70,6 +70,7 @@ void PossessorComponent::Mount(Entity* mount) {
jetPackMode.target = m_Parent->GetObjectID();
jetPackMode.bUse = false;
jetPackMode.Send(UNASSIGNED_SYSTEM_ADDRESS);
if (auto* const physics = m_Parent->GetComponent<ControllablePhysicsComponent>()) physics->SetJetPackMode(jetPackMode.bUse, jetPackMode.effectID, jetPackMode.bBypassChecks);
if (mount->GetComponent<HavokVehiclePhysicsComponent>()) {
auto characterComponent = m_Parent->GetComponent<CharacterComponent>();
if (characterComponent) characterComponent->SetIsRacing(true);

View File

@@ -125,7 +125,9 @@ void ControllablePhysicsComponent::Serialize(RakNet::BitStream& outBitStream, bo
outBitStream.Write(m_DirtyEquippedItemInfo || bIsInitialUpdate);
if (m_DirtyEquippedItemInfo || bIsInitialUpdate) {
outBitStream.Write(m_PickupRadius);
outBitStream.Write(m_InJetpackMode);
// LWOControllablePhysComponent::Deserialize (0x00c45770) reads this into the same flag as the jetpack block's
// flying bit; live wrote 0, also for players in jetpack mode (10,069 player constructions)
outBitStream.Write(m_JetpackFlying);
if (!bIsInitialUpdate) m_DirtyEquippedItemInfo = false;
}
@@ -181,6 +183,12 @@ void ControllablePhysicsComponent::Serialize(RakNet::BitStream& outBitStream, bo
}
}
void ControllablePhysicsComponent::SetJetPackMode(const bool use, const int32_t effectID, const bool bypassChecks) {
m_InJetpackMode = use;
m_JetpackEffectID = effectID;
m_JetpackBypassChecks = bypassChecks;
}
// A character's save, then where it comes into this world
void ControllablePhysicsComponent::LoadFromSaveData(const tinyxml2::XMLDocument& doc) {
ReadSaveData(doc);

View File

@@ -150,6 +150,12 @@ public:
*/
const bool GetJetpackBypassChecks() const { return m_JetpackBypassChecks; }
/**
* Keeps what a SetJetPackMode sent to the clients said, for the construction of this object: use, effect and
* bypass checks, as LWOControllablePhysComponent::SendMessage (0x00d2f020) keeps them
*/
void SetJetPackMode(bool use, int32_t effectID, bool bypassChecks);
/**
* Set the jetpack effect ID
* @param effectID the effect to play while using the jetpack
@@ -337,12 +343,12 @@ private:
/**
* Whether or not this entity is currently flying a jetpack
*/
bool m_JetpackFlying;
bool m_JetpackFlying{ false };
/**
* Bypass jetpack checks, currently unused
* Bypass jetpack checks, from the last SetJetPackMode
*/
bool m_JetpackBypassChecks;
bool m_JetpackBypassChecks{ false };
/**
* The effect that plays while using the jetpack

View File

@@ -1,4 +1,5 @@
#include "GMGreaterThanZeroCommands.h"
#include "ControllablePhysicsComponent.h"
#include "ChatServerLink.h"
#include "DashboardNotify.h"
#include "SlashCommandHandler.h"
@@ -206,6 +207,7 @@ namespace GMGreaterThanZeroCommands {
jetPackMode.target = entity->GetObjectID();
jetPackMode.bUse = false;
jetPackMode.Send(UNASSIGNED_SYSTEM_ADDRESS);
if (auto* const physics = entity->GetComponent<ControllablePhysicsComponent>()) physics->SetJetPackMode(jetPackMode.bUse, jetPackMode.effectID, jetPackMode.bBypassChecks);
character->SetIsFlying(false);
} else {
@@ -235,6 +237,7 @@ namespace GMGreaterThanZeroCommands {
jetPackMode.fMaxAirspeed = maxAirSpeed;
jetPackMode.fVertVel = verticalVelocity;
jetPackMode.Send(UNASSIGNED_SYSTEM_ADDRESS);
if (auto* const physics = entity->GetComponent<ControllablePhysicsComponent>()) physics->SetJetPackMode(jetPackMode.bUse, jetPackMode.effectID, jetPackMode.bBypassChecks);
character->SetIsFlying(true);
}

View File

@@ -173,6 +173,43 @@ TEST_F(ReplicaConstructionTest, CharacterActivityAndPvpChangesAreWrittenOnce) {
player.SetCharacter(nullptr);
}
// A player in jetpack mode is constructed in it: live player constructions carried the jetpack block (effect 167,
// flying 0, bypass checks 1) and a 0 after the pickup radius. LWOControllablePhysComponent::Deserialize reads both.
TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionCarriesJetPackMode) {
Entity player(1152921506064087006, info);
auto* const physics = player.AddComponent<ControllablePhysicsComponent>(-1);
const auto construct = [physics]() {
RakNet::BitStream construction;
physics->Serialize(construction, true);
return Bits(construction);
};
const auto bits = [](const RakNet::BitStream& stream) { return Bits(stream); };
// No jetpack: one 0 bit, then the stun immunities
const auto plain = construct();
EXPECT_EQ(plain.substr(0, 2), "01");
physics->SetJetPackMode(true, 167, true);
RakNet::BitStream head;
head.Write1(); // jetpack
head.Write<int32_t>(167); // effect
head.Write0(); // flying
head.Write1(); // bypass checks
head.Write1(); // stun immunities
const auto jetpack = construct();
EXPECT_EQ(jetpack.substr(0, head.GetNumberOfBitsUsed()), bits(head));
// After the 7 immunity counts: no cheats, then the pickup radius and a 0 (not the jetpack flag)
const size_t afterImmunities = head.GetNumberOfBitsUsed() + 7 * 32;
EXPECT_EQ(jetpack.substr(afterImmunities, 2), "01");
EXPECT_EQ(jetpack[afterImmunities + 2 + 32], '0');
// The rest is what a player without a jetpack gets
EXPECT_EQ(jetpack.substr(head.GetNumberOfBitsUsed()), plain.substr(2));
physics->SetJetPackMode(false, -1, false);
EXPECT_EQ(construct(), plain);
}
// A guild change (docs/Guilds.md) is written once: the client reads guild id, a u8 length and 16-bit characters, and
// redraws the name billboard every time it reads a name.
TEST_F(ReplicaConstructionTest, CharacterGuildChangeIsWrittenOnce) {