Player identity across a disconnect and rejoin¶
A player's Human.ReferenceId is stable across a disconnect and rejoin into the same running server world. The Human is never destroyed on disconnect and the rejoining client re-possesses the same object. Four specific cases produce a new id.
Line numbers in this page refer to the 0.2.6403.27689 decompile of Assembly-CSharp.dll.
How ReferenceId is assigned¶
Referencable (37867) holds the allocator. Ids are sequential, server-authoritative, and persisted in the save. Not random, not a hash, not derived from the Steam id.
public static class Referencable
{
public static long NextReferenceId = 1L;
public static object NextReferenceIdLock = new object();
public static readonly List<IReferencable> ReferencablesChanged = new List<IReferencable>();
public static readonly Dictionary<long, IReferencable> Referencables = new Dictionary<long, IReferencable>();
public static List<IReferencable> FailedToRegister = new List<IReferencable>();
public const long INVALID = 0L;
RegisterNew (37900) is the fresh-spawn path. It throws if called on a client (37902-37910):
public static bool RegisterNew(IReferencable iReferencable)
{
if (Assets.Scripts.Networking.NetworkManager.IsClient)
{
string text = "Error: Clients should not be assigning a new Reference";
if (iReferencable != null)
{
text = text + ". Client is trying to assign a new reference for " + iReferencable.DisplayName;
}
throw new System.Exception(text);
}
and then hands out the next number under the lock (37921-37930):
lock (NextReferenceIdLock)
{
if (Referencables.ContainsKey(NextReferenceId))
{
ConsoleWindow.PrintError($"error trying to register '{iReferencable.DisplayName}' with referenceId of '{NextReferenceId}' as it is already Assigned to: '{Referencables[NextReferenceId].DisplayName}'");
return false;
}
iReferencable.ReferenceId = NextReferenceId;
NextReferenceId++;
}
RegisterAs (37940) is the adopt-an-existing-id path used by save load and by client join:
lock (NextReferenceIdLock)
{
thing.ReferenceId = referenceId;
referenceId++;
NextReferenceId = ((referenceId > NextReferenceId) ? referenceId : NextReferenceId);
}
Load re-adopts the saved id: Thing.Create<Thing>(prefab, pos, rot, thingData.ReferenceId) reaches the referenceId != 0 branch inside Thing.Create<T> (319024-319027):
The referenceId parameter of Thing.Create<T> is the discriminator throughout: 0L means "server-side fresh spawn, allocate via RegisterNew", nonzero means "recreate a known thing under an existing id via RegisterAs". See StructureRegistration for the full Thing.Create<T> body.
Disconnect leaves the Human in the world¶
NetworkServer.ClientDisconnected (213891) is the entire server-side teardown:
public static void ClientDisconnected(long connectionId)
{
Client client = Client.Find(connectionId);
if (client != null)
{
client.SetState(ClientState.Disconnected);
ConsoleWindow.Print("Client disconnected: " + client.ToStringOneLine());
NetworkBase.RemoveClient(client);
}
}
and NetworkBase.RemoveClient (39227) is:
No Thing destruction, no Referencable deregistration, no Brain.PlayerBrains removal. The body stays in the world as an offline entity.
Brain.IsOnline (340989) is defined purely as connection presence:
Offline bodies keep metabolising at a reduced rate. Brain.OnLifeTick (341225-341238) gates the rate on DifficultySetting.Current.OfflineMetabolism:
public override void OnLifeTick()
{
base.OnLifeTick();
if (!ParentEntity)
{
return;
}
bool flag = ParentEntity is Npc;
float num = ((flag || IsOnline) ? 0.2f : (0.2f * Mathf.Clamp(DifficultySetting.Current.OfflineMetabolism, 0.1f, 1f)));
if (!flag && ((object)ParentHuman == null || ParentHuman.IsSleeping))
{
num *= 0.5f;
}
float num2 = ((flag || IsOnline) ? 3f : (3f * Mathf.Clamp(DifficultySetting.Current.OfflineMetabolism, 0.1f, 1f)));
The only code that unbinds a brain from a client id is OnServer.RelinquishBrain (40470) and Brain.RelinquishControl (341127):
public static void RelinquishBrain(Brain playerBrain)
{
Brain.PlayerBrains.Remove(playerBrain.ClientId);
playerBrain.ClientControl = false;
playerBrain.ClientId = 0uL;
}
public void RelinquishControl()
{
LocalControl = false;
ParentEntity?.ReleaseControl();
PlayerBrains.Remove(ClientId);
if (Assets.Scripts.Networking.NetworkManager.IsClient)
{
NetworkClient.ReturnCharacter();
}
InventoryManager.ParentBrain = null;
InventoryManager.Parent = null;
ClientId = 0uL;
}
Those are reached only from the respawn paths Human.Respawn() (361921) and Human.RespawnFromNoParent() (361956), and from RelinquishControlMessage (278569). Nothing on the disconnect path touches them.
Rejoin re-possesses the same object¶
NetworkClient.ProcessJoinData (213188) ends with a character request (213229-213230):
await ImGuiLoadingScreen.SetState(GameStrings.LoadingScreenRequestingCharacter.DisplayString);
await RequestCharacterAsync(isRespawn: false, cancellationToken);
RequestCharacterAsync (213286) is the decision point. A new Human is requested only when there is no surviving brain for this Steam id, or when the caller explicitly asks for a respawn:
public static async UniTask RequestCharacterAsync(bool isRespawn, CancellationToken token)
{
ConsoleWindow.Print("[NetworkClient] Sending respawn message and waiting to take control");
ulong localClientId = Assets.Scripts.Networking.NetworkManager.LocalClientId;
PlayerCosmetics cosmetics = PlayerCosmetics.Load(Singleton<GameManager>.Instance.CustomCosmeticsSlot);
Brain.GetValidatedBrain(localClientId, out var playerBrain);
if ((object)playerBrain == null || isRespawn)
{
SendToServer(new RespawnMessage(localClientId, cosmetics));
}
await TakeControl(token);
}
Brain.GetValidatedBrain (341304) is a Steam-id lookup into PlayerBrains, with a decay check:
public static void GetValidatedBrain(ulong steamId, out Brain playerBrain)
{
PlayerBrains.TryGetValue(steamId, out playerBrain);
if ((object)playerBrain != null && (object)playerBrain.ParentHuman != null && playerBrain.ParentHuman.State == EntityState.Decay)
{
playerBrain.ClientId = 0uL;
if (GameManager.RunSimulation)
{
OnServer.Destroy(playerBrain);
}
playerBrain = null;
}
}
PlayerBrains (340944) is a plain static dictionary keyed by Steam id:
It is rebuilt on the joining client by Brain.DeserializeOnJoin (341216), which ends in RegisterBrain(ClientId):
public override void DeserializeOnJoin(RocketBinaryReader reader)
{
base.DeserializeOnJoin(reader);
ClientId = reader.ReadUInt64();
SteamName = reader.ReadString();
ClientControl = reader.ReadBoolean();
RegisterBrain(ClientId);
}
TakeControl (213308) then possesses the existing body and echoes its id back (213321, 213327-213329):
takeControlMessage.HumanId = playerBrain.ParentHuman.ReferenceId;
takeControlMessage.ClientId = localClientId;
takeControlMessage.SendToServer();
No Human.CreateCharacter, no OnServer.Create<Human>, no RegisterNew. Same Human, same ReferenceId.
The four cases that do produce a new ReferenceId¶
| Case | Trigger | Mechanism | Result |
|---|---|---|---|
| Body fully decayed while offline | ParentHuman.State == EntityState.Decay at rejoin |
GetValidatedBrain (341304) nulls ClientId, destroys the brain, returns null, so RequestCharacterAsync takes the RespawnMessage branch |
New Human via Human.CreateCharacter (362098) and OnServer.Create<Human> (362115) with referenceId == 0, which reaches RegisterNew and a new sequential id |
| Player died and chose respawn | Human.Respawn() (361921) or Human.RespawnFromNoParent() (361956) |
Both call NetworkClient.RequestCharacterAsync(isRespawn: true, ...) (361935 / 361964), forcing the RespawnMessage branch regardless of the brain lookup |
New Human, new id. See RespawnFlow |
| Character relinquished | RelinquishControlMessage (278569) |
OnServer.RelinquishBrain (40470) does Brain.PlayerBrains.Remove(playerBrain.ClientId) and playerBrain.ClientId = 0uL |
The next join finds no brain for that Steam id, so GetValidatedBrain yields null and a new Human is created |
| A different Steam account | Any join under a different Steam id | PlayerBrains is keyed by Steam id |
No entry, so a new Human |
The creation site (362098, 362115):
public static Human CreateCharacter(ulong clientId, string steamName, PlayerCosmetics cosmetics = null, bool isRespawn = false, StartLocationData startLocation = null, ISpawnPoint spawnPoint = null)
CreateCharacter does reuse spawn continuity, but that is start-location data, not the ReferenceId. RespawnMessage.Process (278542-278546) looks up the previous client info to decide the spawn point:
SerializedClientInfo clientInfo = GameManager.GetClientInfo(HumanId);
bool isRespawn = clientInfo != null;
StartLocationData startLocation = ((clientInfo != null) ? DataCollection.Get<StartLocationData>(clientInfo.StartLocationHash) : null);
ISpawnPoint spawnPoint = ((clientInfo != null) ? Referencable.Find<ISpawnPoint>(clientInfo.SpawnPointReference) : null);
Human.CreateCharacter(HumanId, client?.name ?? "Unknown", Cosmetics, isRespawn, startLocation, spawnPoint);
and CreateCharacter writes the record back at 362145:
GameManager.AddClientInfo(clientId, new SerializedClientInfo(clientId, startLocation.IdHash, spawnPoint?.ReferenceId ?? 0));
ReferenceId remapping on load¶
Normal load preserves ids exactly. Remapping happens only on collision or failure. RegisterAs pushes onto FailedToRegister in three cases (37949-37966): the id is 0, the thing already holds an id, or the id is already taken.
if (referenceId == 0L)
{
ConsoleWindow.PrintError("error trying to assign " + thing.DisplayName + " a null reference id");
FailedToRegister.Add(thing);
return false;
}
if (!force && thing.ReferenceId != 0L)
{
ConsoleWindow.PrintError($"error trying to register '{thing.DisplayName}' with id '{referenceId}' as it already has been registered with '{thing.ReferenceId}' ");
FailedToRegister.Add(thing);
return false;
}
if (Referencables.ContainsKey(referenceId))
{
ConsoleWindow.PrintError($"Couldn't register '{thing.DisplayName}' with id '{referenceId}' as there is already an entry for this id. It is used by: {Referencables[referenceId].DisplayName}");
FailedToRegister.Add(thing);
return false;
}
World load then calls Referencable.AssignNewIdToDuplicates() (268759-268761):
SpawnDataHelper.LoadSave(worldData);
Referencable.AssignNewIdToDuplicates();
await UniTask.NextFrame();
public static void AssignNewIdToDuplicates()
{
foreach (IReferencable item in FailedToRegister)
{
RegisterNew(item);
ConsoleWindow.PrintAction(item.DisplayName + "' has been assigned a new Id: " + StringManager.Get(item.ReferenceId));
}
FailedToRegister.Clear();
}
Referencable.FindAndSetNextReferenceId(worldData) (38060) scans the saved collections and parks the allocator past the highest saved id (38091-38094):
Referencable.ClearReferences() (38043) resets the allocator to 1:
public static void ClearReferences()
{
lock (Referencables)
{
Referencables.Clear();
}
lock (NextReferenceIdLock)
{
NextReferenceId = 1L;
}
}
It is reached via Thing.ClearAll() in World.Initialize (325012-325019), which is why ids restart at 1 for a fresh world:
private static void Initialize(string worldName, bool newWorld, string loadingScreenMessage)
{
if (!GameManager.IsBatchMode)
{
XmlSaveLoad.UpdateLoadingScreen(loadingScreenMessage, 0f).Forget();
}
Thing.ClearAll();
GameManager.GameState = GameState.Joining;
The three deployment scenarios¶
Dedicated server that keeps running: stable. The world is never torn down, Referencables is never cleared, the Human is never destroyed, and PlayerBrains[steamId] survives the disconnect. A player who drops and rejoins gets the same Human with the same ReferenceId.
Host / listen server: stable for remote clients. A remote client disconnecting and rejoining behaves exactly like the dedicated case. The host leaving shuts the world down, which is the next case.
Save reloaded from the menu: still stable for the same Steam account. Ids are re-adopted by RegisterAs, and brain ownership round-trips through the save. BrainSaveData (340934-340941) carries the Steam id:
public class BrainSaveData : DynamicThingSaveData
{
[XmlElement]
public ulong ClientSteamId;
[XmlElement]
public PlayerCosmetics identity;
}
Brain.DeserializeSave (341153) restores the mapping:
public override void DeserializeSave(ThingSaveData savedData)
{
base.DeserializeSave(savedData);
if (savedData is BrainSaveData brainSaveData)
{
ClientId = brainSaveData.ClientSteamId;
}
if (ClientId != 0)
{
PlayerBrains[ClientId] = this;
}
}
World.HandlePlayerControl() (324973) then matches the local Steam id against PlayerBrains and takes control of the existing body:
public static DynamicThing HandlePlayerControl()
{
foreach (KeyValuePair<ulong, Brain> playerBrain in Brain.PlayerBrains)
{
if (playerBrain.Key != Assets.Scripts.Networking.NetworkManager.LocalClientId)
{
continue;
}
Brain value = playerBrain.Value;
if ((bool)value)
{
if ((bool)(value.ParentSlot.Parent as Entity))
{
value.TakeControl();
}
else
{
value.TakeControlInBodyBag();
}
return value.ParentSlot.Parent.AsDynamicThing;
}
}
return null;
}
Only if that returns null does the loader fall through to World.CreateCharacterAndTakeControl() and a new Human. The call site (268783-268791):
else if ((bool)World.HandlePlayerControl())
{
await LodManager.InitialiseLodsOnLoad();
}
else
{
LodManager.EnqueueRequesterToUpdate(World.CreateCharacterAndTakeControl());
await LodManager.InitialiseLodsOnLoad();
}
and the definition (324998):
public static Human CreateCharacterAndTakeControl()
{
ulong localClientId = Assets.Scripts.Networking.NetworkManager.LocalClientId;
string username = Assets.Scripts.Networking.NetworkManager.Username;
PlayerCosmetics cosmetics = PlayerCosmetics.Load(Singleton<GameManager>.Instance.CustomCosmeticsSlot) ?? new PlayerCosmetics();
SerializedClientInfo value;
bool flag = GameManager.ClientInfo.TryGetValue(localClientId, out value);
StartLocationData startLocation = (flag ? DataCollection.Get<StartLocationData>(value.StartLocationHash) : null);
ISpawnPoint spawnPoint = (flag ? Referencable.Find<ISpawnPoint>(value.SpawnPointReference) : null);
Human human = Human.CreateCharacter(localClientId, username, cosmetics, flag, startLocation, spawnPoint);
human.OrganBrain.TakeControl(setPhysics: false);
return human;
}
The caveat on this third scenario is AssignNewIdToDuplicates: a save carrying duplicate or zero ids can have arbitrary Things renumbered on load. That is a corrupted-save path, not normal operation.
When the server's process restarts under a remote player¶
The three scenarios above follow a remote player through a server that keeps running, and a save's own local player through a reload. None follows a remote player through a restart of the server's process: a dedicated server restarting into its save, or a listen host relaunching the game and loading its world again, while the remote player stays in their own game and joins the new process. Line numbers in this subsection refer to the 0.2.6428.27798 decompile, where every member cited is textually identical in the client and the dedicated server builds.
The id comes back when the save holds the player's character. The save carries the player's brain with its Steam id (BrainSaveData.ClientSteamId, 346885). The restarted server's Brain.DeserializeSave (347101-347112) puts that brain back into Brain.PlayerBrains, the join payload hands it to the rejoining client, whose Brain.DeserializeOnJoin (347164-347171) registers it the same way (RegisterBrain, 347283-347287), and NetworkClient.RequestCharacterAsync (218933-218944) then finds it through GetValidatedBrain and takes control of the saved body rather than sending a RespawnMessage: the mechanism of "Rejoin re-possesses the same object" and of the third scenario. Measured on the test rig on 2026-09-27, where the rig's reload saves the world under its own name before it stops the server and loads it again: a joiner's Human read 509126 before and after, and 509106 before and after in each of the nine earlier dedicated-server restarts of that day whose records were kept. A listen host's relaunch keeps it the same way, measured on 2026-09-28 in two runs in which the joiner went to its menu, the host relaunched into its save and the joiner joined again: its Human read 509317 before and after, and 509313 before and after. Dedicated-server restarts that day kept 509119, 509123, and 509126 twice in one run.
When the save does not hold it, the player gets a new Human. Measured on the rig on 2026-09-25: a dedicated server that was stopped without saving and then loaded the same save again came back without the character the joiner had been given in the unsaved session, and the rejoin gave the player a new Human, 509159 in place of 510297. The new id is lower because the loaded world hands out ids again from past the highest id in the save (Referencable.FindAndSetNextReferenceId, 38063-38099, sets NextReferenceId = highest + 1; see "ReferenceId remapping on load"). What survives is the id the save carries, not the id the player had a moment before.
What does not survive is anything the server kept in memory. The new process starts with every static empty, so server-side mod state keyed by the Human.ReferenceId is gone although the key comes back. The client's process did not restart. It left its world through GameManager.LeaveGame: a RakNet ConnectionLost (279323-279326) reaches NetworkManager.PlayerDisconnected, which on NetworkRole.Client calls GameManager.LeaveGame() (279371-279382), and LeaveGame tears the world down at the end of that frame (see GameManager, "Leaving a session: LeaveGame returns before the world is gone"). Its own statics carry into the next join, where the same key is back. Measured the same day, on the joiner's console, in order: A client lost the connection., Client Disconnecting from Host, Game State: None, Ending network connection, Resetting network state, a second Ending network connection, then Game State: Joining and a new Connection ID as it joined again in the same game process. After it the joiner still held Spray Paint Plus's preference mask row 32760 under Human 509126, as both processes had before the restart, and through 15 s of reads that began 3 s after a spray gun was put in the joiner's hand, the restarted dedicated server held no row for it at all.
Why this matters for mod state¶
Mod state keyed by Human.ReferenceId on a server survives a player's reconnect. That cuts both ways.
- Per-player rows do not self-expire when a player leaves. They must be pruned explicitly, which is what a Prefix on
NetworkServer.ClientDisconnectedis for. See ClientDisconnectedPrefix. - A client-side cache keyed by the local
Human'sReferenceIdis still valid after a rejoin. Any "resend only when the value changed" dedupe built on it stays silent across the reconnect, because the key and the last-sent value both still match. If a rejoin must force a resend, clear the cache on the join path rather than relying on the id to change.
A server restart breaks the symmetry a rejoin keeps¶
Both bullets above assume the server's process keeps running, so the server keeps its copy while the client keeps its own. Across a restart of that process ("When the server's process restarts under a remote player", above) the id comes back and the server's copy does not. A "resend only when the value changed" dedupe on the client then stays silent exactly when the server needs the value again: the client compares against its own record, which lived through the restart in its own process, finds nothing new and sends nothing, and the server holds nothing for that player. Spray Paint Plus shipped exactly this through v1.12.1 with its per-player preference mask. Its server reads an absent row as allowing everything, so after a server restart that gave a returning player their Human back, that player's Paint Single Item By Default and client-side opt-outs were ignored until they pressed Shift or Ctrl (see SprayPaintPlusNetworking, "A restarted server gets the mask back").
Ways to force the resend, and the obvious one that does not work on its own:
- Key the client's record to the controlled body, not to its id. A session builds its world afresh on the client: leaving destroys every Thing (
GameManager.ClearGameAllcallsThing.ClearAll(), 210288, andThing.ClearAll, 328171, destroys each one), and a join or a load creates each Thing again (a load starts withThing.ClearAll()inWorld.Initialize, 330953). So the localHumanis a new object after every join, load and respawn, even when itsReferenceIdis the old one. A record that remembers which object it last reported for, held through aWeakReferenceso a world that was left is not kept alive, owes a report on the first frame of every session and needs no hook. Not in a release yet: Spray Paint Plus does this inPreferenceMaskReports, which is in commitfdcffdc5on master, and is targeted at v1.13.0. In v1.12.1, after a server restart, the server reads a rejoining player as allowing everything until they press Shift or Ctrl. Measured on the test rig on 2026-09-28 by the checka joiner's own paint preferences still apply after the server restarts, across a listen host's relaunch and a dedicated server's restart: on a build without it (Release DLL sha25656763957) the restarted server still held no mask for the returning player after 30 s with a can in that player's hand, and onfdcffdc5's build (c0c00c18) it held the player's mask by the first read with a can in hand (see SprayPaintPlusNetworking, "A restarted server gets the mask back"). - Clear the record on the join path, once the new world exists, as the second bullet above says. By then
ClearGameAllhas run and the old world is gone. - Not on
GameManager.LeaveGamealone.LeaveGameis on every way a client leaves a session while its process keeps running, but it returns with the old world still standing:GameManager.ClearGameAlltears it down at the end of that frame, andInventoryManager.NormalModecan still run in between with the oldHumanin hand, depending on the order ofGameManager.Managers, which is scene data and not in the code. A record cleared by aLeaveGamepostfix can be spent in that window on a send into a closing connection, and the next session then sends nothing. See GameManager, "Leaving a session: LeaveGame returns before the world is gone".
Verification history¶
- 2026-07-27: page created against the 0.2.6403.27689 decompile of
Assembly-CSharp.dll. All excerpts read directly from the decompile and confirmed at the cited line numbers by an independent pass. - 2026-07-27: while writing this page, the then-current skeleton and mechanism sentence on ClientDisconnectedPrefix were found to contradict the verified signature
NetworkServer.ClientDisconnected(long connectionId). A fresh validator was spawned perResearch/WORKFLOW.mdRule 3 and returned "B is correct": the method takes a singlelong connectionId,NetworkBase.RemoveClientonly removes the instance from the staticClientslist and mutates nothing on theClientitself, and a Harmony patch parameter namedclientfails to bind at patch time. A parallel pass in the same session reached the identical verdict and rewrote that page; its own Verification History carries the full resolution. Nothing on this page was changed by the conflict, and the disconnect excerpts here match the ones on the rewritten page. - 2026-09-27 (game version 0.2.6428.27798): added two subsections, each with its own stamp; the rest of the page and its stamps are untouched, and its 0.2.6403.27689 line numbers stand. "When the server's process restarts under a remote player", under the deployment scenarios: the id comes back when the save holds the player's character (the brain chain re-read at 0.2.6428.27798,
Brain.DeserializeSave,DeserializeOnJoin,RegisterBrain,GetValidatedBrain,NetworkClient.RequestCharacterAsync, each textually identical in the client and dedicated server decompiles; measured on the rig on 2026-09-27, Human 509126 kept across a restart in the dedicated-server cell of 'removing the last glow leaves no side-car', lock owner d718eb1f, and 509106 kept in nine earlier restarts that day); a new, lower id when the save does not hold it (measured 2026-09-25, 510297 became 509159 after a restart that had not saved;FindAndSetNextReferenceIdre-read); and the client's side of the restart, a RakNetConnectionLostreachingGameManager.LeaveGame(ReceiveEvents279319-279326,PlayerDisconnected279371-279382), with the joiner's console and the preference-mask rows read on both processes after the rejoin. "A server restart breaks the symmetry a rejoin keeps", under "Why this matters for mod state": the dedupe that stays silent when the server lost its copy, Spray Paint Plus's shipped instance of it, and three ways to force a resend, one of them Spray Paint Plus's fix in commitb9fce88d(unreleased, marked). Drafted in.work/2026-09-27-phase3-rig/M5-RESULT.mdsection 7.3 (a); changed while checking it: the earlier restarts that kept 509106 are nine with records, not three; the 510297 case was a restart that had not saved, which gave the player a new Human rather than a body the save held; and the console sequence includes the secondEnding network connectionthatNetworkClient.Disconnectprints 500 ms after the first. Additive: every earlier statement on the page is about a server whose process keeps running, and the two bullets of "Why this matters for mod state" stand as written, so no fresh validator was needed. The top-levelverified_inmoved to 0.2.6428.27798 for these two subsections. - 2026-09-28: the Spray Paint Plus fix this page marks landed on master as
fdcffdc5; the cited files are unchanged from the branch commits named in the entries above, so only the markers and sources were updated to cite it. Still not in a release (v1.12.1 is the published build). - 2026-09-28 (game version 0.2.6428.27798): added rig readings to both 0.2.6428.27798 subsections, from the runs of
a joiner's own paint preferences still apply after the server restarts(cellsJLHandJDS, on a build withoutfdcffdc5, Release DLL sha25656763957, and onfdcffdc5's build,c0c00c18) and ofremoving the last glow leaves no side-caronc0c00c18. "When the server's process restarts under a remote player": a listen host's relaunch into its save kept the joiner's Human (509317, and 509313 in the second run), which the subsection had in scope but had not measured, and three more dedicated-server restarts kept theirs. "A server restart breaks the symmetry a rejoin keeps": the Spray Paint Plus fix in the first bullet, which the entry of 2026-09-27 recorded as source only, measured on both builds. Read from each cell'sreloads.txtandrestart.txt. Additive; no earlier statement changes. Both subsections restamped; the marker stays, since the fix is not in a release.
Open questions¶
- Whether an in-world confirmation run matches the source reading has not been done. The whole page is source-derived; a dedicated-server session that records a player's
Human.ReferenceId, disconnects, rejoins, and re-reads it would close the loop. (A rejoin across a restart of the server's process has since been measured, as of 2026-09-27: the id came back when the save held the character; see "When the server's process restarts under a remote player". A rejoin into a server that kept running has still not been measured.)