diff --git a/dGame/EntityManager.cpp b/dGame/EntityManager.cpp index 6759b0efb..2d3b703b8 100644 --- a/dGame/EntityManager.cpp +++ b/dGame/EntityManager.cpp @@ -499,8 +499,9 @@ void EntityManager::SerializeEntity(const Entity& entity) { } } -void EntityManager::DestructAllEntities(const SystemAddress& sysAddr) { +void EntityManager::DestructAllEntities(const SystemAddress& sysAddr, const LWOOBJID except) { for (auto* entity : m_Entities | std::views::values) { + if (entity && except != LWOOBJID_EMPTY && entity->GetObjectID() == except) continue; DestructEntity(entity, sysAddr); } } diff --git a/dGame/EntityManager.h b/dGame/EntityManager.h index 6e597af1b..1809cea48 100644 --- a/dGame/EntityManager.h +++ b/dGame/EntityManager.h @@ -56,7 +56,8 @@ public: void SerializeEntity(const Entity& entity); void ConstructAllEntities(const SystemAddress& sysAddr); - void DestructAllEntities(const SystemAddress& sysAddr); + // Takes every object away from one client, except `except` when given + void DestructAllEntities(const SystemAddress& sysAddr, LWOOBJID except = LWOOBJID_EMPTY); void SetGhostDistanceMax(float value); void SetGhostDistanceMin(float value); diff --git a/dGame/dComponents/Character/CharacterComponent.cpp b/dGame/dComponents/Character/CharacterComponent.cpp index 9249d74a5..e826ab6bd 100644 --- a/dGame/dComponents/Character/CharacterComponent.cpp +++ b/dGame/dComponents/Character/CharacterComponent.cpp @@ -227,12 +227,20 @@ void CharacterComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInit outBitStream.Write(m_RacesFinished); outBitStream.Write(m_FirstPlaceRaceFinishes); - outBitStream.Write0(); - outBitStream.Write(m_IsLanding); - if (m_IsLanding) { - outBitStream.Write(m_LastRocketConfig.size()); - for (uint16_t character : m_LastRocketConfig) { - outBitStream.Write(character); + // The world transition state, two bits: 0 none, 1 arriving by rocket, 2 leaving. The client's + // LWOCharacterComponent::PostCharacterLoaded (0x00c71660) fades the screen in from white and pushes its + // gameplay state again when it reads 0. A client that kept its scene gets 3, which nothing acts on. + if (m_KeepClientScene) { + outBitStream.Write1(); + outBitStream.Write1(); + } else { + outBitStream.Write0(); + outBitStream.Write(m_IsLanding); + if (m_IsLanding) { + outBitStream.Write(m_LastRocketConfig.size()); + for (uint16_t character : m_LastRocketConfig) { + outBitStream.Write(character); + } } } } diff --git a/dGame/dComponents/Character/CharacterComponent.h b/dGame/dComponents/Character/CharacterComponent.h index a1770744e..70ceb1b07 100644 --- a/dGame/dComponents/Character/CharacterComponent.h +++ b/dGame/dComponents/Character/CharacterComponent.h @@ -71,6 +71,13 @@ public: void SetLastCustomBuild(const std::u16string& tokenizedLOTList) { m_LastRocketConfig = tokenizedLOTList; } bool GetIsLanding() const { return m_IsLanding; } + /** + * Constructions written while this is set tell the client there is no world transition to show (state 3 instead + * of 0 or the rocket landing). For the one construction a client gets of its own player after a seamless instance + * move (docs/SeamlessTransfer.md). + */ + void SetKeepClientScene(const bool value) { m_KeepClientScene = value; } + /** * Find a player's rocket * @param player the entity that triggered the event @@ -435,6 +442,11 @@ private: */ bool m_IsLanding; + /** + * Whether constructions say there is no world transition to show (SetKeepClientScene) + */ + bool m_KeepClientScene = false; + /** * The configuration of the last used rocket, essentially a string of LOTs separated by commas */ diff --git a/dGame/dComponents/Physics/ControllablePhysicsComponent.cpp b/dGame/dComponents/Physics/ControllablePhysicsComponent.cpp index a04ffaa99..d4378853d 100644 --- a/dGame/dComponents/Physics/ControllablePhysicsComponent.cpp +++ b/dGame/dComponents/Physics/ControllablePhysicsComponent.cpp @@ -143,8 +143,11 @@ void ControllablePhysicsComponent::Serialize(RakNet::BitStream& outBitStream, bo if (!bIsInitialUpdate) m_DirtyBubble = false; } - outBitStream.Write(m_DirtyPosition || bIsInitialUpdate); - if (m_DirtyPosition || bIsInitialUpdate) { + // A client that kept its player across a seamless instance move knows where it stands better than the save does: + // its construction leaves the frame out, and LWOBasePhysComponent::Deserialize (0x00bf5a10) then sets nothing + const bool writePosition = bIsInitialUpdate ? !m_KeepClientPosition : m_DirtyPosition; + outBitStream.Write(writePosition); + if (writePosition) { outBitStream.Write(m_Position.x); outBitStream.Write(m_Position.y); outBitStream.Write(m_Position.z); diff --git a/dGame/dComponents/Physics/ControllablePhysicsComponent.h b/dGame/dComponents/Physics/ControllablePhysicsComponent.h index 27765cdc5..ea2fdfce3 100644 --- a/dGame/dComponents/Physics/ControllablePhysicsComponent.h +++ b/dGame/dComponents/Physics/ControllablePhysicsComponent.h @@ -216,6 +216,13 @@ public: */ bool GetIsTeleporting() const { return m_IsTeleporting; } + /** + * Constructions written while this is set carry no position, rotation or velocity. For the one construction a + * client gets of its own player after a seamless instance move (docs/SeamlessTransfer.md): the client applies a + * construction's frame to its player at once, which would pull it back to where it was saved. + */ + void SetKeepClientPosition(const bool value) { m_KeepClientPosition = value; } + /** * Returns the Physics entity for the component * @return Physics entity for the component @@ -400,6 +407,11 @@ private: */ void UpdateSpeedBoost(); + /** + * Whether constructions leave the position out (SetKeepClientPosition) + */ + bool m_KeepClientPosition = false; + /** * The list of speed boosts for this entity */ diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index 362519f71..310e727aa 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -70,7 +70,11 @@ namespace GameMessages { return; } const auto* const zoneControlEntity = Game::entityManager->GetZoneControlEntity(); - SendPlayerReady(entity->GetObjectID(), zoneControlEntity ? zoneControlEntity->GetObjectID() : LWOOBJID_EMPTY, sysAddr); + // A client that kept its player across a seamless instance move is ready already; PlayerReady would only put + // its camera's pitch back to the default (LWOCharacterComponent::iSendMessage, 0x00d34330) + if (!WorldMigration::ArrivesSeamlessly(entity->GetObjectID())) { + SendPlayerReady(entity->GetObjectID(), zoneControlEntity ? zoneControlEntity->GetObjectID() : LWOOBJID_EMPTY, sysAddr); + } entity->SetPlayerReadyForUpdates(); // Venture vision from equipped items was sent before the client's UI existed on a fresh login diff --git a/dGame/dUtilities/WorldMigration.cpp b/dGame/dUtilities/WorldMigration.cpp index fb855c0b5..e7d971917 100644 --- a/dGame/dUtilities/WorldMigration.cpp +++ b/dGame/dUtilities/WorldMigration.cpp @@ -40,6 +40,10 @@ namespace { // Give up on a migration after this long moving; whoever is left stays (or is saved when the world shuts down) constexpr float MAX_MOVE_SECONDS = 150.0f; constexpr float STATUS_INTERVAL = 1.0f; + // How long after it was sent a seamless arrival is expected (the reconnect takes a second or two) + constexpr float SEAMLESS_ARRIVAL_SECONDS = 30.0f; + // A client that arrived without reloading and hasn't sent PlayerLoaded after this gets it run for them + constexpr float SEAMLESS_LOADED_FALLBACK_SECONDS = 5.0f; // Carried state for a player who never arrives constexpr float CARRIED_EXPIRY_SECONDS = 300.0f; @@ -185,10 +189,13 @@ namespace { MasterPackets::SendToMaster(carried); } - // Seamless: take every object we sent away first. The client deletes them but keeps its own player object when - // that ghost goes (LWOGhostComponent::OnGhostReceiveDestruction), and the target's construction of the - // player is adopted by it. Same ordered channel as the transfer, so these arrive before it. - if (order.seamless) Game::entityManager->DestructAllEntities(sysAddr); + // Seamless: take every object we sent away first, except their own player. The client keeps its player object + // either way (LWOGhostComponent::OnGhostReceiveDestruction, 0x00c99410), but losing its last ghost puts it out + // of scope: it fades out, and fades back in with the login effect when the target constructs it + // (LWOBaseRenderComponent::OnEnterScope, 0x00be2150). With its old ghost left alone, the target's + // construction just takes over (LwoClientGhostManager::OnReceiveConstruction, 0x00b31c30). Same ordered + // channel as the transfer, so these arrive before it. + if (order.seamless) Game::entityManager->DestructAllEntities(sysAddr, playerId); ClientPackets::TransferToWorld transfer; transfer.serverIP = LUString(order.targetIp); @@ -391,7 +398,14 @@ void WorldMigration::OnDisconnected(const SystemAddress& sysAddr) { bool WorldMigration::ArrivesSeamlessly(LWOOBJID characterId) { const auto it = g_Carried.find(characterId); - return it != g_Carried.end() && it->second.first.seamless; + // Only for a while: a client that logs in again later has no scene and needs LOAD_STATIC_ZONE + return it != g_Carried.end() && it->second.first.seamless && it->second.second <= SEAMLESS_ARRIVAL_SECONDS; +} + +void WorldMigration::KeepClientPlayer(Entity* player, const bool keep) { + if (!player || (keep && !ArrivesSeamlessly(player->GetObjectID()))) return; + if (auto* physics = player->GetComponent()) physics->SetKeepClientPosition(keep); + if (auto* characterComponent = player->GetComponent()) characterComponent->SetKeepClientScene(keep); } void WorldMigration::OnSeamlessArrival(Entity* player) { @@ -399,10 +413,11 @@ void WorldMigration::OnSeamlessArrival(Entity* player) { const auto playerId = player->GetObjectID(); const auto sysAddr = player->GetSystemAddress(); LOG("Player %llu arrived without reloading the zone", playerId); - // The client already finished loading on the old instance and won't send PlayerLoaded again: do what it would - // trigger, once the constructions have had a moment to arrive - player->AddCallbackTimer(1.0f, [playerId, sysAddr]() { - if (!Game::entityManager->GetEntity(playerId)) return; + // The client sends PlayerLoaded itself once its character got the construction + // (LWOCharacterComponent::PostCharacterLoaded, 0x00c71660). Should that not come, do what it would trigger. + player->AddCallbackTimer(SEAMLESS_LOADED_FALLBACK_SECONDS, [playerId, sysAddr]() { + if (!g_Carried.contains(playerId) || !Game::entityManager->GetEntity(playerId)) return; + LOG("Player %llu sent no PlayerLoaded after arriving without a reload, running it for them", playerId); RakNet::BitStream empty; GameMessageHandler::HandleMessage(empty, sysAddr, playerId, MessageType::Game::PLAYER_LOADED); }); diff --git a/dGame/dUtilities/WorldMigration.h b/dGame/dUtilities/WorldMigration.h index d5d7e85f2..d6ac1b56e 100644 --- a/dGame/dUtilities/WorldMigration.h +++ b/dGame/dUtilities/WorldMigration.h @@ -51,7 +51,12 @@ namespace WorldMigration { // not be sent LOAD_STATIC_ZONE (that tears the scene down); load it at once instead bool ArrivesSeamlessly(LWOOBJID characterId); - // Its player entity was created and constructed: run what the client's PlayerLoaded would have + // While `keep` is set, constructions of a seamlessly arriving player leave out what would reset its client's own + // player: the position (the client stands where it is) and the world transition (no fade from white). Set only + // around the construction that goes to the player's own client; does nothing for other players. + void KeepClientPlayer(Entity* player, bool keep); + + // Its player entity was created and constructed: the client sends PlayerLoaded itself; a fallback runs it if not void OnSeamlessArrival(Entity* player); // MIGRATE_STATUS from master: tell the GM who asked, if they are here diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index a9d0ca019..89dd0c32e 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -1227,7 +1227,11 @@ void LoadPlayer(const SystemAddress& sysAddr) { } } + // Their own client's construction of the player: one that kept its player (seamless instance move) is not + // to be put back where it was saved or shown a world transition + WorldMigration::KeepClientPlayer(player, true); Game::entityManager->ConstructAllEntities(sysAddr); + WorldMigration::KeepClientPlayer(player, false); characterComponent->RocketUnEquip(player); diff --git a/docs/SeamlessTransfer.md b/docs/SeamlessTransfer.md index fdddfa047..f5f2edbf6 100644 --- a/docs/SeamlessTransfer.md +++ b/docs/SeamlessTransfer.md @@ -18,10 +18,13 @@ lu_packets. server as a localized announcement. Live used this to evacuate instances. * Moving within the same zone keeps the player where they stood. Merging instances therefore works, but the player sees the warning, a short loading screen, and the "shift succeeded" notice. -* **A transfer without a loading screen looks possible.** The client's replica code can **adopt** an object it already - has for a new server's construction, and it keeps its own player object when the old server takes its ghost away. So - a new server can take over if it skips `LOAD_STATIC_ZONE`. This is built as an opt-in experiment (see below) and has - **not been tried with the real client yet**. +* **A transfer within the same zone works without a loading screen.** The client's replica code **adopts** an object + it already has for a new server's construction, and it never deletes its own player. So a new server can take over + if it skips `LOAD_STATIC_ZONE`. This is the opt-in seamless mode (see below). The player keeps its object, position + and camera; the other objects still fade out and come back. +* **A transfer to another zone cannot skip the loading screen.** The client loads a zone only on `LOAD_STATIC_ZONE` + (or its own script command), and that handler always shows the loading screen and deletes every object first. No + server message loads or unloads single scenes. ## What the client does (1.10.64) @@ -72,6 +75,28 @@ network state message. * When the world connection closes, nothing is deleted. `ReplicaManager::RemoveParticipant` @ `00a478d0` leaves the objects alone. +### A second construction of the local player + +What the client does when a construction arrives for the player object it already has (the seamless mode's case): + +* `LWOGameObject::Unserialize` @ `00f99690` runs every component's construction read on the live object. +* `LWOBasePhysComponent::Deserialize` @ `00bf5a10`: when the construction has a frame, it is applied at once + (position, rotation, both velocities), local player or not. Without the frame bit nothing is set. +* `LWOInventoryComponent_Client::Deserialize` @ `00d52860` compares the equipped items with the ones it has and only + equips or unequips the difference. +* `LWOCharacterComponent::Unserialize` @ `00ca3a40` reads the 2-bit world transition state (0 none, 1 arriving by + rocket, 2 leaving) and ends in `LWOCharacterComponent::PostCharacterLoaded` @ `00c71660`. With state 0 that pushes + the gameplay state again and posts a **full screen fade from white**. In every case it sends `PlayerLoaded` to the + server. State 3 is read but nothing acts on it. +* `LWOGhostComponent::OnGhostReceiveDestruction` @ `00c99410`: when the local player's last ghost is destroyed the + object stays, but it leaves scope (`LWOBaseRenderComponent::OnLeaveScope` @ `00c49140` hides it). The next + construction brings it back through `LWOBaseRenderComponent::OnEnterScope` @ `00be2150`, which fades it in with the + `login` effect. A destruction for a ghost that a newer construction already replaced does nothing. +* `PlayerReady` (`LWOCharacterComponent::iSendMessage` @ `00d34330`) puts the gameplay camera's pitch back to the + default; the rest of it refreshes UI. +* `ConnectToWorldServer` posts `UILoginFeedback` state 4, which only calls `_root.setLoginFeedback` on the current + Flash scene (`NDUIScaleformScene::SendMessage` @ `00bc2d10`). Nothing shows in game. + ### Connection loss vs. a transfer `LwoNetClient::OnConnectionDropped_World` @ `00b22fb0` shows `NET_CONNDROP_WORLD_LOST`, `..._TIMEOUT` or @@ -169,21 +194,29 @@ the rest of the 33 bytes uninitialised, port 2005, flag 0). lcdr-utils' `found.z Built from the replica behaviour above: -* **Source**: after saving and locking the player, it sends a destruction for every object it sent them - (`DestructAllEntities`) and then `TRANSFER_TO_WORLD` without `mythranShift`. Both go out on the same ordered channel, - so the destructions arrive first. The client deletes those objects but keeps its player, terrain and UI. -* **Target**: the carried state is marked seamless. When that character's session is validated, the target skips - `LOAD_STATIC_ZONE` and runs the level-load step at once (`LoadPlayer`): it creates the player entity, sends +* **Source**: after saving and locking the player, it sends a destruction for every object it sent them **except their + own player**, and then `TRANSFER_TO_WORLD` without `mythranShift`. Both go out on the same ordered channel, so the + destructions arrive first. The client deletes those objects and keeps its player (in scope, with its old ghost), + terrain and UI. +* **Target**: the carried state is marked seamless. When that character's session is validated within 30 s, the target + skips `LOAD_STATIC_ZONE` and runs the level-load step at once (`LoadPlayer`): it creates the player entity, sends `CREATE_CHARACTER` (which the client ignores because the object exists), and constructs the player and everything - else. The client adopts its existing player object for the new ghost. One second later the server does what the - client's `PlayerLoaded` would have triggered, because the client never sends it again. -* **Untested**: - * how the client reacts to `ServerDoneLoadingAllObjects` / `PlayerReady` without a load in progress - * the UI state `ConnectToWorldServer` posts - * the camera reset in `PlayerReady` - * whether objects visibly pop out and back in - - If it goes wrong the worst case should be a stuck client that has to log in again. The character is already saved. + else. The client adopts its existing player object for the new ghost. +* The construction of the player that goes to **its own client** differs from the one everyone else gets + (`WorldMigration::KeepClientPlayer`): + * no position frame, so the client stays where it stands instead of snapping back to where it was saved; + * world transition state 3, so there is no fade from white. +* The client sends `PlayerLoaded` by itself after that construction. If it has not after 5 s the server runs it. + `PlayerReady` is not sent (the client is ready, and it would reset the camera pitch). +* **First test (2026-09-30), before these changes**: no loading screen, but a white flash and the character came back + with the login effect. Cause: the source destroyed the player's own ghost too, and the construction said transition + state 0. +* **Still visible**: the other objects fade out when the source takes them away and come back when the target + constructs them. Objects placed in the level have the same object ID on both instances and could be handed over + without that (construct on the target first, destroy the source's ghosts after); that needs network ID ranges that + don't overlap between instances and is not built. +* **Untested with these changes**: everything. If it goes wrong the worst case should be a stuck client that has to + log in again. The character is already saved. ### What carries over @@ -231,6 +264,8 @@ Both need DEVELOPER, act on the instance the GM is in, and report progress to th ### Limits * Normal mode always shows a loading screen, because the client has no way around it once `LOAD_STATIC_ZONE` is sent. +* The seamless mode only works within one zone: another zone's terrain and scenes only load through + `LOAD_STATIC_ZONE`. Staying in the same zone keeps it short: the zone files are warm in the OS cache and the target is already running. * Only public, non-clone, non-activity instances can be moved. * The target world starts its own simulation. Enemies, smashables, quickbuilds and dropped loot are the target's, not diff --git a/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp b/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp index 8d082274f..9f1cf6328 100644 --- a/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp +++ b/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp @@ -544,6 +544,87 @@ TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionWritesCheatsOnlyW EXPECT_FLOAT_EQ(cheats.second, 1.05f); } +// A client that kept its player across a seamless instance move (docs/SeamlessTransfer.md): its own player's +// construction carries no frame, because LWOBasePhysComponent::Deserialize (0x00bf5a10) applies a construction's +// position, rotation and velocities to the object at once. Everyone else's construction still has it. +TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionCanLeaveThePositionOut) { + Entity entity(15, info); + auto* const physics = entity.AddComponent(-1); + physics->SetPosition(NiPoint3(1.0f, 2.0f, 3.0f)); + + // no jetpack, stun immunities, no cheats, equipped item info (flag, pickup radius, in jetpack), bubble (flag, off) + constexpr uint32_t beforePosition = 1 + 1 + 7 * 32 + 1 + (1 + 32 + 1) + (1 + 1); + const auto positionBit = [physics, beforePosition](uint32_t& bitsAfter) { + RakNet::BitStream stream; + physics->Serialize(stream, true); + stream.IgnoreBits(beforePosition); + bool hasPosition{}; + EXPECT_TRUE(stream.Read(hasPosition)); + bitsAfter = stream.GetNumberOfUnreadBits(); + return hasPosition; + }; + + uint32_t bitsAfter{}; + EXPECT_TRUE(positionBit(bitsAfter)); + EXPECT_GT(bitsAfter, 7u * 32u); + + physics->SetKeepClientPosition(true); + EXPECT_FALSE(positionBit(bitsAfter)); + EXPECT_EQ(bitsAfter, 0u); + + // Serializations are untouched: a moved player is still sent + RakNet::BitStream flushed; + physics->Serialize(flushed, false); + RakNet::BitStream serialization; + physics->SetPosition(NiPoint3(4.0f, 5.0f, 6.0f)); + physics->Serialize(serialization, false); + serialization.IgnoreBits(3); // no cheats, equipped item info or bubble change + bool hasPosition{}; + EXPECT_TRUE(serialization.Read(hasPosition)); + EXPECT_TRUE(hasPosition); + + physics->SetKeepClientPosition(false); + EXPECT_TRUE(positionBit(bitsAfter)); +} + +// The same client's character construction: world transition state 3. With 0 the client's +// LWOCharacterComponent::PostCharacterLoaded (0x00c71660) fades the whole screen in from white and pushes its gameplay +// state a second time; 1 is the rocket landing and 2 hides the character. The blocks after it stay where they were. +TEST_F(ReplicaConstructionTest, CharacterConstructionCanSkipTheWorldTransition) { + User user(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key"); + Character character(1, &user); + info.lot = 1; + Entity player(1152921506064087003, info); + player.SetCharacter(&character); + character.SetEntity(&player); + auto* const component = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + + // 4 absent claim codes, 10 u32 appearance fields, 4 u64s, the free-trial bit and 27 u64 statistics + constexpr uint32_t beforeTransition = 4 + 10 * 32 + 4 * 64 + 1 + 27 * 64; + const auto transition = [component, beforeTransition](uint32_t& bitsAfter) { + RakNet::BitStream construction; + component->Serialize(construction, true); + construction.IgnoreBits(beforeTransition); + uint8_t state{}; + EXPECT_TRUE(construction.ReadBits(&state, 2)); + bitsAfter = construction.GetNumberOfUnreadBits(); + return state; + }; + + uint32_t normalBits{}; + EXPECT_EQ(transition(normalBits), 0); + + component->SetKeepClientScene(true); + uint32_t keptBits{}; + EXPECT_EQ(transition(keptBits), 3); + EXPECT_EQ(keptBits, normalBits); + + component->SetKeepClientScene(false); + EXPECT_EQ(transition(keptBits), 0); + + player.SetCharacter(nullptr); +} + // 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).