Files
DarkflameServer/docs/SeamlessTransfer.md
Aaron Kimbrell 854d02f509 docs: what live servers sent around world transfers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:30:37 -05:00

230 lines
16 KiB
Markdown

# Moving players between world instances
How the LEGO Universe client (1.10.64, `legouniverse.exe`) switches world servers, what that allows, and how
DarkflameServer uses it to **replace** an instance (live updates) or **merge** two quiet instances of a zone.
Addresses are from `legouniverse.exe` 1.10.64 in Ghidra; the functions below are named and commented there, with
bookmarks in the `SeamlessTransfer` category. Message layouts were checked against the client first, then
lu_packets.
## Short answer
* A normal transfer always shows a **loading screen**. `TRANSFER_TO_WORLD` makes the client connect to another world
server, and the `LOAD_STATIC_ZONE` that server sends tears the scene down and loads the zone again. There is no
"same zone already loaded" shortcut in the client.
* The **Mythran shift** flag is not an animation. It is LU's own maintenance feature: after connecting it shows
"Mythran Dimensional Shift Succeeded! You have been moved to a new dimension to continue playing." The matching
warning ("Mythran Maintenance Alert! The Mythrans have detected some problems in this dimension...") is sent by the
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**.
## What the client does (1.10.64)
### TRANSFER_TO_WORLD (client message 0x0e)
`PacketHandler_MSG_CLIENT_TRANSFER_TO_WORLD` @ `00b32c60`, packet `ClientTransferToWorldPacket`:
`char[33] ip`, `u16 port`, `bool mythranShift` (lu_packets calls the flag `is_maintenance_transfer`).
* `ip[0] == 0` means the transfer failed, and `port` is the reason: 0 "No empty servers available", 1 "Map couldn't
load", 2 "Target specific instance was full", anything else "Non-specific failure". It is only shown in chat.
* Otherwise the client closes its world connection (`LwoNetClient::CloseConnection` @ `00a32080`, with a disconnection
notification), clears `worldNetID` (`LwoNetClient::ResetNetID` @ `00b213c0`) and calls
`LwoNetClient::ConnectToWorldServer(ip, port, requestCharacterList = false)` @ `00b325e0`.
* If the connection attempt starts and `mythranShift` is set: it hides the announcement `UI_INSTANCE_LOCKED_ANNOUNCE_TITLE`
and shows `ToggleAnnounce` with the title `UI_INSTANCE_LOCKED_TRANSFERRED_TITLE` and message
`UI_INSTANCE_LOCKED_TRANSFERRED_BODY`.
Nothing else happens yet. The old scene stays on screen and nothing is deleted. `ConnectToWorldServer` only posts a UI
network state message.
### Connecting to the new world
* `LwoNetClient::HandleServerConnectionType` @ `00b2df70`: once the version handshake with a world (service type 4)
succeeds, it calls `WorldValidation` @ `00b2dc00`.
* `WorldValidation` sends `MSG_WORLD_CLIENT_VALIDATION` (username, session key, cdclient.fdb checksum). It sends
`MSG_WORLD_CLIENT_CHARACTER_LIST_REQUEST` only when `requestCharacterList` (`LwoNetClient+0x79`) is set. The
character select screen sets it; a transfer does not. **After a transfer the client waits for the server to act.**
* `PacketHandler_MSG_CLIENT_LOAD_STATIC_ZONE` @ `00b2f170` resets everything without any checks:
`ResMgr2EnableLoadScreen`, stop game rendering, `DeleteAllGameObjects`, `RenderDumpAll`, audio flush,
`LevelResetEnvironment`, `ResMgr2LoadZone`, and the zone's mixer program. `LoadThread_Run` @ `0105ccf0` loads the zone
from scratch every time (a second request while one is loading gets queued). Afterwards the client sends
`MSG_WORLD_CLIENT_LEVEL_LOAD_COMPLETE`.
* `PacketHandler_MSG_CLIENT_CREATE_CHARACTER` @ `00b2eb40` creates the local player (`LoadObject` with
`isLocalPlayer`) from the LDF (objid, template, position, rotation, xmlData), then sends
`ResMgr2NotifyLevelLoadComplete` and `UIReadyInGameUI`. **If an object with that ID already exists or is waiting to
load, the packet is ignored.**
* `ServerDoneLoadingAllObjects` (in `LWOCharacterComponent::SendMessage` @ `00d34330`) moves the loading screen to its
last phase. The client then sends `PlayerLoaded`, and the server answers with `PlayerReady`.
### Replicas: taking over existing objects
* `LwoClientGhostManager::OnReceiveConstruction` @ `00b31c30`: a construction for an object ID that already exists,
or is waiting to load, does **not** create a second object. A new ghost is registered, and the existing object gets
`UpdateFromGhost` followed by `OnUnserialize(construction)`. This is also how the local player made by
`CREATE_CHARACTER` receives its replica.
* `LWOGhostComponent::OnGhostReceiveDestruction` @ `00c99410`: an object can hold several ghosts. When the last one is
destroyed, other objects are deleted but **the local player is kept** (the code only clears `pGhost`).
* When the world connection closes, nothing is deleted. `ReplicaManager::RemoveParticipant` @ `00a478d0` leaves the
objects alone.
### Connection loss vs. a transfer
`LwoNetClient::OnConnectionDropped_World` @ `00b22fb0` shows `NET_CONNDROP_WORLD_LOST`, `..._TIMEOUT` or
`..._REFUSED`, clears the character ID, and without a new connection shuts the network down and returns to the login
screen. `MSG_SERVER_DISCONNECT_NOTIFY` @ `00b30590` gives each disconnect reason its own message. A transfer avoids all
of this because the client clears `worldNetID` before the old connection closes.
### The warning announcement
`LocalizedAnnouncementServerToSingleClient` (game message 1580). The client reads it in
`GameMessage::LocalizedAnnouncementServerToSingleClient::Deserialize` @ `00f23c50`:
1. body LDF (`u32` length, then UTF-16 with a terminator)
2. `bit` forceOpenChatbox, `bit` showAnnouncementUI, `bit` showTextInChatbox
3. body (`u32` length, then UTF-16)
4. title (`u32` length, then UTF-16)
5. title LDF
`LWOTranslator::LocalizeWithLdf` @ `010e0430` looks the body and title up as locale phrase IDs and falls back to the
text itself. That makes `UI_INSTANCE_LOCKED_ANNOUNCE_TITLE` / `..._BODY` the game's own maintenance alert.
### Other client-side state
The Flash UI keeps its state across a transfer: chat history, the open chat and friends windows, and the minimap.
Friends, the team and guild live on the chat server, which the world forwards to. The client does not reset them
itself; the new world sends them again. Anything tied to objects is rebuilt: the pet, missions shown in the UI,
buffs, and possession.
### What live servers sent (packet captures)
These are live captures of 242 world connections from several players (the lcdr capture archive). They were read
locally, and none of it is in this repository. They agree with the client:
* **`TRANSFER_TO_WORLD`**: 168 captured. Every one is 44 bytes (8-byte header, `char[33]` ip, `u16` port, `u8`
flag). All 168 have the Mythran shift flag at 0, and none were failures (empty ip). Ports were 2001-2009, one world
server each. No maintenance transfer (flag 1) and no `LocalizedAnnouncementServerToSingleClient` (1580) is in the
captures.
* **What followed a transfer on the old connection**: nothing in 89 of them. The rest show replica destructions (`0x25`,
about 2,500) and disconnection notifications (`0x13`). Live took its objects away from the leaving client, but only
after the transfer, when the client had already left. The experimental seamless mode sends them before it.
* **Every new world connection** started the same way: handshake, `MSG_WORLD_CLIENT_VALIDATION`, then at once
`LOAD_STATIC_ZONE`. After that came the client's `LEVEL_LOAD_COMPLETE`, `CREATE_CHARACTER`, `SERVER_STATES` and the
replica constructions. Some clients sent a few game messages in between.
* A character list request appears in only 6 connections, the ones at character select. So after a transfer the
client did not ask for its characters, as `WorldValidation` predicts.
* **Transfers within the same map**: 4 transfers went to another instance of the same map, all property → property
(map 1150, different clones). Live still sent `LOAD_STATIC_ZONE`, a full reload. No live transfer kept the scene.
lu_packets' test packet `src/world/client/tests/TransferToWorld.bin` shows the same shape (ip `"171.20.35.42"` with
the rest of the 33 bytes uninitialised, port 2005, flag 0). lcdr-utils' `found.zip` has only replica constructions.
## What DarkflameServer does
### Normal mode (loading screen)
1. **A GM** (level DEVELOPER) in the instance types `/replaceinstance [warn seconds] [seamless]` or
`/mergeinstance [target instance, 0 = best fit] [warn seconds] [seamless]`. The world sends `INSTANCE_MIGRATE` to
master. Anything else connected to master (a web dashboard later) can send the same message.
2. **Master** (`dMasterServer/MigrationCoordinator`) checks the source. Character selection, private instances,
properties/clones and activity zones (any `Activities.instanceMapID`) are refused, as is an instance already taking
part in a migration.
* *Replace*: starts a new instance of the zone. It runs the world binary on disk now, so an updated server takes
effect.
* *Merge*: uses the chosen target or picks one (`PickMergeTarget`). The target has to fit everyone under its hard
cap; one that stays under the soft cap is preferred, and then the fullest.
The source is marked **draining** (`FindInstance` skips it, so nobody new is sent there). Seats for its players are
**reserved** on the target (`IsFull` counts them). Once the target is ready, master sends `MIGRATE_PLAYERS` to the
source.
3. **Source world** (`dGame/dUtilities/WorldMigration`):
* Sends everyone the Mythran Maintenance Alert, then waits `warnSeconds`.
* Moves up to 10 players a second. Each player's open trade is cancelled (nothing changes hands) and their character
is saved, including position. The zone stays the same and the target scene is cleared, so they land where they
stood.
* The player is then **locked**: packets from them are ignored, and the disconnect does not save them again. Nothing
they do after the save can be lost or duplicated, and a late save cannot overwrite what the target saves.
* The pet that was out is sent on through `MIGRATE_PLAYER_STATE`, and the player gets `TRANSFER_TO_WORLD` with
`mythranShift`.
* Dead players and players in build mode wait up to 15 s.
* A client still connected 20 s after its transfer is disconnected; it is already saved.
* Progress goes to master every second (`MIGRATE_STATUS`). The migration is done once nobody is left and everyone
sent away has disconnected.
* Master passes each status on to every world. The world where the GM who asked is now tells them in chat each time
the state changes.
4. **Target world**: a normal login (validation, `LOAD_STATIC_ZONE`, level load, `CREATE_CHARACTER`, construction). On
`PlayerLoaded` it summons the carried pet.
5. **Master** releases the reservation and shuts the source down, unless `keepSource` was set. If anything fails, the
source stops draining and a fresh target nobody reached is shut down again. If the target stops, the source is told
to stop moving players.
6. **Chat**: the old world reports the disconnect, and the chat server waits 20 s before removing the player (which
would also take them out of their team). The new world's login notice cancels that. Friends and the team carry over
as long as loading takes less than 20 s.
### Experimental seamless mode ("Without loading screen")
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
`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.
### What carries over
| Carried by | State |
| --- | --- |
| Character XML, saved before the transfer | position/rotation (same zone), health/armor/imagination, buffs (not `cancelOnZone` ones), inventory, missions, flags, currency, stats |
| `MIGRATE_PLAYER_STATE` | the active pet (summoned again) |
| Chat server | team, friends, guild (within the 20 s grace) |
| Client UI | chat history, open windows |
| **Lost** | an open trade (cancelled first), build mode in progress, possession/mounts, an activity lobby, dropped loot on the ground, things NPCs or enemies were doing (the target's world simulation is separate) |
### Messages (appended to `MessageType::Master`; nothing renumbered)
| Message | Direction | Payload |
| --- | --- | --- |
| `INSTANCE_MIGRATE` | world (GM command) or a future dashboard → master | `InstanceMigrationRequest` |
| `MIGRATE_PLAYERS` | master → source world | `MigratePlayersOrder` (target port 0 = cancel) |
| `MIGRATE_STATUS` | source world → master → every world | `MigrationStatus` (carries the requester's character ID) |
| `MIGRATE_PLAYER_STATE` | source world → master → target world | `CarriedPlayerState` |
All structs are in `dNet/InstanceMigration.h`, together with the pure planning functions (`CheckSource`,
`CheckMergeTarget`, `PickMergeTarget`, `SuggestMerges`, `DecidePlayer`). They are unit tested in
`tests/dCommonTests/InstanceMigrationTests.cpp`. The values are appended after `NEW_SESSION_ALERT`; a branch that also
appends there has to put one set after the other when merged.
### Controls
* `/replaceinstance [warn seconds, 0-300, default 10] [seamless]`
* `/mergeinstance [target instance, 0 = best fit] [warn seconds] [seamless]`
Both need DEVELOPER, act on the instance the GM is in, and report progress to the GM in chat.
`SuggestMerges` is there for a dashboard to offer merges; nothing uses it yet. A dashboard only has to send
`INSTANCE_MIGRATE` and listen for `MIGRATE_STATUS`.
### Limits
* Normal mode always shows a loading screen, because the client has no way around it once `LOAD_STATIC_ZONE` is sent.
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
the source's.
* The team survives only when loading takes less than the chat server's 20 s grace.
* Players still loading into the source when it starts draining are moved once they have loaded.