fix(physics): simple-physics motion type and velocity like live

Live's simple-physics constructions (40,786 in the captures):
- Motion type Fixed (5) for objects without a motionType (38,970). DLU read
  the level's motionType and, when there was none, sent 0, a value the
  client's motion-type enum does not have.
- Keyframed (4) for every moving platform (1,059 of 1,059) and for models
  without behaviors; Dynamic (1) for models with behaviors (655 of 666 model
  constructions follow this; DLU only switched it when a behavior was added
  or removed).
- No velocity for fixed objects (all 38,970); keyframed and dynamic objects
  have one. DLU wrote a velocity in every construction.
A level-set motionType still wins.

Test: ReplicaConstructionTest.SimplePhysicsConstructionLikeLive (values of a
captured smashable, LOT 12266); the existing SimplePhysicsTest bytes are
unchanged.

Check in game: moving platforms (AG, GF, NS, NT), spinners and other
simple-physics objects move and collide as before; smashables and static
objects stay put; on a property, placed models with and without behaviors
sit and run as before, including after picking them up and placing again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 05:21:36 -05:00
parent c0dcb66f1c
commit c711eb8045
5 changed files with 84 additions and 6 deletions

View File

@@ -98,6 +98,12 @@ void ModelComponent::LoadBehaviors() {
// add behavior at the back
LoadBehavior(behaviorId.value(), m_Behaviors.size(), false);
}
// Live constructed models with behaviors as dynamic and models without as keyframed (655 of 666 model constructions)
auto* const simplePhysics = m_Parent->GetComponent<SimplePhysicsComponent>();
if (simplePhysics && !m_Parent->HasVar(u"motionType")) {
simplePhysics->SetPhysicsMotionState(m_Behaviors.empty() ? SimplePhysicsComponent::MOTION_TYPE_KEYFRAMED : SimplePhysicsComponent::MOTION_TYPE_DYNAMIC);
}
}
void ModelComponent::LoadBehavior(const LWOOBJID behaviorID, const size_t index, const bool isIndexed) {

View File

@@ -65,6 +65,10 @@ MovingPlatformComponent::MovingPlatformComponent(Entity* parent, const int32_t c
if (m_Path == nullptr) {
LOG("Path not found: %s", pathName.c_str());
}
// Live constructed every moving platform with simple physics as keyframed
auto* const simplePhysics = m_Parent->GetComponent<SimplePhysicsComponent>();
if (simplePhysics && !m_Parent->HasVar(u"motionType")) simplePhysics->SetPhysicsMotionState(SimplePhysicsComponent::MOTION_TYPE_KEYFRAMED);
}
MovingPlatformComponent::~MovingPlatformComponent() {

View File

@@ -31,7 +31,8 @@ SimplePhysicsComponent::SimplePhysicsComponent(Entity* parent, const int32_t com
} else {
SetClimbableType(eClimbableType::CLIMBABLE_TYPE_NOT);
}
m_PhysicsMotionState = m_Parent->GetVarAs<uint32_t>(u"motionType");
// Without a motionType the object is fixed (live: Fixed on 38,970 of 40,786 constructions, never the invalid 0)
if (m_Parent->HasVar(u"motionType")) m_PhysicsMotionState = m_Parent->GetVarAs<uint32_t>(u"motionType");
}
SimplePhysicsComponent::~SimplePhysicsComponent() {
@@ -50,13 +51,16 @@ void SimplePhysicsComponent::Serialize(RakNet::BitStream& outBitStream, bool bIs
outBitStream.Write(m_ClimbableType);
}
outBitStream.Write(m_DirtyVelocity || bIsInitialUpdate);
if (m_DirtyVelocity || bIsInitialUpdate) {
// Live left the velocity out of a fixed object's construction and wrote it for keyframed and dynamic ones.
const bool writeVelocity = bIsInitialUpdate
? m_PhysicsMotionState != MOTION_TYPE_FIXED || m_Velocity != NiPoint3Constant::ZERO || m_AngularVelocity != NiPoint3Constant::ZERO
: m_DirtyVelocity;
outBitStream.Write(writeVelocity);
if (writeVelocity) {
outBitStream.Write(m_Velocity);
outBitStream.Write(m_AngularVelocity);
m_DirtyVelocity = false;
}
if (writeVelocity || bIsInitialUpdate) m_DirtyVelocity = false;
// Physics motion state
outBitStream.Write(m_DirtyPhysicsMotionState || bIsInitialUpdate);

View File

@@ -30,6 +30,11 @@ class SimplePhysicsComponent : public PhysicsComponent {
public:
static constexpr eReplicaComponentType ComponentType = eReplicaComponentType::SIMPLE_PHYSICS;
// Havok motion types the client reads as the physics motion state
static constexpr uint32_t MOTION_TYPE_DYNAMIC = 1;
static constexpr uint32_t MOTION_TYPE_KEYFRAMED = 4;
static constexpr uint32_t MOTION_TYPE_FIXED = 5;
SimplePhysicsComponent(Entity* parent, const int32_t componentID);
~SimplePhysicsComponent() override;
@@ -106,7 +111,7 @@ private:
/**
* The current physics motion state
*/
uint32_t m_PhysicsMotionState = 5;
uint32_t m_PhysicsMotionState = MOTION_TYPE_FIXED;
bool m_DirtyPhysicsMotionState = true;

View File

@@ -159,6 +159,65 @@ TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionWritesCheatsOnlyW
EXPECT_FLOAT_EQ(cheats.second, 1.05f);
}
// Simple physics: live sent motion type Fixed for objects without a motionType (never the invalid 0 DLU sent when the
// level gave none) and no velocity for fixed objects (38,970 Fixed constructions, all without velocity); moving
// platforms were Keyframed with a velocity (1,059 of 1,059).
TEST_F(ReplicaConstructionTest, SimplePhysicsConstructionLikeLive) {
info.lot = 12266;
Entity smashable(288300744895979394, info);
auto* const physics = smashable.AddComponent<SimplePhysicsComponent>(-1);
physics->SetPosition(NiPoint3(-259.89438f, 77.147575f, 485.42264f));
NiQuaternion rotation = QuatUtils::IDENTITY;
rotation.x = 0.51204455f;
rotation.y = 0.0f;
rotation.z = 0.0f;
rotation.w = 0.8589589f;
physics->SetRotation(rotation);
RakNet::BitStream construction;
physics->Serialize(construction, true);
RakNet::BitStream expected;
expected.Write0(); // is_climbable
expected.Write<int32_t>(0); // climbing_property
expected.Write0(); // velocity_info None
expected.Write1(); // motion_type Some
expected.Write<uint32_t>(5); // Fixed
expected.Write1(); // position_rotation_info
expected.Write(-259.89438f);
expected.Write(77.147575f);
expected.Write(485.42264f);
expected.Write(0.51204455f); // x, y, z, w
expected.Write(0.0f);
expected.Write(0.0f);
expected.Write(0.8589589f);
ExpectSameBits(construction, expected);
info.lot = 11950;
Entity platform(288300744895900003, info);
auto* const platformPhysics = platform.AddComponent<SimplePhysicsComponent>(-1);
platform.AddComponent<MovingPlatformComponent>(-1, "");
EXPECT_EQ(platformPhysics->GetPhysicsMotionState(), SimplePhysicsComponent::MOTION_TYPE_KEYFRAMED);
RakNet::BitStream platformConstruction;
platformPhysics->Serialize(platformConstruction, true);
platformConstruction.IgnoreBits(1 + 32);
bool hasVelocity{};
EXPECT_TRUE(platformConstruction.Read(hasVelocity));
EXPECT_TRUE(hasVelocity);
// A property model without behaviors is keyframed
info.lot = 14;
Entity model(288300744895908946, info);
auto* const modelPhysics = model.AddComponent<SimplePhysicsComponent>(-1);
model.AddComponent<ModelComponent>(-1)->LoadBehaviors();
EXPECT_EQ(modelPhysics->GetPhysicsMotionState(), SimplePhysicsComponent::MOTION_TYPE_KEYFRAMED);
// A level-set motion type wins
info.settings.Insert<uint32_t>(u"motionType", 1);
Entity dynamic(288300744895900004, info);
EXPECT_EQ(dynamic.AddComponent<SimplePhysicsComponent>(-1)->GetPhysicsMotionState(), SimplePhysicsComponent::MOTION_TYPE_DYNAMIC);
info.settings.values.clear();
}
class InventoryConstructionTest : public GameDependenciesTest {
protected:
// The items of the live sample below (CDClient 1.10.64 values)