feat: live updates move every server onto a new build without a restart

With new binaries in place, master moves everything onto them while the
server keeps running (the dashboard's Live update, /liveupdate or SIGUSR2 to
master):

- database migrations of the new build first; a failure stops there
- UGC finishes the jobs it is running (queued rows stay pending), auth
  restarts, chat hands its teams to master for the next chat server; master
  starts the new processes and retries ones that don't come back
- once the new chat server is up (CHAT_SERVER_READY) every world connects at
  once and sends its players again (LoginSessionNotify resync, no login logged)
- every world instance is replaced with an instance migration: public worlds
  and private ones (same password) at once, properties after the old instance
  saved and froze the property (MIGRATE_PREPARE: no building, claiming or
  saving there any more), activity zones and character select once their
  players left or after a wait; empty instances just stop, zones in
  prestart_worlds get a new one first
- the dashboard restarts last and picks the status up again

Players land where they stood (position carried in CarriedPlayerState, also on
properties and Moon Base). Draining instances get no new players
(InstanceMigration::AcceptsNewPlayers) and show as "Moving players" in the
world list. The order lives in LiveUpdateMachine.h without master state and is
unit tested; master's glue is LiveUpdateCoordinator. Master itself is not
replaced. Message IDs are appended only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 15:01:05 -05:00
parent bfc45dc662
commit 109a936798
47 changed files with 3213 additions and 59 deletions

View File

@@ -52,6 +52,8 @@ namespace ChatPackets {
WriteZone(bitStream, zoneID);
bitStream.Write(muteExpire);
bitStream.Write(gmLevel);
// Only on a resync, so every other one is byte for byte what it always was
if (resync) bitStream.Write<uint8_t>(1);
}
bool LoginSessionNotify::Deserialize(RakNet::BitStream& bitStream) {
@@ -60,6 +62,8 @@ namespace ChatPackets {
VALIDATE_READ(ReadZone(bitStream, zoneID));
VALIDATE_READ(bitStream.Read(muteExpire));
VALIDATE_READ(bitStream.Read(gmLevel));
uint8_t resyncValue{};
resync = bitStream.GetNumberOfUnreadBits() >= 8 && bitStream.Read(resyncValue) && resyncValue != 0;
return true;
}

View File

@@ -44,6 +44,9 @@ namespace ChatPackets {
LWOZONEID zoneID{};
int64_t muteExpire{}; // time_t
eGameMasterLevel gmLevel{};
// Sent again to a new chat server after a live update restarted chat (CHAT_SERVER_READY): the player didn't
// log in or change zones. Written last, only when set, and read only when there.
bool resync{};
LoginSessionNotify() : LUBitStream(ServiceType::CHAT, MessageType::Chat::LOGIN_SESSION_NOTIFY) {}
void Serialize(RakNet::BitStream& bitStream) const override;

View File

@@ -269,7 +269,7 @@ namespace MasterPackets {
for (auto& instance : instances) {
uint8_t state{};
VALIDATE_READ(bitStream.Read(state));
if (state > static_cast<uint8_t>(eState::STOPPING)) return false;
if (state > static_cast<uint8_t>(eState::DRAINING)) return false;
instance.state = static_cast<eState>(state);
}
return true;

View File

@@ -13,6 +13,7 @@
#include "master/DashboardMessages.h"
#include "master/DataChanged.h"
#include "master/InstanceMigration.h"
#include "master/LiveUpdate.h"
#include "master/MessageCapture.h"
#include "master/PlayerAction.h"
#include "master/UgcModelsMade.h"
@@ -24,7 +25,7 @@ class dServer;
*
* The dashboard's messages and instance migration are MASTER packets too; they live in per-topic headers under
* dNet/master/ (PlayerAction.h, DataChanged.h, DashboardMessages.h, MessageCapture.h, InstanceMigration.h,
* UgcModelsMade.h), which
* LiveUpdate.h, UgcModelsMade.h), which
* this header includes, so including MasterPackets.h gives every MASTER struct.
*/
namespace MasterPackets {
@@ -251,8 +252,9 @@ namespace MasterPackets {
// More than this many worlds is a broken packet
static constexpr uint32_t MAX_INSTANCES = 10000;
// Where a world is: connected, launched but not connected yet, or shutting down
enum class eState : uint8_t { READY = 0, STARTING = 1, STOPPING = 2 };
// Where a world is: connected, launched but not connected yet, shutting down, or connected but having its
// players moved to a new instance (a live update or instance migration; nobody new is sent there)
enum class eState : uint8_t { READY = 0, STARTING = 1, STOPPING = 2, DRAINING = 3 };
struct Instance {
LWOMAPID mapID{};

View File

@@ -55,6 +55,10 @@ namespace InstanceMigration {
MOVING,
DONE,
FAILED,
// Live updates of property (clone) instances: the source freezes the property and saves it before the new
// instance loads it (MIGRATE_PREPARE)
PREPARING,
PREPARED,
};
inline const char* KindName(eKind kind) { return kind == eKind::MERGE ? "merge" : "replace"; }
@@ -66,6 +70,8 @@ namespace InstanceMigration {
case eState::MOVING: return "moving";
case eState::DONE: return "done";
case eState::FAILED: return "failed";
case eState::PREPARING: return "preparing";
case eState::PREPARED: return "prepared";
}
return "unknown";
}
@@ -137,6 +143,29 @@ namespace InstanceMigration {
return eRefusal::NONE;
}
/**
* A live update replaces every instance, so it moves what CheckSource refuses too: character selection, private
* instances, properties (after MIGRATE_PREPARE) and activity zones (once their players had time to finish). Only
* an instance that isn't running properly is left alone. A draining one is fine: the live update drains activity
* zones and character selection itself before moving whoever is left.
*/
inline eRefusal CheckLiveUpdateSource(const InstanceView& source) {
if (source.shuttingDown) return eRefusal::SHUTTING_DOWN;
if (!source.ready) return eRefusal::NOT_READY;
return eRefusal::NONE;
}
/**
* Whether master may send a new player to this instance of zone/clone (InstanceManager::FindInstance): not
* private, not shutting down, not draining (being emptied for a live update or migration), and with room: under
* the soft cap, or under the hard cap for players following a friend. Seats held for players being moved in count.
*/
inline bool AcceptsNewPlayers(const InstanceView& instance, uint32_t zone, uint32_t clone, bool friendTransfer) {
if (instance.zoneId != zone || instance.cloneId != clone) return false;
if (instance.isPrivate || instance.shuttingDown || instance.draining) return false;
return instance.Load() < (friendTransfer ? instance.hardCap : instance.softCap);
}
// Whether everyone in source fits into target. Merges may fill up to the hard cap: the soft cap only keeps
// room for friends joining, and a merge is those players staying together anyway.
inline eRefusal CheckMergeTarget(const InstanceView& target, const InstanceView& source) {
@@ -297,6 +326,11 @@ struct MigratePlayersOrder : public LUBitStream {
* (LwoClientGhostManager::OnReceiveConstruction). Not tested with the real client yet.
*/
bool seamless{};
// How long players who are dead or building may take before they are moved anyway. Written last and read only
// when there (older senders didn't write it)
uint16_t maxWaitSeconds{ DEFAULT_MAX_WAIT_SECONDS };
static constexpr uint16_t DEFAULT_MAX_WAIT_SECONDS = 15;
static constexpr uint16_t MAX_MAX_WAIT_SECONDS = 600;
void Serialize(RakNet::BitStream& stream) const override {
stream.Write(migrationId);
@@ -309,6 +343,7 @@ struct MigratePlayersOrder : public LUBitStream {
stream.Write(playersPerSecond);
stream.Write<uint8_t>(mythranShift ? 1 : 0);
stream.Write<uint8_t>(seamless ? 1 : 0);
stream.Write(std::min(maxWaitSeconds, MAX_MAX_WAIT_SECONDS));
}
bool Deserialize(RakNet::BitStream& stream) override {
@@ -319,6 +354,9 @@ struct MigratePlayersOrder : public LUBitStream {
mythranShift = shift != 0;
seamless = seamlessValue != 0;
if (playersPerSecond == 0) playersPerSecond = 1;
maxWaitSeconds = DEFAULT_MAX_WAIT_SECONDS;
uint16_t wait{};
if (stream.GetNumberOfUnreadBits() >= 16 && stream.Read(wait)) maxWaitSeconds = std::min(wait, MAX_MAX_WAIT_SECONDS);
return true;
}
};
@@ -365,7 +403,7 @@ struct MigrationStatus : public LUBitStream {
if (!stream.Read(migrationId) || !stream.Read(stateValue) || !stream.Read(kindValue) || !stream.Read(zoneId) ||
!stream.Read(sourceInstance) || !stream.Read(targetInstance) || !stream.Read(moved) || !stream.Read(remaining) ||
!stream.Read(failed) || !stream.Read(requesterId)) return false;
if (stateValue > static_cast<uint8_t>(InstanceMigration::eState::FAILED) || kindValue > static_cast<uint8_t>(InstanceMigration::eKind::MERGE)) return false;
if (stateValue > static_cast<uint8_t>(InstanceMigration::eState::PREPARED) || kindValue > static_cast<uint8_t>(InstanceMigration::eKind::MERGE)) return false;
state = static_cast<InstanceMigration::eState>(stateValue);
kind = static_cast<InstanceMigration::eKind>(kindValue);
return InstanceMigration::ReadText(stream, message, MAX_MESSAGE);
@@ -385,6 +423,14 @@ struct CarriedPlayerState : public LUBitStream {
LWOOBJID characterId{};
LWOOBJID petItemId{}; // the pet that was out (its item), summoned again; 0 for none
bool seamless{}; // the client kept its scene: skip LOAD_STATIC_ZONE and load the player at once
/**
* Where the player stood. The saved character only keeps it where the location is saved (not on properties
* unless save_property_location is on, and Moon Base always starts at the spawn point), so the target puts the
* player here when it creates them. Written last and read only when there (older senders didn't write it).
*/
bool hasPosition{};
float x{}, y{}, z{};
float rotW{ 1.0f }, rotX{}, rotY{}, rotZ{};
void Serialize(RakNet::BitStream& stream) const override {
stream.Write(targetZone);
@@ -392,12 +438,55 @@ struct CarriedPlayerState : public LUBitStream {
stream.Write(characterId);
stream.Write(petItemId);
stream.Write<uint8_t>(seamless ? 1 : 0);
stream.Write<uint8_t>(hasPosition ? 1 : 0);
if (hasPosition) {
stream.Write(x);
stream.Write(y);
stream.Write(z);
stream.Write(rotW);
stream.Write(rotX);
stream.Write(rotY);
stream.Write(rotZ);
}
}
bool Deserialize(RakNet::BitStream& stream) override {
uint8_t seamlessValue{};
if (!stream.Read(targetZone) || !stream.Read(targetInstance) || !stream.Read(characterId) || !stream.Read(petItemId) || !stream.Read(seamlessValue)) return false;
seamless = seamlessValue != 0;
hasPosition = false;
uint8_t positionValue{};
if (stream.GetNumberOfUnreadBits() >= 8 && stream.Read(positionValue) && positionValue != 0) {
if (!stream.Read(x) || !stream.Read(y) || !stream.Read(z) || !stream.Read(rotW) || !stream.Read(rotX) || !stream.Read(rotY) || !stream.Read(rotZ)) return false;
hasPosition = true;
}
return true;
}
};
/**
* MIGRATE_PREPARE (master -> source world, live updates of property instances): the new instance loads the property
* from the database when it starts, so before it is started the source waits (up to maxWaitSeconds) for everyone to
* leave build mode, saves the property and freezes it: nobody can build or claim it there any more and it is never
* saved again, so the new instance's saves can't be overwritten. The source answers MIGRATE_STATUS PREPARED (or
* FAILED). A MIGRATE_PLAYERS with target port 0 for the same migration unfreezes it again when nobody was moved yet.
*/
struct MigratePrepare : public LUBitStream {
MigratePrepare() : LUBitStream(ServiceType::MASTER, MessageType::Master::MIGRATE_PREPARE) {}
static constexpr uint16_t MAX_WAIT_SECONDS = 600;
uint32_t migrationId{};
uint16_t maxWaitSeconds{ 60 };
void Serialize(RakNet::BitStream& stream) const override {
stream.Write(migrationId);
stream.Write(std::min(maxWaitSeconds, MAX_WAIT_SECONDS));
}
bool Deserialize(RakNet::BitStream& stream) override {
if (!stream.Read(migrationId) || !stream.Read(maxWaitSeconds)) return false;
maxWaitSeconds = std::min(maxWaitSeconds, MAX_WAIT_SECONDS);
return true;
}
};

299
dNet/master/LiveUpdate.h Normal file
View File

@@ -0,0 +1,299 @@
#ifndef __LIVEUPDATE__H__
#define __LIVEUPDATE__H__
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "BitStream.h"
#include "BitStreamUtils.h"
#include "MessageType/Master.h"
#include "dCommonVars.h"
#include "master/InstanceMigration.h"
/**
* Live updates (docs/LiveUpdate.md): with a new build of the server binaries in place, master moves everything onto
* processes started from it without taking the server down. The UGC server finishes its jobs and restarts, auth
* restarts, chat hands its teams over and restarts, every world instance is replaced by a new one its players are
* moved to (instance migrations, InstanceMigration.h), and the dashboard restarts last. Master itself keeps running:
* updating master takes a normal restart.
*
* Messages (appended to MessageType::Master):
* LIVE_UPDATE_REQUEST dashboard, or a world for a GM's /liveupdate -> master LiveUpdateRequest
* LIVE_UPDATE_STATUS master -> dashboard (and the world of the GM who asked) LiveUpdateStatus
* LIVE_UPDATE_RETIRE master -> chat or UGC server LiveUpdateRetire
* CHAT_HANDOFF retiring chat -> master -> the next chat server ChatHandoff
* CHAT_SERVER_READY master -> every world, once the new chat server is up ChatServerReady
* MIGRATE_PREPARE master -> a property instance being replaced MigratePrepare (InstanceMigration.h)
*/
namespace LiveUpdate {
enum class eAction : uint8_t {
START = 0,
CANCEL, // start nothing new; what is under way finishes
STATUS, // just send the status (the dashboard asks when it connects)
};
// What one row of the update is about
enum class eUnitKind : uint8_t {
DATABASE = 0, // the new build's database migrations
UGC,
AUTH,
CHAT,
WORLD, // one world instance
DASHBOARD,
};
/**
* Where one row is. A world goes PENDING -> (PREPARING, a property saving itself) -> STARTING (its replacement is
* starting) -> READY (the replacement took its first players or is waiting for them) -> DRAINING (players are
* being moved, or it waits for an activity to end; nobody new is sent to it) -> STOPPING -> STOPPED. A server:
* PENDING -> STOPPING (asked to exit) -> STARTING (the new one is launching) -> STOPPED (the new one is up).
*/
enum class eUnitState : uint8_t {
PENDING = 0,
PREPARING,
STARTING,
READY,
WAITING, // a world waiting for its players to leave by themselves (activities, character selection)
DRAINING,
STOPPING,
STOPPED, // done: replaced (or simply stopped when nobody was there)
FAILED, // left as it was (a world keeps running on the old build)
SKIPPED, // not running, not enabled, or the update was cancelled before it got there
};
enum class ePhase : uint8_t {
IDLE = 0,
RUNNING,
CANCELLING, // cancelled; waiting for what is under way
DONE,
FAILED, // stopped early (e.g. the database migrations failed); nothing after that was touched
CANCELLED,
};
inline const char* KindName(eUnitKind kind) {
switch (kind) {
case eUnitKind::DATABASE: return "database";
case eUnitKind::UGC: return "ugc";
case eUnitKind::AUTH: return "auth";
case eUnitKind::CHAT: return "chat";
case eUnitKind::WORLD: return "world";
case eUnitKind::DASHBOARD: return "dashboard";
}
return "unknown";
}
inline const char* StateName(eUnitState state) {
switch (state) {
case eUnitState::PENDING: return "pending";
case eUnitState::PREPARING: return "preparing";
case eUnitState::STARTING: return "starting";
case eUnitState::READY: return "ready";
case eUnitState::WAITING: return "waiting";
case eUnitState::DRAINING: return "draining";
case eUnitState::STOPPING: return "stopping";
case eUnitState::STOPPED: return "stopped";
case eUnitState::FAILED: return "failed";
case eUnitState::SKIPPED: return "skipped";
}
return "unknown";
}
inline const char* PhaseName(ePhase phase) {
switch (phase) {
case ePhase::IDLE: return "idle";
case ePhase::RUNNING: return "running";
case ePhase::CANCELLING: return "cancelling";
case ePhase::DONE: return "done";
case ePhase::FAILED: return "failed";
case ePhase::CANCELLED: return "cancelled";
}
return "unknown";
}
inline bool IsFinished(eUnitState state) {
return state == eUnitState::STOPPED || state == eUnitState::FAILED || state == eUnitState::SKIPPED;
}
inline bool IsFinished(ePhase phase) {
return phase == ePhase::DONE || phase == ePhase::FAILED || phase == ePhase::CANCELLED;
}
}
// LIVE_UPDATE_REQUEST
struct LiveUpdateRequest : public LUBitStream {
LiveUpdateRequest() : LUBitStream(ServiceType::MASTER, MessageType::Master::LIVE_UPDATE_REQUEST) {}
static constexpr uint16_t MAX_BY = 64;
static constexpr int32_t DEFAULT_WARN = -1;
LiveUpdate::eAction action{ LiveUpdate::eAction::STATUS };
int32_t warnSeconds{ DEFAULT_WARN }; // seconds players are warned before they are moved; -1: live_update_warn_seconds
LWOOBJID requesterId{}; // the character who asked (told how it goes in chat); 0 for the dashboard
std::string requestedBy;
void Serialize(RakNet::BitStream& stream) const override {
stream.Write(static_cast<uint8_t>(action));
stream.Write(warnSeconds);
stream.Write(requesterId);
InstanceMigration::WriteText(stream, requestedBy, MAX_BY);
}
bool Deserialize(RakNet::BitStream& stream) override {
uint8_t actionValue{};
if (!stream.Read(actionValue) || actionValue > static_cast<uint8_t>(LiveUpdate::eAction::STATUS)) return false;
action = static_cast<LiveUpdate::eAction>(actionValue);
if (!stream.Read(warnSeconds) || !stream.Read(requesterId)) return false;
if (warnSeconds < DEFAULT_WARN || warnSeconds > InstanceMigrationRequest::MAX_WARN_SECONDS) return false;
return InstanceMigration::ReadText(stream, requestedBy, MAX_BY);
}
};
// LIVE_UPDATE_STATUS: the whole update, sent again whenever something changes
struct LiveUpdateStatus : public LUBitStream {
LiveUpdateStatus() : LUBitStream(ServiceType::MASTER, MessageType::Master::LIVE_UPDATE_STATUS) {}
static constexpr uint16_t MAX_TEXT = 300;
static constexpr uint32_t MAX_UNITS = 4096;
struct Unit {
LiveUpdate::eUnitKind kind{};
LiveUpdate::eUnitState state{};
uint32_t zoneId{};
uint32_t instanceId{};
uint32_t cloneId{};
uint32_t replacement{}; // the instance that took over (worlds)
uint32_t players{}; // players there when the row was made
uint16_t moved{};
uint8_t isPrivate{};
std::string message;
};
uint32_t updateId{}; // 0: there never was one since master started
LiveUpdate::ePhase phase{ LiveUpdate::ePhase::IDLE };
int64_t startedAt{}; // unix time
int64_t finishedAt{}; // unix time, 0 while running
LWOOBJID requesterId{};
std::string by;
std::string message;
std::vector<Unit> units;
void Serialize(RakNet::BitStream& stream) const override {
stream.Write(updateId);
stream.Write(static_cast<uint8_t>(phase));
stream.Write(startedAt);
stream.Write(finishedAt);
stream.Write(requesterId);
InstanceMigration::WriteText(stream, by, MAX_TEXT);
InstanceMigration::WriteText(stream, message, MAX_TEXT);
const auto count = static_cast<uint32_t>(std::min<size_t>(units.size(), MAX_UNITS));
stream.Write(count);
for (uint32_t i = 0; i < count; i++) {
const auto& unit = units[i];
stream.Write(static_cast<uint8_t>(unit.kind));
stream.Write(static_cast<uint8_t>(unit.state));
stream.Write(unit.zoneId);
stream.Write(unit.instanceId);
stream.Write(unit.cloneId);
stream.Write(unit.replacement);
stream.Write(unit.players);
stream.Write(unit.moved);
stream.Write(unit.isPrivate);
InstanceMigration::WriteText(stream, unit.message, MAX_TEXT);
}
}
bool Deserialize(RakNet::BitStream& stream) override {
uint8_t phaseValue{};
if (!stream.Read(updateId) || !stream.Read(phaseValue) || phaseValue > static_cast<uint8_t>(LiveUpdate::ePhase::CANCELLED)) return false;
phase = static_cast<LiveUpdate::ePhase>(phaseValue);
if (!stream.Read(startedAt) || !stream.Read(finishedAt) || !stream.Read(requesterId)) return false;
if (!InstanceMigration::ReadText(stream, by, MAX_TEXT) || !InstanceMigration::ReadText(stream, message, MAX_TEXT)) return false;
uint32_t count{};
if (!stream.Read(count) || count > MAX_UNITS) return false;
units.resize(count);
for (auto& unit : units) {
uint8_t kind{}, state{};
if (!stream.Read(kind) || !stream.Read(state)) return false;
if (kind > static_cast<uint8_t>(LiveUpdate::eUnitKind::DASHBOARD) || state > static_cast<uint8_t>(LiveUpdate::eUnitState::SKIPPED)) return false;
unit.kind = static_cast<LiveUpdate::eUnitKind>(kind);
unit.state = static_cast<LiveUpdate::eUnitState>(state);
if (!stream.Read(unit.zoneId) || !stream.Read(unit.instanceId) || !stream.Read(unit.cloneId) || !stream.Read(unit.replacement) ||
!stream.Read(unit.players) || !stream.Read(unit.moved) || !stream.Read(unit.isPrivate)) return false;
if (!InstanceMigration::ReadText(stream, unit.message, MAX_TEXT)) return false;
}
return true;
}
};
// LIVE_UPDATE_RETIRE: no payload. Chat hands its teams over first; the UGC server finishes the jobs it is running.
struct LiveUpdateRetire : public LUBitStream {
LiveUpdateRetire() : LUBitStream(ServiceType::MASTER, MessageType::Master::LIVE_UPDATE_RETIRE) {}
};
// CHAT_SERVER_READY: no payload
struct ChatServerReady : public LUBitStream {
ChatServerReady() : LUBitStream(ServiceType::MASTER, MessageType::Master::CHAT_SERVER_READY) {}
};
/**
* CHAT_HANDOFF: the teams a retiring chat server had. Who is online is not carried: every world sends its players to
* the new chat server again (CHAT_SERVER_READY), and friends lists are read from the database.
*/
struct ChatHandoff : public LUBitStream {
ChatHandoff() : LUBitStream(ServiceType::MASTER, MessageType::Master::CHAT_HANDOFF) {}
static constexpr uint32_t MAX_TEAMS = 20000;
static constexpr uint8_t MAX_MEMBERS = 16;
struct Team {
LWOOBJID teamId{};
LWOOBJID leaderId{};
std::vector<LWOOBJID> members;
uint8_t lootFlag{};
bool local{};
uint32_t zoneId{};
uint32_t instanceId{};
uint32_t cloneId{};
};
std::vector<Team> teams;
void Serialize(RakNet::BitStream& stream) const override {
const auto count = static_cast<uint32_t>(std::min<size_t>(teams.size(), MAX_TEAMS));
stream.Write(count);
for (uint32_t i = 0; i < count; i++) {
const auto& team = teams[i];
stream.Write(team.teamId);
stream.Write(team.leaderId);
const auto members = static_cast<uint8_t>(std::min<size_t>(team.members.size(), MAX_MEMBERS));
stream.Write(members);
for (uint8_t m = 0; m < members; m++) stream.Write(team.members[m]);
stream.Write(team.lootFlag);
stream.Write<uint8_t>(team.local ? 1 : 0);
stream.Write(team.zoneId);
stream.Write(team.instanceId);
stream.Write(team.cloneId);
}
}
bool Deserialize(RakNet::BitStream& stream) override {
uint32_t count{};
if (!stream.Read(count) || count > MAX_TEAMS) return false;
teams.resize(count);
for (auto& team : teams) {
uint8_t members{}, local{};
if (!stream.Read(team.teamId) || !stream.Read(team.leaderId) || !stream.Read(members) || members > MAX_MEMBERS) return false;
team.members.resize(members);
for (auto& member : team.members) {
if (!stream.Read(member)) return false;
}
if (!stream.Read(team.lootFlag) || !stream.Read(local) || !stream.Read(team.zoneId) || !stream.Read(team.instanceId) || !stream.Read(team.cloneId)) return false;
team.local = local != 0;
}
return true;
}
};
#endif //!__LIVEUPDATE__H__