Merge exp/seamless-transfer-2: seamless instance moves keep the player in place

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 16:49:22 -06:00
12 changed files with 220 additions and 39 deletions

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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<uint16_t>(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<uint16_t>(m_LastRocketConfig.size());
for (uint16_t character : m_LastRocketConfig) {
outBitStream.Write(character);
}
}
}
}

View File

@@ -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
*/

View File

@@ -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);

View File

@@ -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
*/

View File

@@ -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

View File

@@ -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<ControllablePhysicsComponent>()) physics->SetKeepClientPosition(keep);
if (auto* characterComponent = player->GetComponent<CharacterComponent>()) 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);
});

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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<ControllablePhysicsComponent>(-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<CharacterComponent>(-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).