diff --git a/docs/SeamlessTransfer.md b/docs/SeamlessTransfer.md new file mode 100644 index 000000000..0e098ced2 --- /dev/null +++ b/docs/SeamlessTransfer.md @@ -0,0 +1,215 @@ +# 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. + +### Packet captures + +* `found.zip` in lcdr-utils holds 9,730 files, all `[24]` (replica constructions). It contains no world-connect or + transfer packets. The full live capture folder those were extracted from was not accessible to this investigation. +* lu_packets' test packet `src/world/client/tests/TransferToWorld.bin` is a live transfer: ip `"171.20.35.42"` (the rest + of the 33 bytes is uninitialised stack), port 2005, flag 0. Its `LoadStaticZone` sample (map 1450, clone 376426) shows + that live sent `LOAD_STATIC_ZONE` after transfers. lu_packets' notes also describe the "no `LoadStaticZone`" takeover + architecture as theoretical. + +## 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.