Conversation
There was a problem hiding this comment.
Pull request overview
Adds a dedicated “bootstrap mode” for standalone server deployments where settings.toml and/or save.zip are missing, enabling the first connecting client to configure settings and upload an initial world save, after which the server shuts down cleanly.
Changes:
- Introduces bootstrap networking packets + client/server connection states to coordinate settings and save upload.
- Adds a client-side bootstrap configurator UI + supporting patches/components to drive the initial world generation and upload.
- Updates standalone server startup to detect missing files, enter bootstrap mode, and adjust join/connection gating accordingly.
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| Source/Tests/BootstrapSettingsTest.cs | Adds a test ensuring shared TOML serialization includes join-critical fields. |
| Source/Server/Server.cs | Detects missing settings.toml/save.zip, enables bootstrap mode, adjusts startup flow. |
| Source/Server/BootstrapMode.cs | Adds a bootstrap-mode wait helper used by the standalone server entrypoint. |
| Source/Common/Util/TomlSettingsCommon.cs | Adds shared TOML serializer used by bootstrap settings upload/preview. |
| Source/Common/ServerSettings.cs | Adds defaults for gameName/lanAddress and includes them in ExposeData. |
| Source/Common/PlayerManager.cs | Allows pre-connect while server is not fully started when in bootstrap mode. |
| Source/Common/Networking/State/ServerJoiningState.cs | Sends bootstrap state to client during protocol handshake and transitions to bootstrap state. |
| Source/Common/Networking/State/ServerBootstrapState.cs | Implements server-side bootstrap protocol: accept settings, receive save upload, write files, disconnect clients, stop server. |
| Source/Common/Networking/Packets.cs | Adds new packet IDs for bootstrap settings/save upload and server bootstrap state. |
| Source/Common/Networking/Packet/BootstrapUploadPackets.cs | Defines bootstrap upload packets and a binder for ServerSettings. |
| Source/Common/Networking/Packet/BootstrapPacket.cs | Defines ServerBootstrapPacket carrying server bootstrap/missing-files flags. |
| Source/Common/Networking/MpDisconnectReason.cs | Adds BootstrapCompleted disconnect reason. |
| Source/Common/Networking/ConnectionStateEnum.cs | Adds new client/server bootstrap connection states. |
| Source/Common/MultiplayerServer.cs | Registers ServerBootstrapState and adds BootstrapMode flag to the server instance. |
| Source/Common/LiteNetManager.cs | Changes Tick iteration to a snapshot to avoid concurrent modification issues. |
| Source/Client/Windows/ServerSettingsUI.cs | Adds reusable UI for editing networking/gameplay server settings in bootstrap flow. |
| Source/Client/Windows/HostWindow.cs | Adds a programmatic host entrypoint used by bootstrap world generation flow. |
| Source/Client/Windows/BootstrapConfiguratorWindow.cs | Adds the main bootstrap configurator window and core state handling. |
| Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs | Implements settings tabs, TOML preview, and settings upload UI/actions. |
| Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs | Implements world generation, local hosting, replay save creation, reconnect, and save.zip upload. |
| Source/Client/Session/MultiplayerSession.cs | Stores and applies server-provided bootstrap state in the session. |
| Source/Client/Session/BootstrapServerState.cs | Adds a small value type representing bootstrap mode + required uploads. |
| Source/Client/Patches/BootstrapStartedNewGamePatch.cs | Hooks new game start to coordinate bootstrap map initialization flow. |
| Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs | Periodically checks/arms bootstrap initialization when entering play state. |
| Source/Client/Patches/BootstrapRootPlayPatch.cs | Arms bootstrap initialization when Root_Play starts. |
| Source/Client/Patches/BootstrapMapInitPatch.cs | Hooks map finalization to notify the bootstrap window when initialization completes. |
| Source/Client/Networking/State/ClientJoiningState.cs | Receives bootstrap packet during join and enters bootstrap client state/UI when enabled. |
| Source/Client/Networking/State/ClientBootstrapState.cs | Adds client bootstrap state handling (bootstrap packets + disconnect UX/cleanup). |
| Source/Client/Networking/State/ClientBaseState.cs | Makes disconnect handler virtual to support bootstrap-specific override behavior. |
| Source/Client/MultiplayerStatic.cs | Registers ClientBootstrapState implementation. |
| Source/Client/Comp/BootstrapCoordinator.cs | Adds a game component to tick bootstrap save timing/logic during generation flow. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ScribeLike.provider = scribe; | ||
|
|
||
| settings.ExposeData(); | ||
|
|
||
| return scribe.ToToml(); |
There was a problem hiding this comment.
TomlSettingsCommon.Serialize overwrites the global ScribeLike.provider but never restores the previous provider. Since ServerSettingsClient.ExposeData relies on ScribeLike.provider being the Verse-backed ScribeProvider during settings load/save, calling this serializer from the bootstrap UI can leave the provider in an incompatible state for later serialization. Save the prior provider and restore it in a finally block (or otherwise scope the provider change).
| ScribeLike.provider = scribe; | |
| settings.ExposeData(); | |
| return scribe.ToToml(); | |
| var previousProvider = ScribeLike.provider; | |
| try | |
| { | |
| ScribeLike.provider = scribe; | |
| settings.ExposeData(); | |
| return scribe.ToToml(); | |
| } | |
| finally | |
| { | |
| ScribeLike.provider = previousProvider; | |
| } |
| public byte[] data = data; | ||
|
|
||
| public void Bind(PacketBuffer buf) | ||
| { | ||
| buf.BindBytes(ref data, maxLength: -1); |
There was a problem hiding this comment.
ClientBootstrapSaveDataPacket binds its payload with maxLength = -1 (unbounded). Since this packet is marked allowFragmented and is part of a file upload path, leaving it unbounded makes it easy for a malicious/buggy client to request huge allocations during deserialization. Set a sane maxLength per chunk (and ensure it matches the sender’s chunk size) to cap memory usage.
| public byte[] data = data; | |
| public void Bind(PacketBuffer buf) | |
| { | |
| buf.BindBytes(ref data, maxLength: -1); | |
| private const int MaxChunkSize = 64 * 1024; // Must match or exceed sender's upload chunk size. | |
| public byte[] data = data; | |
| public void Bind(PacketBuffer buf) | |
| { | |
| buf.BindBytes(ref data, maxLength: MaxChunkSize); |
| [TypedPacketHandler] | ||
| public void HandleSaveData(ClientBootstrapSaveDataPacket packet) | ||
| { | ||
| if (!IsConfigurator()) | ||
| return; | ||
|
|
||
| if (pendingZipBytes == null) | ||
| { | ||
| pendingZipBytes = packet.data; | ||
| } | ||
| else | ||
| { | ||
| var oldLength = pendingZipBytes.Length; | ||
| var combined = new byte[oldLength + packet.data.Length]; | ||
| Buffer.BlockCopy(pendingZipBytes, 0, combined, 0, oldLength); | ||
| Buffer.BlockCopy(packet.data, 0, combined, oldLength, packet.data.Length); | ||
| pendingZipBytes = combined; | ||
| } |
There was a problem hiding this comment.
HandleSaveData/HandleSaveEnd accumulate the uploaded save into pendingZipBytes by repeatedly allocating and copying arrays, and there’s no enforcement that the total received bytes stay within packet.length. This creates quadratic copy cost and enables memory-exhaustion (client can send arbitrarily large/extra data or even skip HandleSaveStart). Prefer a bounded upload state (require SaveStart first, track an offset, enforce an upper limit/expected length, and write to a preallocated buffer or stream directly to a temp file).
|
|
||
| ResetUploadState(); | ||
| Server.running = false; | ||
| Server.TryStop(); |
There was a problem hiding this comment.
HandleSaveEnd calls Server.TryStop() directly from the connection state handler after setting Server.running = false. The server thread already calls TryStop() when its Run() loop exits, so this can lead to concurrent Stop/Tick (and double-stop) behavior, which is likely what prompted the Tick snapshot workaround. Consider only setting running = false here (or otherwise synchronize shutdown) and let the server thread perform TryStop() exactly once.
| Server.TryStop(); |
| public void Tick() | ||
| { | ||
| foreach (var (_, man) in netManagers) man.PollEvents(); | ||
| var managersSnapshot = netManagers.ToArray(); | ||
| foreach (var (_, man) in managersSnapshot) man.PollEvents(); | ||
| } |
There was a problem hiding this comment.
LiteNetManager.Tick() now allocates a new array on every tick via netManagers.ToArray(). Since Tick() runs in the server’s hot loop, this adds steady GC pressure. If the goal is to avoid concurrent modification during shutdown, consider synchronizing access to netManagers (lock/immutable swap) or ensuring Stop() only runs after the server thread exits, instead of snapshotting every frame.
| if (bootstrap) | ||
| BootstrapMode.WaitForClient(server, CancellationToken.None); | ||
|
|
There was a problem hiding this comment.
BootstrapMode.WaitForClient() doesn’t actually wait for a client connection (it only sleeps until server.running becomes false), and calling it here blocks the main thread before the console command loop runs. That makes it impossible to issue console commands like "stop" during bootstrap, and the log message is misleading. Either remove this blocking call, or change it to wait on an actual “first client connected / bootstrap completed” signal without preventing console input.
| if (bootstrap) | |
| BootstrapMode.WaitForClient(server, CancellationToken.None); |
| [PacketDefinition(Packets.Client_BootstrapSettingsUploadStart)] | ||
| public record struct ClientBootstrapSettingsPacket(ServerSettings settings) : IPacket | ||
| { | ||
| public ServerSettings settings = settings; | ||
|
|
||
| public void Bind(PacketBuffer buf) | ||
| { | ||
| ServerSettingsPacketBinder.Bind(buf, ref settings); | ||
| } | ||
| } | ||
|
|
||
| [PacketDefinition(Packets.Client_BootstrapUploadStart)] | ||
| public record struct ClientBootstrapSaveStartPacket(string fileName, int length) : IPacket | ||
| { | ||
| public string fileName = fileName; | ||
| public int length = length; | ||
|
|
||
| public void Bind(PacketBuffer buf) | ||
| { | ||
| buf.Bind(ref fileName, maxLength: 256); | ||
| buf.Bind(ref length); | ||
| } | ||
| } | ||
|
|
||
| [PacketDefinition(Packets.Client_BootstrapUploadData, allowFragmented: true)] | ||
| public record struct ClientBootstrapSaveDataPacket(byte[] data) : IPacket | ||
| { | ||
| public byte[] data = data; | ||
|
|
||
| public void Bind(PacketBuffer buf) | ||
| { | ||
| buf.BindBytes(ref data, maxLength: -1); | ||
| } | ||
| } | ||
|
|
||
| [PacketDefinition(Packets.Client_BootstrapUploadFinish)] | ||
| public record struct ClientBootstrapSaveEndPacket(byte[] sha256Hash) : IPacket | ||
| { | ||
| public byte[] sha256Hash = sha256Hash; | ||
|
|
||
| public void Bind(PacketBuffer buf) | ||
| { | ||
| buf.BindBytes(ref sha256Hash, maxLength: 32); | ||
| } | ||
| } |
There was a problem hiding this comment.
The new bootstrap packets/states are not included in the existing packet roundtrip/snapshot tests (PacketTest.RoundtripPackets), so regressions in their wire format won’t be caught. Add representative instances of the bootstrap packets to the packet test cases and update snapshots accordingly.
| if (state.RequiresSettingsUpload) | ||
| { | ||
| step = Step.Settings; | ||
| settingsUploaded = false; | ||
| statusText = "Server settings.toml is missing. Configure it and upload it first."; | ||
| return; | ||
| } | ||
|
|
||
| step = Step.GenerateMap; | ||
|
|
||
| if (preserveTransientState && (saveReady || isUploadingSave || saveGenerationStarted || autoAdvanceArmed || AwaitingBootstrapMapInit || bootstrapSaveQueued || awaitingControllablePawns)) | ||
| return; | ||
|
|
||
| statusText = saveUploadRequestedByServer | ||
| ? "Server settings.toml already exists. Review the warning below, then create and upload save.zip." | ||
| : "Waiting for the server to request save.zip generation."; |
There was a problem hiding this comment.
This window introduces several user-facing strings as hardcoded English text (e.g. title/status messages). The surrounding UI codebase generally uses translation keys via Translate(), so these strings will not be localizable. Consider moving them to the translation system (and keeping statusText values as keys/translated strings).
Summary
This adds a standalone server bootstrap flow for fresh dedicated server setups.
Notes
Testing
After that the server have to be started and will work.