From 7f6242545684bc59b7b99bce208bed7fbd6e9f3a Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 13:39:48 -0600 Subject: [PATCH] fix(njhub): a built counterweight goes down by itself, and the scripts hear it arrive Live (the Frakjaw battle captures, LOT 16141): when its quick build completes the simple mover is serialized waiting at waypoint 0 (9) and about 0.1 s later travelling (2); it stops at its end without a serialization. That is the client's LWOPlatform, active once the quick build is complete (SendMessage 0x00bdc930), waiting the shortest wait and setting off (RunPlatform 0x00c86e10). L_CAVE_PRISON_CAGE and L_MONASTERY_BOSS_INSTANCE_SERVER never send it: they ask to be told of its PlatformAtLastWaypoint, and act then. So the simple mover sets off when it is built, the cave prison's button goes down when the counterweight arrives instead of when it is built, and Frakjaw is hit when it arrives (0.1 s and the row's 1 s after the build) instead of on a 0.5 s timer with a counterweight that never moved. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MovingPlatformComponent.cpp | 41 +++++++++-- dGame/dComponents/MovingPlatformComponent.h | 9 +++ .../02_server/Map/njhub/CavePrisonCage.cpp | 17 ++--- .../boss_instance/NjMonastryBossInstance.cpp | 72 ++++++++++--------- .../MovingPlatformComponentTests.cpp | 40 +++++++++++ 5 files changed, 130 insertions(+), 49 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 11c9cb8b7..e1639db37 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -285,7 +285,21 @@ void MovingPlatformComponent::OnQuickBuildInitilized() { void MovingPlatformComponent::OnCompleteQuickBuild() { if (m_MoverSubComponentType == eMoverSubComponentType::simpleMover) { - GetSimpleMoverSubComponent()->mDirtyState = true; + // Live (the NJ counterweights, LOT 16141): the built simple mover is serialized waiting at its waypoint (9) and + // about 0.1 s later travelling (2) to its other end, where it stops without a serialization and + // PlatformAtLastWaypoint tells the scripts. It is the client's LWOPlatform at work: active once its quick build + // is complete (SendMessage 0x00bdc930), it waits the shortest wait (SetWaitingTime 0x00bdc860) and sets off + // (RunPlatform 0x00c86e10). + auto* const simpleMover = GetSimpleMoverSubComponent(); + simpleMover->mDirtyState = true; + const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); + if (!startOnLoad || (simpleMover->mState & ePlatformStateFlag::Travelling) != 0) return; + const auto generation = ++m_MoveGeneration; + m_Parent->AddCallbackTimer(0.1f, [this, generation] { + if (generation != m_MoveGeneration) return; + auto* const simpleMover = GetSimpleMoverSubComponent(); + if (simpleMover) SimpleMoverGotoWaypoint(simpleMover->mCurrentWaypointIndex == 0 ? 1 : 0); + }); return; } @@ -728,13 +742,19 @@ void MovingPlatformComponent::OnSimpleMoverArrived(const int32_t index) { auto* const simpleMover = GetSimpleMoverSubComponent(); if (!simpleMover) return; - // LWOPlatform::ArrivedAtWaypoint: both ends are the path's ends, and a path run once stops there + // LWOPlatform::ArrivedAtWaypoint: both ends are the path's ends, and a path run once stops there (28) and turns + // round. Live did not serialize it (each client's simple mover arrives by itself), and the object is moved ignoring + // the dirty flags (ProcessStateChange 0x00cce380). simpleMover->mCurrentWaypointIndex = index; simpleMover->mState = ePlatformStateFlag::Stopped | ePlatformStateFlag::ReachedDesiredWaypoint | ePlatformStateFlag::ReachedFinalDestination; simpleMover->mInReverse = index == 1; - simpleMover->mDirtyState = true; - m_Parent->SetPosition(simpleMover->GetWaypointPosition(index)); - Game::entityManager->SerializeEntity(m_Parent); + const auto position = simpleMover->GetWaypointPosition(index); + if (auto* const simplePhysics = m_Parent->GetComponent()) { + simplePhysics->SetPositionAndRotationIgnoringDirtyFlags(position, simpleMover->mStartRotation); + } + if (auto* const phantomPhysics = m_Parent->GetComponent()) { + phantomPhysics->SetPositionAndRotationIgnoringDirtyFlags(position, simpleMover->mStartRotation); + } FireArrivalHooks(index, true, index == 1); } @@ -745,7 +765,16 @@ void MovingPlatformComponent::FireArrivalHooks(const uint32_t index, const bool auto* const script = m_Parent->GetScript(); script->OnWaypointReached(m_Parent, index); if (atDesiredWaypoint) script->OnArrivedAtDesiredWaypoint(m_Parent, index); - if (atLastWaypoint) script->OnPlatformAtLastWaypoint(m_Parent); + if (atLastWaypoint) { + script->OnPlatformAtLastWaypoint(m_Parent); + // A callback may add another + const auto callbacks = m_AtLastWaypointCallbacks; + for (const auto& callback : callbacks) callback(); + } +} + +void MovingPlatformComponent::AddPlatformAtLastWaypointCallback(const std::function& callback) { + m_AtLastWaypointCallbacks.push_back(callback); } void MovingPlatformComponent::Allocator(Entity& entity, ObjectLoadState& state) { diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index 25536a2a1..59d5b9b5c 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -6,6 +6,7 @@ #ifndef MOVINGPLATFORMCOMPONENT_H #define MOVINGPLATFORMCOMPONENT_H +#include #include #include @@ -275,6 +276,12 @@ public: eMoverSubComponentType GetMoverSubComponentType() const { return m_MoverSubComponentType; } + /** + * Calls back whenever the platform is at the last waypoint of its path (the PlatformAtLastWaypoint game message, + * which live's scripts ask to be notified of on the NJ counterweights) + */ + void AddPlatformAtLastWaypointCallback(const std::function& callback); + /** * Called when a simple mover gets to the waypoint it was sent to: the client's LWOPlatform::ArrivedAtWaypoint * and HandleWaypointArrived (Arrived, then ArrivedAtDesiredWaypoint and PlatformAtLastWaypoint) @@ -315,6 +322,8 @@ private: */ std::vector> m_SubComponents; + std::vector> m_AtLastWaypointCallbacks; + // The object's quick build, looked up when the platform first runs class QuickBuildComponent* m_QuickBuild = nullptr; bool m_CheckedQuickBuild = false; diff --git a/dScripts/02_server/Map/njhub/CavePrisonCage.cpp b/dScripts/02_server/Map/njhub/CavePrisonCage.cpp index 6ce4e0868..04b4d6690 100644 --- a/dScripts/02_server/Map/njhub/CavePrisonCage.cpp +++ b/dScripts/02_server/Map/njhub/CavePrisonCage.cpp @@ -84,19 +84,16 @@ void CavePrisonCage::SpawnCounterweight(Entity* self, Spawner* spawner) { }); quickBuildComponent->AddQuickBuildCompleteCallback([this, self](Entity* user) { - auto* counterweight = Game::entityManager->GetEntity(self->GetVar(u"Counterweight")); - - if (counterweight == nullptr) { - return; - } - - // The counterweight is a simple mover (MovingPlatforms row 41): send it down to its end - auto* const platform = counterweight->GetComponent(); - if (platform) platform->GotoWaypoint(1); - // Save the userID as Builder self->SetVar(u"Builder", user->GetObjectID()); + }); + } + // L_CAVE_PRISON_CAGE (notifyPlatformAtLastWaypoint): the built counterweight, a simple mover (MovingPlatforms row + // 41) that sets off by itself when its quick build completes, has gone down to the end of its path + auto* const platform = counterweight->GetComponent(); + if (platform) { + platform->AddPlatformAtLastWaypointCallback([this, self]() { // Get the button and make sure it still exists auto* button = Game::entityManager->GetEntity(self->GetVar(u"Button")); diff --git a/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp b/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp index 965662b75..2b2b01253 100644 --- a/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp +++ b/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp @@ -1,5 +1,6 @@ #include "NjMonastryBossInstance.h" #include "QuickBuildComponent.h" +#include "MovingPlatformComponent.h" #include "DestroyableComponent.h" #include "EntityManager.h" #include "dZoneManager.h" @@ -237,44 +238,49 @@ void NjMonastryBossInstance::HandleCounterWeightSpawned(Entity* self, Entity* co case eQuickBuildState::RESETTING: ActivityTimerStart(self, SpawnCounterWeightTimer, 0.0f, 0.0f); return; - case eQuickBuildState::COMPLETED: { - // TODO: Move the platform? - - // The counterweight is actually a moving platform and we should listen to the last waypoint event here - // 0.5f is a rough estimate of that path, though, and results in less needed logic - self->AddCallbackTimer(0.5f, [this, self, counterWeight]() { - if (counterWeight != nullptr) { - counterWeight->Kill(); - } - - auto* frakjaw = Game::entityManager->GetEntity(self->GetVar(LedgeFrakjawVariable)); - if (frakjaw == nullptr) { - GameMessages::NotifyClientObject(self->GetObjectID(), u"LedgeFrakjawDead", 0, 0, LWOOBJID_EMPTY, "").Send(UNASSIGNED_SYSTEM_ADDRESS); - return; - } - - auto* skillComponent = frakjaw->GetComponent(); - if (skillComponent != nullptr) { - skillComponent->CalculateBehavior(1635, 39097, frakjaw->GetObjectID(), true, false); - } - - RenderComponent::PlayAnimation(frakjaw, StunnedAnimation); - GameMessages::PlayNDAudioEmitter(frakjaw->GetObjectID(), CounterSmashAudio).Send(UNASSIGNED_SYSTEM_ADDRESS); - - // Before wave 4 we should lower frakjaw from the ledge - if (self->GetVar(WaveNumberVariable) == 3) { - LowerFrakjaw(self, frakjaw); - return; - } - - ActivityTimerStart(self, SpawnNextWaveTimer, 2.0f, 2.0f); - }); - } default: return; } }); } + + // L_MONASTERY_BOSS_INSTANCE_SERVER (notifyPlatformAtLastWaypoint): the built counterweight has gone down to the end + // of its path. It is a simple mover that sets off by itself when its quick build completes: live sent it + // travelling about 0.1 s after the build, and its trip takes the second of its MovingPlatforms row. + auto* const platform = counterWeight->GetComponent(); + if (platform == nullptr) return; + const auto counterWeightID = counterWeight->GetObjectID(); + platform->AddPlatformAtLastWaypointCallback([this, self, counterWeightID]() { + // Not from inside the counterweight's own arrival + self->AddCallbackTimer(0.0f, [this, self, counterWeightID]() { + auto* const counterWeight = Game::entityManager->GetEntity(counterWeightID); + if (counterWeight != nullptr) { + counterWeight->Kill(); + } + + auto* frakjaw = Game::entityManager->GetEntity(self->GetVar(LedgeFrakjawVariable)); + if (frakjaw == nullptr) { + GameMessages::NotifyClientObject(self->GetObjectID(), u"LedgeFrakjawDead", 0, 0, LWOOBJID_EMPTY, "").Send(UNASSIGNED_SYSTEM_ADDRESS); + return; + } + + auto* skillComponent = frakjaw->GetComponent(); + if (skillComponent != nullptr) { + skillComponent->CalculateBehavior(1635, 39097, frakjaw->GetObjectID(), true, false); + } + + RenderComponent::PlayAnimation(frakjaw, StunnedAnimation); + GameMessages::PlayNDAudioEmitter(frakjaw->GetObjectID(), CounterSmashAudio).Send(UNASSIGNED_SYSTEM_ADDRESS); + + // Before wave 4 we should lower frakjaw from the ledge + if (self->GetVar(WaveNumberVariable) == 3) { + LowerFrakjaw(self, frakjaw); + return; + } + + ActivityTimerStart(self, SpawnNextWaveTimer, 2.0f, 2.0f); + }); + }); } void NjMonastryBossInstance::HandleLowerFrakjawSpawned(Entity* self, Entity* lowerFrakjaw) { diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index d231f02b6..dbfc654fb 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -390,3 +390,43 @@ TEST_F(MovingPlatformComponentTest, SetCurrentPathPutsTheMoverOnThePath) { EXPECT_EQ(mover->mState, 28u); EXPECT_EQ(mover->mCurrentWaypointIndex, 2u); } + +// The NJ counterweights in the live captures (LOT 16141, the Frakjaw battle): when the quick build completes the simple +// mover is serialized waiting at waypoint 0 (9), then travelling from it (2); it stops at its end without a +// serialization, where PlatformAtLastWaypoint tells the scripts +TEST_F(MovingPlatformComponentTest, BuiltSimpleMoverSetsOffLikeLive) { + info.lot = 16141; + info.pos = NiPoint3(10.0f, 20.0f, 30.0f); + info.rot = QuatUtils::IDENTITY; + Entity counterweight(288300744895900112, info); + counterweight.AddComponent(-1); + auto* const platform = counterweight.AddComponent(41, ""); + auto* const mover = platform->GetSimpleMoverSubComponent(); + uint32_t atLastWaypoint = 0; + platform->AddPlatformAtLastWaypointCallback([&atLastWaypoint] { atLastWaypoint++; }); + + platform->OnCompleteQuickBuild(); + RakNet::BitStream built; + platform->Serialize(built, false); + EXPECT_EQ(Bits(built), "101" "00000101000000000000000000000000" "0" "1" "00001001000000000000000000000000" "00000000000000000000000000000000" "0" "0"); + + counterweight.Update(0.0f); // timers added now start on the next update + counterweight.Update(0.11f); + EXPECT_EQ(mover->mState, 2u); + RakNet::BitStream travelling; + platform->Serialize(travelling, false); + EXPECT_EQ(Bits(travelling), "101" "00000101000000000000000000000000" "0" "1" "00000010000000000000000000000000" "00000000000000000000000000000000" "0" "0"); + + counterweight.Update(0.0f); + counterweight.Update(0.5f); + EXPECT_EQ(mover->mState, 2u); + EXPECT_EQ(atLastWaypoint, 0u); + counterweight.Update(0.6f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 1u); + EXPECT_EQ(atLastWaypoint, 1u); + EXPECT_EQ(counterweight.GetPosition(), NiPoint3(10.0f, 17.5f, 30.0f)); + RakNet::BitStream arrived; + platform->Serialize(arrived, false); + EXPECT_EQ(Bits(arrived), "00"); +}