diff --git a/Assets/UI Toolkit.meta b/Assets/UI Toolkit.meta
new file mode 100644
index 000000000..2b0335e6f
--- /dev/null
+++ b/Assets/UI Toolkit.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c91eb6d541888a04ba1aa4353578cb44
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/UI Toolkit/UnityThemes.meta b/Assets/UI Toolkit/UnityThemes.meta
new file mode 100644
index 000000000..bec24d270
--- /dev/null
+++ b/Assets/UI Toolkit/UnityThemes.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: da3af149a1afd6f4e8b1f021201ad948
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss
new file mode 100644
index 000000000..1056e07ed
--- /dev/null
+++ b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss
@@ -0,0 +1 @@
+@import url("unity-theme://default");
\ No newline at end of file
diff --git a/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta
new file mode 100644
index 000000000..753a876a2
--- /dev/null
+++ b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta
@@ -0,0 +1,12 @@
+fileFormatVersion: 2
+guid: 8d8cd9ceeb854cb4292122b7adc0e718
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0}
+ disableValidation: 0
+ unsupportedSelectorAction: 0
diff --git a/Assets/_Project/Resources.meta b/Assets/_Project/Resources.meta
new file mode 100644
index 000000000..081cc2b2e
--- /dev/null
+++ b/Assets/_Project/Resources.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: a8c31026d2a6f0b4aa2c47046bcd0384
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Resources/RuntimePanelSettings.asset b/Assets/_Project/Resources/RuntimePanelSettings.asset
new file mode 100644
index 000000000..03ba579ce
--- /dev/null
+++ b/Assets/_Project/Resources/RuntimePanelSettings.asset
@@ -0,0 +1,52 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 19101, guid: 0000000000000000e000000000000000, type: 0}
+ m_Name: RuntimePanelSettings
+ m_EditorClassIdentifier: UnityEngine.dll::UnityEngine.UIElements.PanelSettings
+ themeUss: {fileID: -4733365628477956816, guid: 7234a887e0f405148ae5bd095ea5f3c4, type: 3}
+ m_DisableNoThemeWarning: 0
+ m_TargetTexture: {fileID: 0}
+ m_RenderMode: 0
+ m_ColliderUpdateMode: 0
+ m_ColliderIsTrigger: 1
+ m_ScaleMode: 1
+ m_ReferenceSpritePixelsPerUnit: 100
+ m_PixelsPerUnit: 100
+ m_Scale: 1
+ m_ReferenceDpi: 96
+ m_FallbackDpi: 96
+ m_ReferenceResolution: {x: 1200, y: 800}
+ m_ScreenMatchMode: 0
+ m_Match: 0
+ m_SortingOrder: 0
+ m_TargetDisplay: 0
+ m_BindingLogLevel: 0
+ m_ClearDepthStencil: 1
+ m_ClearColor: 0
+ m_ColorClearValue: {r: 0, g: 0, b: 0, a: 0}
+ m_VertexBudget: 0
+ m_TextureSlotCount: 8
+ m_DynamicAtlasSettings:
+ m_MinAtlasSize: 64
+ m_MaxAtlasSize: 4096
+ m_MaxSubTextureSize: 64
+ m_ActiveFilters: 31
+ m_AtlasBlitShader: {fileID: 9101, guid: 0000000000000000f000000000000000, type: 0}
+ m_DefaultShader: {fileID: 9100, guid: 0000000000000000f000000000000000, type: 0}
+ m_RuntimeGaussianBlurShader: {fileID: 20300, guid: 0000000000000000f000000000000000, type: 0}
+ m_RuntimeColorEffectShader: {fileID: 20301, guid: 0000000000000000f000000000000000, type: 0}
+ m_SDFShader: {fileID: 19011, guid: 0000000000000000f000000000000000, type: 0}
+ m_BitmapShader: {fileID: 9001, guid: 0000000000000000f000000000000000, type: 0}
+ m_SpriteShader: {fileID: 19012, guid: 0000000000000000f000000000000000, type: 0}
+ m_ICUDataAsset: {fileID: 0}
+ forceGammaRendering: 0
+ textSettings: {fileID: 0}
diff --git a/Assets/_Project/Resources/RuntimePanelSettings.asset.meta b/Assets/_Project/Resources/RuntimePanelSettings.asset.meta
new file mode 100644
index 000000000..54c07baef
--- /dev/null
+++ b/Assets/_Project/Resources/RuntimePanelSettings.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 638f245095dd4ea4a83f2349de780e00
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Resources/RuntimeTheme.tss b/Assets/_Project/Resources/RuntimeTheme.tss
new file mode 100644
index 000000000..51924cb33
--- /dev/null
+++ b/Assets/_Project/Resources/RuntimeTheme.tss
@@ -0,0 +1 @@
+@import url("unity-theme://default");
diff --git a/Assets/_Project/Resources/RuntimeTheme.tss.meta b/Assets/_Project/Resources/RuntimeTheme.tss.meta
new file mode 100644
index 000000000..22843f086
--- /dev/null
+++ b/Assets/_Project/Resources/RuntimeTheme.tss.meta
@@ -0,0 +1,12 @@
+fileFormatVersion: 2
+guid: 7234a887e0f405148ae5bd095ea5f3c4
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0}
+ disableValidation: 0
+ unsupportedSelectorAction: 0
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs
index ab05b0ec0..42ed2ba12 100644
--- a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs
@@ -47,7 +47,7 @@ namespace ProjectM.Client
_ambient.loop = true;
_ambient.playOnAwake = false;
_ambient.spatialBlend = 0f; // 2D bed
- _ambient.volume = AmbientBaseVolume;
+ _ambient.volume = AmbientBaseVolume * GameVolume.Music;
_ambient.Play();
}
@@ -75,14 +75,14 @@ namespace ProjectM.Client
// Ease the drone intensity toward the phase target (tenser during Defend).
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
- _ambient.volume = Mathf.MoveTowards(_ambient.volume, target, SystemAPI.Time.DeltaTime * 0.25f);
+ _ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
}
void PlaySting(byte phase)
{
AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild;
if (clip != null && _ambient != null)
- _ambient.PlayOneShot(clip, 0.6f);
+ _ambient.PlayOneShot(clip, 0.6f * GameVolume.Music);
}
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
diff --git a/Assets/_Project/Scripts/Client/Settings.meta b/Assets/_Project/Scripts/Client/Settings.meta
new file mode 100644
index 000000000..13d12ac87
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Settings.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6a54f970b22b42c4d95036021a8a2b2f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Client/Settings/GameSettings.cs b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs
new file mode 100644
index 000000000..a03f44d25
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs
@@ -0,0 +1,71 @@
+using System;
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Serializable client settings model (Graphics + Audio) persisted to JSON by .
+ /// Flat fields only (an enum stored as an int) so JsonUtility round-trips it without Newtonsoft.
+ /// gates forward-compatible migration. Purely client-local — never replicated (the
+ /// server has no opinion on a player's resolution or volume).
+ ///
+ [Serializable]
+ public struct GameSettings
+ {
+ public const int CurrentVersion = 1;
+
+ public int Version;
+
+ // ---- Graphics ----
+ public int ResWidth;
+ public int ResHeight;
+ public int RefreshHz; // 0 = platform default
+ public int FullScreenMode; // (int)UnityEngine.FullScreenMode (0 Exclusive,1 FullScreenWindow,2 Maximized,3 Windowed)
+ public int QualityLevel;
+ public int VSync; // QualitySettings.vSyncCount: 0/1/2
+ public int TargetFps; // Application.targetFrameRate: -1 = uncapped
+
+ // ---- Audio (0..1) ----
+ public float Master;
+ public float Music;
+ public float Sfx;
+
+ /// Sensible defaults derived from the current display + active quality level.
+ public static GameSettings Defaults()
+ {
+ var r = Screen.currentResolution;
+ return new GameSettings
+ {
+ Version = CurrentVersion,
+ ResWidth = r.width > 0 ? r.width : 1920,
+ ResHeight = r.height > 0 ? r.height : 1080,
+ RefreshHz = 0,
+ FullScreenMode = (int)UnityEngine.FullScreenMode.FullScreenWindow,
+ QualityLevel = QualitySettings.GetQualityLevel(),
+ VSync = 1,
+ TargetFps = -1,
+ Master = 1f,
+ Music = 1f,
+ Sfx = 1f,
+ };
+ }
+
+ /// Clamp every field into a safe range (defensive against hand-edited / corrupt JSON).
+ public GameSettings Clamped()
+ {
+ var s = this;
+ s.ResWidth = Mathf.Clamp(s.ResWidth <= 0 ? 1920 : s.ResWidth, 640, 7680);
+ s.ResHeight = Mathf.Clamp(s.ResHeight <= 0 ? 1080 : s.ResHeight, 480, 4320);
+ s.RefreshHz = Mathf.Max(0, s.RefreshHz);
+ s.FullScreenMode = Mathf.Clamp(s.FullScreenMode, 0, 3);
+ int qn = (QualitySettings.names != null && QualitySettings.names.Length > 0) ? QualitySettings.names.Length : 1;
+ s.QualityLevel = Mathf.Clamp(s.QualityLevel, 0, qn - 1);
+ s.VSync = Mathf.Clamp(s.VSync, 0, 2);
+ s.TargetFps = s.TargetFps <= 0 ? -1 : Mathf.Clamp(s.TargetFps, 20, 1000);
+ s.Master = Mathf.Clamp01(s.Master);
+ s.Music = Mathf.Clamp01(s.Music);
+ s.Sfx = Mathf.Clamp01(s.Sfx);
+ return s;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Settings/GameSettings.cs.meta b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs.meta
new file mode 100644
index 000000000..352b437cb
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1b7e08f285d06e4429849d6ef4d7b730
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Settings/GameVolume.cs b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs
new file mode 100644
index 000000000..403fd97db
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs
@@ -0,0 +1,33 @@
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-only audio volume BUSES, read by the procedural-audio presentation systems
+ /// ( = Music bus; +
+ /// = Sfx bus). MASTER is applied separately as
+ /// AudioListener.volume (a global listener gain) by , so the per-call
+ /// multipliers here are the per-bus trims ONLY — never multiply by master again or it double-attenuates.
+ ///
+ /// A plain static (not an IComponentData) so the Burst-free managed presentation systems read it with
+ /// zero ECS plumbing. NOT named AudioSettings — that collides with UnityEngine.AudioSettings.
+ /// Reset on play-enter () so a fast-enter-playmode
+ /// domain reload never carries a stale value into a fresh session.
+ ///
+ ///
+ public static class GameVolume
+ {
+ /// Music/ambience bus trim (0..1). Applied by AmbientAudioSystem.
+ public static float Music = 1f;
+
+ /// SFX bus trim (0..1). Applied by Combat/World feedback systems.
+ public static float Sfx = 1f;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void ResetStatics()
+ {
+ Music = 1f;
+ Sfx = 1f;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Settings/GameVolume.cs.meta b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs.meta
new file mode 100644
index 000000000..06ac679c8
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a41fe49c050b2ae4b869edd52ddb716e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Settings/SettingsService.cs b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs
new file mode 100644
index 000000000..c4145b095
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs
@@ -0,0 +1,116 @@
+using System;
+using System.IO;
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-local settings persistence + application. Loads settings.json from
+ /// Application.persistentDataPath at boot () and applies
+ /// Graphics (Screen / QualitySettings / targetFrameRate) + Audio (AudioListener.volume = Master;
+ /// Music/Sfx bus trims). The single source of truth for the UITK
+ /// SettingsScreen shared by the main menu + the in-game pause overlay. Saves are atomic
+ /// (temp file + File.Replace). JsonUtility keeps it asmdef-ref-free. Never replicated.
+ ///
+ public static class SettingsService
+ {
+ public static GameSettings Current { get; private set; } = GameSettings.Defaults();
+
+ static string FilePath => Path.Combine(Application.persistentDataPath, "settings.json");
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
+ static void Boot()
+ {
+ Load();
+ Apply(Current);
+ }
+
+ /// Read settings from disk (or defaults if absent/corrupt). Returns the loaded value.
+ public static GameSettings Load()
+ {
+ try
+ {
+ if (File.Exists(FilePath))
+ {
+ var json = File.ReadAllText(FilePath);
+ var s = JsonUtility.FromJson(json);
+ if (s.Version != GameSettings.CurrentVersion)
+ s = Migrate(s);
+ Current = s.Clamped();
+ }
+ else
+ {
+ Current = GameSettings.Defaults();
+ }
+ }
+ catch (Exception e)
+ {
+ Debug.LogWarning($"[SettingsService] Load failed ({e.Message}); using defaults.");
+ Current = GameSettings.Defaults();
+ }
+ return Current;
+ }
+
+ /// Clamp + version-stamp + atomically write to disk; updates .
+ public static void Save(GameSettings settings)
+ {
+ settings = settings.Clamped();
+ settings.Version = GameSettings.CurrentVersion;
+ Current = settings;
+ try
+ {
+ var json = JsonUtility.ToJson(settings, true);
+ var tmp = FilePath + ".tmp";
+ File.WriteAllText(tmp, json);
+ if (File.Exists(FilePath)) File.Replace(tmp, FilePath, null);
+ else File.Move(tmp, FilePath);
+ }
+ catch (Exception e)
+ {
+ Debug.LogWarning($"[SettingsService] Save failed: {e.Message}");
+ }
+ }
+
+ /// Apply settings to the live engine. Master rides AudioListener.volume; buses are per-call trims.
+ public static void Apply(GameSettings s)
+ {
+ s = s.Clamped();
+
+ // Graphics
+ QualitySettings.SetQualityLevel(s.QualityLevel, true);
+ QualitySettings.vSyncCount = s.VSync;
+ var mode = (FullScreenMode)s.FullScreenMode;
+ if (s.RefreshHz > 0)
+ Screen.SetResolution(s.ResWidth, s.ResHeight, mode, new RefreshRate { numerator = (uint)s.RefreshHz, denominator = 1 });
+ else
+ Screen.SetResolution(s.ResWidth, s.ResHeight, mode);
+ Application.targetFrameRate = s.TargetFps; // -1 = uncapped (ignored while vSync > 0)
+
+ // Audio
+ AudioListener.volume = s.Master;
+ GameVolume.Music = s.Music;
+ GameVolume.Sfx = s.Sfx;
+ }
+
+ /// Convenience for the settings UI: persist then apply.
+ public static void SaveAndApply(GameSettings s)
+ {
+ Save(s);
+ Apply(Current);
+ }
+
+ // Forward-compatible migration: fill from current defaults, preserve any recognizable old values.
+ // Additive-only as the schema grows (never throws on an unknown version).
+ static GameSettings Migrate(GameSettings old)
+ {
+ var def = GameSettings.Defaults();
+ if (old.ResWidth > 0) def.ResWidth = old.ResWidth;
+ if (old.ResHeight > 0) def.ResHeight = old.ResHeight;
+ if (old.Master > 0f) def.Master = old.Master;
+ if (old.Music > 0f) def.Music = old.Music;
+ if (old.Sfx > 0f) def.Sfx = old.Sfx;
+ def.Version = GameSettings.CurrentVersion;
+ return def;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Settings/SettingsService.cs.meta b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs.meta
new file mode 100644
index 000000000..790c8eab6
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3daf657b3863e6e4fa02aa046a1b074e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/UI.meta b/Assets/_Project/Scripts/Client/UI.meta
new file mode 100644
index 000000000..e1ab7e5d4
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ff809b44eac2a224cae4178439cf3215
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs
new file mode 100644
index 000000000..2c433a80f
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs
@@ -0,0 +1,104 @@
+using ProjectM.Simulation;
+using Unity.Entities;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Drives the front-end main menu (UI Toolkit). Lives on a GameObject (with a UIDocument) in
+ /// MainMenu.unity — build index 0. On it ENSURES a default "menu" world exists (the
+ /// bootstrap creates one on first boot, but on return-from-game World.DisposeAllWorlds left none and
+ /// Initialize does not re-run), ensures the EventSystem, and assigns the shared PanelSettings; on
+ /// enable it builds the menu. Single/Host/Join hand off to .
+ ///
+ [RequireComponent(typeof(UIDocument))]
+ public class MainMenuController : MonoBehaviour
+ {
+ UIDocument _doc;
+ VisualElement _mainPanel;
+ VisualElement _settingsPanel;
+ TextField _ipField;
+
+ void Awake()
+ {
+ EnsureMenuWorld();
+ MenuUi.EnsureEventSystem();
+ _doc = GetComponent();
+ if (_doc.panelSettings == null)
+ _doc.panelSettings = MenuUi.LoadPanelSettings();
+ // The menu owns the cursor.
+ UnityEngine.Cursor.lockState = CursorLockMode.None;
+ UnityEngine.Cursor.visible = true;
+ }
+
+ void OnEnable()
+ {
+ if (_doc == null) _doc = GetComponent();
+ var root = _doc.rootVisualElement;
+ if (root == null) return;
+ root.Clear();
+ BuildMain(root);
+ }
+
+ static void EnsureMenuWorld()
+ {
+ var w = World.DefaultGameObjectInjectionWorld;
+ if (w == null || !w.IsCreated)
+ DefaultWorldInitialization.Initialize("MenuWorld", false);
+ }
+
+ void BuildMain(VisualElement root)
+ {
+ _mainPanel = MenuUi.FullScreenRoot(true);
+ var card = MenuUi.Card("PROJECT M");
+ card.Add(MenuUi.Caption("Frontier colony — co-op"));
+
+ card.Add(MenuUi.Button("Single Player", () => Launch(SessionMode.Single, false)));
+
+ if (SaveService.HasSave())
+ card.Add(MenuUi.Button("Continue", () => Launch(SessionMode.Single, true)));
+
+ card.Add(MenuUi.Button("Host Co-op (LAN)", () => Launch(SessionMode.Host, SaveService.HasSave())));
+
+ _ipField = new TextField("Join IP") { value = "127.0.0.1" };
+ _ipField.style.marginTop = 8;
+ card.Add(_ipField);
+ card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
+
+ card.Add(MenuUi.Button("Settings", ShowSettings));
+ card.Add(MenuUi.Button("Quit", Quit));
+
+ _mainPanel.Add(card);
+ root.Add(_mainPanel);
+ }
+
+ void Launch(SessionMode mode, bool loadSave)
+ {
+ string ip = _ipField != null ? _ipField.value : "127.0.0.1";
+ WorldLauncher.StartSession(mode, ip, loadSave);
+ }
+
+ void ShowSettings()
+ {
+ _mainPanel.style.display = DisplayStyle.None;
+ _settingsPanel = SettingsScreen.Build(HideSettings);
+ _doc.rootVisualElement.Add(_settingsPanel);
+ }
+
+ void HideSettings()
+ {
+ if (_settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
+ _mainPanel.style.display = DisplayStyle.Flex;
+ }
+
+ static void Quit()
+ {
+#if UNITY_EDITOR
+ UnityEditor.EditorApplication.isPlaying = false;
+#else
+ Application.Quit();
+#endif
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs.meta b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs.meta
new file mode 100644
index 000000000..889176cd8
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bb953c16db2c778418e6c1c049c003aa
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/UI/MenuUi.cs b/Assets/_Project/Scripts/Client/UI/MenuUi.cs
new file mode 100644
index 000000000..ac319fbdd
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/MenuUi.cs
@@ -0,0 +1,118 @@
+using System;
+using UnityEngine;
+using UnityEngine.EventSystems;
+using UnityEngine.InputSystem.UI;
+using UnityEngine.UIElements;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Shared UI Toolkit helpers for the code-built menu / settings / pause screens — a small palette + styled
+ /// element factories (so every screen reads in one visual language) plus runtime plumbing: loading the
+ /// shared PanelSettings (from Resources) and ensuring an EventSystem with an
+ /// InputSystemUIInputModule exists. Runtime UITK receives pointer/click events THROUGH the
+ /// EventSystem, and this project uses the Input System (1.19.0), so the module must be the Input-System one,
+ /// not the legacy StandaloneInputModule — without it the buttons are silently dead.
+ ///
+ public static class MenuUi
+ {
+ public static readonly Color Bg = new(0.04f, 0.05f, 0.07f, 0.96f);
+ public static readonly Color PanelBg = new(0.09f, 0.11f, 0.15f, 0.98f);
+ public static readonly Color Accent = new(0.30f, 0.85f, 1f, 1f); // Aether cyan
+ public static readonly Color TextCol = new(0.86f, 0.92f, 0.97f, 1f);
+ public static readonly Color SubCol = new(0.60f, 0.68f, 0.76f, 1f);
+
+ public static PanelSettings LoadPanelSettings()
+ => Resources.Load("RuntimePanelSettings");
+
+ public static void EnsureEventSystem()
+ {
+ if (UnityEngine.Object.FindFirstObjectByType() != null) return;
+ var go = new GameObject("EventSystem");
+ go.AddComponent();
+ go.AddComponent();
+ }
+
+ public static VisualElement FullScreenRoot(bool dim)
+ {
+ var root = new VisualElement();
+ root.style.position = Position.Absolute;
+ root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
+ root.style.alignItems = Align.Center;
+ root.style.justifyContent = Justify.Center;
+ if (dim) root.style.backgroundColor = Bg;
+ return root;
+ }
+
+ public static VisualElement Card(string titleText)
+ {
+ var card = new VisualElement();
+ card.style.backgroundColor = PanelBg;
+ card.style.paddingLeft = 28; card.style.paddingRight = 28;
+ card.style.paddingTop = 22; card.style.paddingBottom = 22;
+ card.style.minWidth = 380;
+ card.style.alignItems = Align.Stretch;
+ Round(card, 10);
+ Border(card, new Color(Accent.r, Accent.g, Accent.b, 0.25f), 1);
+ if (!string.IsNullOrEmpty(titleText)) card.Add(Title(titleText));
+ return card;
+ }
+
+ public static Label Title(string text)
+ {
+ var l = new Label(text);
+ l.style.unityFontStyleAndWeight = FontStyle.Bold;
+ l.style.fontSize = 26;
+ l.style.color = Accent;
+ l.style.unityTextAlign = TextAnchor.MiddleCenter;
+ l.style.marginBottom = 16;
+ return l;
+ }
+
+ public static Label Caption(string text)
+ {
+ var l = new Label(text);
+ l.style.fontSize = 13;
+ l.style.color = SubCol;
+ l.style.unityTextAlign = TextAnchor.MiddleCenter;
+ l.style.marginTop = 8; l.style.marginBottom = 6;
+ return l;
+ }
+
+ public static Button Button(string text, Action onClick)
+ {
+ var b = new Button(onClick) { text = text };
+ b.style.height = 40;
+ b.style.fontSize = 16;
+ b.style.marginTop = 5; b.style.marginBottom = 5;
+ b.style.color = TextCol;
+ b.style.backgroundColor = new Color(0.16f, 0.20f, 0.27f, 1f);
+ b.style.unityFontStyleAndWeight = FontStyle.Bold;
+ Round(b, 6);
+ Border(b, new Color(0f, 0f, 0f, 0f), 0);
+ return b;
+ }
+
+ public static Button SmallButton(string text, Action onClick)
+ {
+ var b = Button(text, onClick);
+ b.style.width = 36; b.style.height = 30;
+ b.style.marginLeft = 4; b.style.marginRight = 4;
+ return b;
+ }
+
+ public static void Round(VisualElement e, float r)
+ {
+ e.style.borderTopLeftRadius = r; e.style.borderTopRightRadius = r;
+ e.style.borderBottomLeftRadius = r; e.style.borderBottomRightRadius = r;
+ }
+
+ public static void Border(VisualElement e, Color c, float w)
+ {
+ e.style.borderLeftWidth = w; e.style.borderRightWidth = w;
+ e.style.borderTopWidth = w; e.style.borderBottomWidth = w;
+ e.style.borderLeftColor = c; e.style.borderRightColor = c;
+ e.style.borderTopColor = c; e.style.borderBottomColor = c;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/UI/MenuUi.cs.meta b/Assets/_Project/Scripts/Client/UI/MenuUi.cs.meta
new file mode 100644
index 000000000..ef3b1acea
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/MenuUi.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f2a83d2b10895944492bb7441e8308e1
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs
new file mode 100644
index 000000000..fedff8df9
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs
@@ -0,0 +1,87 @@
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace ProjectM.Client
+{
+ ///
+ /// In-game pause overlay (UI Toolkit), spawned by in the client world. Esc
+ /// toggles it; Resume / Settings / Quit-to-Menu / Quit-to-Desktop. Quit-to-Menu hands off to
+ /// (autosave + dispose worlds + load MainMenu). Builds its own
+ /// UIDocument in code (shared PanelSettings from Resources) above the HUD; the scene swap on Quit-to-Menu
+ /// destroys it.
+ ///
+ public class PauseMenuController : MonoBehaviour
+ {
+ UIDocument _doc;
+ VisualElement _root;
+ VisualElement _pausePanel;
+ VisualElement _settingsPanel;
+ bool _open;
+ /// True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).
+ public static bool Open;
+
+ void Awake()
+ {
+ MenuUi.EnsureEventSystem();
+ _doc = gameObject.AddComponent();
+ _doc.panelSettings = MenuUi.LoadPanelSettings();
+ _doc.sortingOrder = 100; // above the in-game HUD
+ }
+
+ void Start()
+ {
+ _root = _doc.rootVisualElement;
+ Build();
+ SetOpen(false);
+ }
+
+ void Update()
+ {
+ var kb = UnityEngine.InputSystem.Keyboard.current;
+ if (kb != null && kb.escapeKey.wasPressedThisFrame)
+ SetOpen(!_open);
+ }
+
+ void Build()
+ {
+ _pausePanel = MenuUi.FullScreenRoot(false);
+ _pausePanel.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.72f);
+ var card = MenuUi.Card("PAUSED");
+ card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
+ card.Add(MenuUi.Button("Settings", ShowSettings));
+ card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
+ card.Add(MenuUi.Button("Quit to Desktop", Quit));
+ _pausePanel.Add(card);
+ _root.Add(_pausePanel);
+ }
+
+ void SetOpen(bool open)
+ {
+ _open = open;
+ Open = open;
+ if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
+ if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
+ if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
+ }
+
+ void ShowSettings()
+ {
+ _pausePanel.style.display = DisplayStyle.None;
+ _settingsPanel = SettingsScreen.Build(() =>
+ {
+ if (_settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
+ _pausePanel.style.display = DisplayStyle.Flex;
+ });
+ _root.Add(_settingsPanel);
+ }
+
+ static void Quit()
+ {
+#if UNITY_EDITOR
+ UnityEditor.EditorApplication.isPlaying = false;
+#else
+ Application.Quit();
+#endif
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs.meta b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs.meta
new file mode 100644
index 000000000..38f86dc32
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 76b9e91f569010b4aaf703ce756a4b1c
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs
new file mode 100644
index 000000000..84dde46ab
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs
@@ -0,0 +1,26 @@
+using Unity.Entities;
+using Unity.NetCode;
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-only bootstrap that ensures the in-game pause overlay (Esc) exists once a session is running.
+ /// Mirrors how HudSystem creates its UI at runtime — spawns one GameObject
+ /// in the active (Game) scene; the scene swap on Quit-to-Menu destroys it, and a fresh session's new client
+ /// world spawns a new one. Client world only, so the menu's plain default world never spawns a pause overlay.
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class PauseMenuSystem : SystemBase
+ {
+ bool _spawned;
+
+ protected override void OnUpdate()
+ {
+ if (_spawned) return;
+ _spawned = true;
+ new GameObject("~PauseMenu").AddComponent();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs.meta b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs.meta
new file mode 100644
index 000000000..01ec4eb38
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0bbe1a1c153da4c4499841a023262e9e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/UI/SessionRunner.cs b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs
new file mode 100644
index 000000000..bdbda49de
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs
@@ -0,0 +1,32 @@
+using System.Collections;
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Persistent (DontDestroyOnLoad) MonoBehaviour that hosts the world-lifecycle coroutines for
+ /// . World create/dispose + scene loads must run at a FRAME BOUNDARY (never
+ /// inside an ECS system update — that crashes), and must survive the MainMenu<->Game scene swap, so a
+ /// single long-lived runner owns them. Created lazily on first use.
+ ///
+ public class SessionRunner : MonoBehaviour
+ {
+ static SessionRunner _instance;
+
+ static SessionRunner Instance
+ {
+ get
+ {
+ if (_instance == null)
+ {
+ var go = new GameObject("~SessionRunner");
+ DontDestroyOnLoad(go);
+ _instance = go.AddComponent();
+ }
+ return _instance;
+ }
+ }
+
+ public static void Run(IEnumerator routine) => Instance.StartCoroutine(routine);
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/UI/SessionRunner.cs.meta b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs.meta
new file mode 100644
index 000000000..704f1fe45
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e2af19327c034684da1997a5478f7e31
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs
new file mode 100644
index 000000000..3dd448daa
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs
@@ -0,0 +1,128 @@
+using System;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Code-built UI Toolkit settings panel (Graphics + Audio) shared by the main menu and the in-game pause
+ /// overlay. Binds to : edits a working copy, previews audio live, and persists
+ /// + applies on Apply/Back. Enum settings use compact "< value >" cycle rows (robust, no dropdown-popup
+ /// theming); volumes use sliders with a live percentage readout.
+ ///
+ public static class SettingsScreen
+ {
+ public static VisualElement Build(Action onBack)
+ {
+ var working = SettingsService.Current;
+
+ var root = MenuUi.FullScreenRoot(true);
+ var card = MenuUi.Card("SETTINGS");
+ card.style.minWidth = 480;
+ root.Add(card);
+
+ // ---------------- Graphics ----------------
+ card.Add(MenuUi.Caption("— GRAPHICS —"));
+
+ var resolutions = Screen.resolutions;
+ int resIndex = FindResIndex(resolutions, working.ResWidth, working.ResHeight);
+ card.Add(CycleRow("Resolution",
+ () => resolutions.Length > 0 ? ResLabel(resolutions[Mathf.Clamp(resIndex, 0, resolutions.Length - 1)]) : $"{working.ResWidth}x{working.ResHeight}",
+ dir =>
+ {
+ if (resolutions.Length == 0) return;
+ resIndex = Wrap(resIndex + dir, resolutions.Length);
+ var r = resolutions[resIndex];
+ working.ResWidth = r.width;
+ working.ResHeight = r.height;
+ working.RefreshHz = Mathf.RoundToInt((float)r.refreshRateRatio.value);
+ }));
+
+ string[] modes = { "Exclusive", "Borderless", "Maximized", "Windowed" };
+ card.Add(CycleRow("Display Mode",
+ () => modes[Mathf.Clamp(working.FullScreenMode, 0, 3)],
+ dir => working.FullScreenMode = Wrap(working.FullScreenMode + dir, 4)));
+
+ var qnames = QualitySettings.names;
+ card.Add(CycleRow("Quality",
+ () => qnames.Length > 0 ? qnames[Mathf.Clamp(working.QualityLevel, 0, qnames.Length - 1)] : "—",
+ dir => { if (qnames.Length > 0) working.QualityLevel = Wrap(working.QualityLevel + dir, qnames.Length); }));
+
+ string[] vsync = { "Off", "On", "Half" };
+ card.Add(CycleRow("V-Sync",
+ () => vsync[Mathf.Clamp(working.VSync, 0, 2)],
+ dir => working.VSync = Wrap(working.VSync + dir, 3)));
+
+ // ---------------- Audio ----------------
+ card.Add(MenuUi.Caption("— AUDIO —"));
+ card.Add(VolumeRow("Master", working.Master, v => { working.Master = v; AudioListener.volume = v; }));
+ card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
+ card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
+
+ // ---------------- Buttons ----------------
+ var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
+ apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);
+ card.Add(apply);
+ card.Add(MenuUi.Button("Back", () =>
+ {
+ SettingsService.SaveAndApply(working);
+ onBack?.Invoke();
+ }));
+
+ return root;
+ }
+
+ static VisualElement CycleRow(string name, Func getValue, Action onCycle)
+ {
+ var row = Row(name);
+ var val = new Label(getValue());
+ val.style.flexGrow = 1;
+ val.style.unityTextAlign = TextAnchor.MiddleCenter;
+ val.style.color = MenuUi.TextCol;
+ val.style.fontSize = 15;
+ var dec = MenuUi.SmallButton("<", () => { onCycle(-1); val.text = getValue(); });
+ var inc = MenuUi.SmallButton(">", () => { onCycle(+1); val.text = getValue(); });
+ row.Add(dec); row.Add(val); row.Add(inc);
+ return row;
+ }
+
+ static VisualElement VolumeRow(string name, float initial, Action onChange)
+ {
+ var row = Row(name);
+ var slider = new Slider(0f, 1f) { value = initial };
+ slider.style.flexGrow = 1;
+ var pct = new Label(Pct(initial));
+ pct.style.minWidth = 52;
+ pct.style.unityTextAlign = TextAnchor.MiddleRight;
+ pct.style.color = MenuUi.TextCol;
+ slider.RegisterValueChangedCallback(evt => { onChange(evt.newValue); pct.text = Pct(evt.newValue); });
+ row.Add(slider); row.Add(pct);
+ return row;
+ }
+
+ static VisualElement Row(string name)
+ {
+ var row = new VisualElement();
+ row.style.flexDirection = FlexDirection.Row;
+ row.style.alignItems = Align.Center;
+ row.style.marginTop = 4; row.style.marginBottom = 4;
+ var label = new Label(name);
+ label.style.minWidth = 120;
+ label.style.color = MenuUi.SubCol;
+ label.style.fontSize = 14;
+ row.Add(label);
+ return row;
+ }
+
+ static string ResLabel(Resolution r) => $"{r.width} x {r.height} @ {Mathf.RoundToInt((float)r.refreshRateRatio.value)}";
+ static string Pct(float v) => Mathf.RoundToInt(v * 100f) + "%";
+ static int Wrap(int i, int n) => n <= 0 ? 0 : ((i % n) + n) % n;
+
+ static int FindResIndex(Resolution[] all, int w, int h)
+ {
+ for (int i = 0; i < all.Length; i++)
+ if (all[i].width == w && all[i].height == h) return i;
+ return Mathf.Max(0, all.Length - 1); // default to the highest available
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs.meta b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs.meta
new file mode 100644
index 000000000..2a89d15fc
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8cd9f577a41c1974796661abc14266d6
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs
new file mode 100644
index 000000000..36c554a8c
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.NetCode;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+namespace ProjectM.Client
+{
+ public enum SessionMode { Single, Host, Join }
+
+ ///
+ /// The SINGLE funnel for netcode world lifecycle driven by the menu, so a connection/handshake fix lives in
+ /// one place. creates the right worlds (Single/Host = server+client, Join =
+ /// client-only) via the public ClientServerBootstrap.Create*World helpers (which register the
+ /// ServerWorld/ClientWorld statics), seeds the existing request component,
+ /// optionally stages a save (Continue), then loads Game.unity worlds-first (the subscene-streaming
+ /// invariant). autosaves (host), disposes all worlds, and returns to MainMenu.
+ /// Every dispose/scene step runs at a frame boundary on — never in an ECS system.
+ ///
+ public static class WorldLauncher
+ {
+ public const ushort Port = 7979;
+ const string GameScene = "Game";
+ const string MenuScene = "MainMenu";
+ const string Loopback = "127.0.0.1";
+
+ public static bool Busy { get; private set; }
+
+ public static void StartSession(SessionMode mode, string joinIp, bool loadSave)
+ {
+ if (Busy) return;
+ Busy = true;
+ SessionRunner.Run(StartRoutine(mode, joinIp, loadSave));
+ }
+
+ public static void TeardownToMenu()
+ {
+ if (Busy) return;
+ Busy = true;
+ SessionRunner.Run(TeardownRoutine());
+ }
+
+ static IEnumerator StartRoutine(SessionMode mode, string joinIp, bool loadSave)
+ {
+ // Dispose the idle menu world so a netcode world can own DefaultGameObjectInjectionWorld (the
+ // subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow).
+ World.DisposeAllWorlds();
+ yield return null;
+
+ World server = null;
+ World client = ClientServerBootstrap.CreateClientWorld("ClientWorld");
+
+ if (mode == SessionMode.Join)
+ {
+ Seed(client, ConnectionMode.Join, string.IsNullOrWhiteSpace(joinIp) ? Loopback : joinIp.Trim(), Port);
+ }
+ else
+ {
+ server = ClientServerBootstrap.CreateServerWorld("ServerWorld");
+ string bind = mode == SessionMode.Single ? Loopback : "0.0.0.0"; // Single binds loopback (no firewall)
+ Seed(server, ConnectionMode.Host, bind, Port);
+ Seed(client, ConnectionMode.Join, Loopback, Port);
+ if (loadSave) StagePendingSave(server);
+ }
+
+ World.DefaultGameObjectInjectionWorld = server ?? client;
+
+ // Worlds exist -> loading Game.unity streams its SubScene into them.
+ SceneManager.LoadScene(GameScene, LoadSceneMode.Single);
+ Busy = false;
+ }
+
+ static IEnumerator TeardownRoutine()
+ {
+ var server = ClientServerBootstrap.ServerWorld;
+ if (server is { IsCreated: true })
+ TrySaveFromServer(server);
+
+ yield return null;
+ World.DisposeAllWorlds();
+ yield return null;
+
+ SceneManager.LoadScene(MenuScene, LoadSceneMode.Single);
+ Busy = false;
+ }
+
+ static void Seed(World world, ConnectionMode mode, string address, ushort port)
+ {
+ if (world is not { IsCreated: true }) return;
+ var em = world.EntityManager;
+ using var q = em.CreateEntityQuery(ComponentType.ReadWrite());
+ Entity e = q.IsEmptyIgnoreFilter ? em.CreateEntity(typeof(ConnectionConfig)) : q.GetSingletonEntity();
+ em.SetComponentData(e, new ConnectionConfig
+ {
+ Mode = mode,
+ Address = address,
+ Port = port,
+ Requested = true,
+ });
+ }
+
+ static void StagePendingSave(World server)
+ {
+ var data = SaveService.Load();
+ if (data == null) return;
+ var em = server.EntityManager;
+ var e = em.CreateEntity();
+ em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, HasData = 1 });
+ var buf = em.AddBuffer(e);
+ if (data.Ledger != null)
+ foreach (var row in data.Ledger)
+ buf.Add(new PendingSaveLedgerRow { ItemId = (ushort)row.ItemId, Count = row.Count });
+
+ // M7: stage player-built structures on a SEPARATE carrier (BaseRestoreSystem owns its lifecycle; the
+ // PendingSave entity above is consumed + destroyed by CycleDirectorSpawnSystem at director spawn).
+ if (data.Structures != null && data.Structures.Length > 0)
+ {
+ var se = em.CreateEntity();
+ var sbuf = em.AddBuffer(se);
+ foreach (var s in data.Structures)
+ sbuf.Add(new PendingStructure
+ {
+ Type = s.Type, CellX = s.CellX, CellZ = s.CellZ, Direction = s.Direction,
+ RemainingTicks = s.RemainingTicks, ConveyorResId = s.ConveyorResId, ConveyorCount = s.ConveyorCount,
+ });
+ var iobuf = em.AddBuffer(se);
+ if (data.StructureIo != null)
+ foreach (var io in data.StructureIo)
+ iobuf.Add(new PendingStructureIo { StructureIndex = io.StructureIndex, Slot = io.Slot, ResourceId = io.ResourceId, Count = io.Count });
+ }
+ }
+
+ static void TrySaveFromServer(World server)
+ {
+ try
+ {
+ var em = server.EntityManager;
+ em.CompleteAllTrackedJobs();
+ using var q = em.CreateEntityQuery(ComponentType.ReadOnly());
+ if (q.IsEmptyIgnoreFilter) return;
+ var dir = q.GetSingletonEntity();
+ var goal = em.HasComponent(dir) ? em.GetComponentData(dir) : default;
+ var buffer = em.GetBuffer(dir, true);
+ var rows = new LedgerRow[buffer.Length];
+ for (int i = 0; i < buffer.Length; i++)
+ rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
+
+ // M7: also persist player-built structures (same shared scan as the autosave path).
+ uint nowTick = 0;
+ using (var tq = em.CreateEntityQuery(ComponentType.ReadOnly()))
+ if (!tq.IsEmptyIgnoreFilter)
+ {
+ var st = tq.GetSingleton().ServerTick;
+ if (st.IsValid) nowTick = st.TickIndexForValidTick;
+ }
+ SaveStructureScan.Collect(em, nowTick, out var structures, out var structureIo);
+
+ SaveService.Save(new SaveData
+ {
+ GoalCharge = goal.Charge,
+ GoalTarget = goal.Target,
+ Ledger = rows,
+ Structures = structures,
+ StructureIo = structureIo,
+ SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ });
+ }
+ catch (Exception e)
+ {
+ Debug.LogWarning($"[WorldLauncher] Quit-to-menu save skipped: {e.Message}");
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs.meta b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs.meta
new file mode 100644
index 000000000..c6894814f
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 96a2c416d0d434a419db03355c08ffea
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/BuildTool.cs b/Assets/_Project/Scripts/Editor/BuildTool.cs
new file mode 100644
index 000000000..7cf8c833a
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/BuildTool.cs
@@ -0,0 +1,63 @@
+#if UNITY_EDITOR
+using UnityEditor;
+using UnityEditor.Build.Reporting;
+using UnityEngine;
+
+namespace ProjectM.EditorTools
+{
+ ///
+ /// One-click Windows player build + the "Boot Into Menu (Editor)" toggle that flips GameBootstrap into the
+ /// frontend path for in-editor menu testing (shared EditorPrefs key "ProjectM.BootIntoMenu"). Lives in the
+ /// special Editor folder (compiles into Assembly-CSharp-Editor) so it needs no ProjectM asmdef references.
+ ///
+ public static class BuildTool
+ {
+ const string BootPrefKey = "ProjectM.BootIntoMenu";
+ const string BootMenuItem = "ProjectM/Boot Into Menu (Editor)";
+
+ [MenuItem("ProjectM/Build/Windows Player")]
+ public static void BuildWindows()
+ {
+ string[] scenes =
+ {
+ "Assets/Scenes/MainMenu.unity",
+ "Assets/Scenes/Game.unity",
+ };
+ const string dir = "Builds/Windows";
+ System.IO.Directory.CreateDirectory(dir);
+
+ var options = new BuildPlayerOptions
+ {
+ scenes = scenes,
+ locationPathName = System.IO.Path.Combine(dir, "ProjectM.exe"),
+ target = BuildTarget.StandaloneWindows64,
+ options = BuildOptions.None,
+ };
+
+ BuildReport report = BuildPipeline.BuildPlayer(options);
+ BuildSummary s = report.summary;
+ if (s.result == BuildResult.Succeeded)
+ Debug.Log($"[BuildTool] Build succeeded: ~{s.totalSize / (1024 * 1024)} MB -> {options.locationPathName}");
+ else
+ Debug.LogError($"[BuildTool] Build {s.result}: {s.totalErrors} error(s).");
+ }
+
+ [MenuItem(BootMenuItem)]
+ public static void ToggleBootMenu()
+ {
+ bool v = !EditorPrefs.GetBool(BootPrefKey, false);
+ EditorPrefs.SetBool(BootPrefKey, v);
+ Debug.Log($"[BuildTool] Boot Into Menu (Editor) = {v}. " +
+ (v ? "Open MainMenu.unity and Play to test the menu."
+ : "Normal instant-play / MPPM loop restored."));
+ }
+
+ [MenuItem(BootMenuItem, true)]
+ public static bool ToggleBootMenuValidate()
+ {
+ Menu.SetChecked(BootMenuItem, EditorPrefs.GetBool(BootPrefKey, false));
+ return true;
+ }
+ }
+}
+#endif
diff --git a/Assets/_Project/Scripts/Editor/BuildTool.cs.meta b/Assets/_Project/Scripts/Editor/BuildTool.cs.meta
new file mode 100644
index 000000000..08be3e225
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/BuildTool.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7c4747ef10cbb424bbfa8ebd4f807fb7
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs b/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs
index e012c79a6..62847b2f8 100644
--- a/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs
+++ b/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs
@@ -29,7 +29,9 @@ namespace ProjectM.Server
cfgRef.ValueRW.Requested = false;
- var endpoint = NetworkEndpoint.AnyIpv4.WithPort(cfgRef.ValueRO.Port);
+ var endpoint = cfgRef.ValueRO.Address.ToString() == "127.0.0.1"
+ ? NetworkEndpoint.LoopbackIpv4.WithPort(cfgRef.ValueRO.Port) // single-player: bind loopback only (no firewall prompt)
+ : NetworkEndpoint.AnyIpv4.WithPort(cfgRef.ValueRO.Port); // host: accept LAN peers
var ecb = new EntityCommandBuffer(Allocator.Temp);
var req = ecb.CreateEntity();
diff --git a/Assets/_Project/Scripts/Server/Persistence.meta b/Assets/_Project/Scripts/Server/Persistence.meta
new file mode 100644
index 000000000..5e16f0c22
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Persistence.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6aa6ed6723d75a64796d926f11f63236
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs
new file mode 100644
index 000000000..db39ddc07
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs
@@ -0,0 +1,60 @@
+using System;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.NetCode;
+
+namespace ProjectM.Server
+{
+ ///
+ /// Host-only autosave writer. A managed (file IO => NO Burst) that reacts to the
+ /// flag the Bursted CyclePhaseSystem raises on the Siege->Calm checkpoint:
+ /// reads the authoritative + shared resource ledger off the CycleDirector ghost,
+ /// writes the JSON save (), then clears the flag. ServerSimulation-only, so a pure
+ /// (Join) client never writes. Deliberately carries NO [UpdateAfter(CyclePhaseSystem)] (that would risk
+ /// a sort-cycle); a one-tick-late autosave is irrelevant.
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
+ public partial class SaveWriteSystem : SystemBase
+ {
+ protected override void OnCreate()
+ {
+ RequireForUpdate();
+ RequireForUpdate();
+ }
+
+ protected override void OnUpdate()
+ {
+ var dir = SystemAPI.GetSingletonEntity();
+ var req = SystemAPI.GetComponent(dir);
+ if (req.Pending == 0)
+ return;
+
+ req.Pending = 0;
+ SystemAPI.SetComponent(dir, req);
+
+ var goal = SystemAPI.HasComponent(dir)
+ ? SystemAPI.GetComponent(dir)
+ : default;
+
+ // The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
+ var buffer = SystemAPI.GetBuffer(dir);
+ var rows = new LedgerRow[buffer.Length];
+ for (int i = 0; i < buffer.Length; i++)
+ rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
+
+ // M7: also persist player-built structures + their production tick-state / inventory (single shared scan).
+ uint nowTick = SystemAPI.GetSingleton().ServerTick.TickIndexForValidTick;
+ SaveStructureScan.Collect(EntityManager, nowTick, out var structures, out var structureIo);
+
+ SaveService.Save(new SaveData
+ {
+ GoalCharge = goal.Charge,
+ GoalTarget = goal.Target,
+ Ledger = rows,
+ Structures = structures,
+ StructureIo = structureIo,
+ SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ });
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs.meta b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs.meta
new file mode 100644
index 000000000..2145c103f
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a08738b815484f4499b9ab614698c3e6
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs
index 12a159718..464f557f4 100644
--- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs
+++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs
@@ -58,6 +58,24 @@ namespace ProjectM.Server
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
ecb.AddComponent(director, new ThreatState());
+
+ // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
+ // ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
+ if (SystemAPI.TryGetSingletonEntity(out var pendingEntity))
+ {
+ var pending = SystemAPI.GetComponent(pendingEntity);
+ if (pending.HasData != 0)
+ {
+ ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = pending.GoalTarget });
+ var srcLedger = SystemAPI.GetBuffer(pendingEntity);
+ var destLedger = ecb.SetBuffer(director);
+ SaveApply.WriteLedger(srcLedger, destLedger);
+ }
+ ecb.DestroyEntity(pendingEntity);
+ }
+
+ // Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
+ ecb.AddComponent(director, new SaveRequest { Pending = 0 });
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs
index bb4f535b5..947bdbfe3 100644
--- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs
+++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs
@@ -99,6 +99,9 @@ namespace ProjectM.Server
var goal = SystemAPI.GetComponent(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
+ // Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
+ if (SystemAPI.HasComponent(cycleEntity))
+ SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
}
}
}
diff --git a/Assets/_Project/Scripts/Simulation/GameBootstrap.cs b/Assets/_Project/Scripts/Simulation/GameBootstrap.cs
index 9783b8b26..aced85289 100644
--- a/Assets/_Project/Scripts/Simulation/GameBootstrap.cs
+++ b/Assets/_Project/Scripts/Simulation/GameBootstrap.cs
@@ -10,7 +10,7 @@ namespace ProjectM.Simulation
///
/// M4 (LAN co-op): auto-connect is DISABLED ( = 0).
/// Listening/connecting is driven explicitly via the singleton and the
- /// per-world ConnectionControlSystems — from ConnectionUI (Host / Join + IP) in player builds,
+ /// per-world ConnectionControlSystems — from the UITK frontend menu (MainMenuController → WorldLauncher, Host / Join + IP) in player builds,
/// or from the editor-only EditorAutoHostSystem, which auto-hosts on loopback and connects the
/// in-proc client plus any Multiplayer-PlayMode-Tools thin clients. Direct IP/LAN only for now; Unity
/// Relay is deferred to a later pass.
@@ -21,10 +21,34 @@ namespace ProjectM.Simulation
{
public override bool Initialize(string defaultWorldName)
{
- // No auto-connect: ConnectionConfig + the ConnectionControlSystems own listen/connect (M4).
+ // No auto-connect: the menu (or, in the editor, the auto-host system) owns listen/connect.
AutoConnectPort = 0;
- CreateDefaultClientServerWorlds();
- return true;
+
+#if UNITY_EDITOR
+ // Editor: keep today's instant-into-game + MPPM loop by DEFAULT. Only the MAIN editor
+ // (ClientAndServer) with the "Boot Into Menu (Editor)" toggle ON takes the frontend path, so MPPM
+ // virtual players (Client) never boot to the menu. Open MainMenu.unity + Play to test the menu.
+ bool bootMenu = UnityEditor.EditorPrefs.GetBool("ProjectM.BootIntoMenu", false)
+ && RequestedPlayType == PlayType.ClientAndServer;
+ if (!bootMenu)
+ {
+ CreateDefaultClientServerWorlds();
+ return true;
+ }
+ return false; // Frontend: Entities makes a single default "menu" world; MainMenuController drives sessions.
+#else
+ // Player build: a dedicated/headless server auto-hosts; everyone else boots the front-end menu.
+ if (RequestedPlayType == PlayType.Server)
+ {
+ var server = CreateServerWorld("ServerWorld");
+ World.DefaultGameObjectInjectionWorld = server;
+ var em = server.EntityManager;
+ var e = em.CreateEntity();
+ em.AddComponentData(e, new ConnectionConfig { Mode = ConnectionMode.Host, Address = "0.0.0.0", Port = 7979, Requested = true });
+ return true;
+ }
+ return false; // Frontend menu (MainMenu.unity is build index 0).
+#endif
}
}
}
diff --git a/Assets/_Project/Scripts/Simulation/Persistence.meta b/Assets/_Project/Scripts/Simulation/Persistence.meta
new file mode 100644
index 000000000..bd9b24049
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 82c8fb2f6f68a864fbbdf60b8c634439
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs
new file mode 100644
index 000000000..077e6f9e7
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs
@@ -0,0 +1,19 @@
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// Pure save-apply helpers shared by the server spawn system (born-correct load) and EditMode tests.
+ /// Burst-safe: unmanaged, non-generic, no enums (avoids the cross-assembly-generic Burst ICE class).
+ ///
+ public static class SaveApply
+ {
+ /// Replace a StorageEntry ledger buffer's contents with a staged PendingSaveLedgerRow buffer.
+ public static void WriteLedger(DynamicBuffer src, DynamicBuffer dest)
+ {
+ dest.Clear();
+ for (int i = 0; i < src.Length; i++)
+ dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count });
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs.meta
new file mode 100644
index 000000000..4410bd9f5
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b187f01051a0d9e45b30b8f1c4c1e4c1
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs
new file mode 100644
index 000000000..2f3f494dd
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs
@@ -0,0 +1,62 @@
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// Server-world, UNMANAGED bridge holding a save slice the menu staged for a "Continue" session, applied
+ /// AT SPAWN by the server CycleDirectorSpawnSystem so the director ghost is BORN correct — it never
+ /// serializes a default / empty ledger to clients (no replication flicker). The
+ /// menu creates exactly one of these (with the buffer) in the freshly
+ /// created ServerWorld BEFORE the gameplay subscene streams in; the spawn system consumes + destroys it.
+ /// Unmanaged so the Bursted spawn system reads it without a managed bridge.
+ ///
+ public struct PendingSave : IComponentData
+ {
+ public int GoalCharge;
+ public int GoalTarget;
+
+ /// 0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn.
+ public byte HasData;
+ }
+
+ /// One staged ledger row for a Continue session; copied into the director's StorageEntry buffer at spawn.
+ public struct PendingSaveLedgerRow : IBufferElementData
+ {
+ public ushort ItemId;
+ public int Count;
+ }
+ /// One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it
+ /// charge-free into the freshly-streamed base. Mirrors but as an unmanaged ECS
+ /// buffer element (staged in the ServerWorld before the subscene streams).
+ public struct PendingStructure : IBufferElementData
+ {
+ public byte Type;
+ public int CellX;
+ public int CellZ;
+ public byte Direction;
+ public uint RemainingTicks;
+ public byte ConveyorResId;
+ public int ConveyorCount;
+ }
+
+ /// One staged machine I/O row (M7), joined to the buffer by index.
+ /// Slot 0 = MachineInput, 1 = MachineOutput.
+ public struct PendingStructureIo : IBufferElementData
+ {
+ public int StructureIndex;
+ public byte Slot;
+ public byte ResourceId;
+ public int Count;
+ }
+
+
+ ///
+ /// Host-only autosave request flag on the CycleDirector entity (added at spawn). The Bursted CyclePhaseSystem
+ /// sets =1 on the Siege->Calm checkpoint; the managed SaveWriteSystem reads it, writes
+ /// the JSON save, and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop).
+ ///
+ public struct SaveRequest : IComponentData
+ {
+ public byte Pending;
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs.meta
new file mode 100644
index 000000000..3dd1e8fe4
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0ed817060ccb7b445b5f1ad094e10443
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs
new file mode 100644
index 000000000..fa816849a
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs
@@ -0,0 +1,63 @@
+using System;
+
+namespace ProjectM.Simulation
+{
+ /// One serialized ledger row (item id + count). An array FIELD of .
+ [Serializable]
+ public struct LedgerRow
+ {
+ public int ItemId;
+ public int Count;
+ }
+ ///
+ /// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production
+ /// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a
+ /// fresh session; the in-flight conveyor item (if any) rides here, while variable-length machine I/O buffers
+ /// live in the flat table keyed by index.
+ ///
+ [Serializable]
+ public struct StructureSave
+ {
+ public byte Type;
+ public int CellX;
+ public int CellZ;
+ public byte Direction; // conveyor facing (0 for non-conveyors)
+ public uint RemainingTicks; // production/cooldown ticks left at save time
+ public byte ConveyorResId; // in-flight conveyor item resource (0 = none)
+ public int ConveyorCount;
+ }
+
+ ///
+ /// One serialized machine I/O buffer row, joined to by
+ /// . A flat top-level array (JsonUtility can't nest arrays-of-arrays); Slot 0 =
+ /// MachineInput, Slot 1 = MachineOutput.
+ ///
+ [Serializable]
+ public struct StructureIoRow
+ {
+ public int StructureIndex;
+ public byte Slot;
+ public byte ResourceId;
+ public int Count;
+ }
+
+ ///
+ /// Versioned, host-authoritative save slice (the FOUNDATION): the long-arc goal charge/target + the shared
+ /// resource ledger. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array).
+ /// The schema is intentionally ADDITIVE: future fields (placed structures, threat, storage) append without
+ /// breaking old saves, gated by migration.
+ ///
+ [Serializable]
+ public class SaveData
+ {
+ public const int CurrentVersion = 2;
+
+ public int Version = CurrentVersion;
+ public int GoalCharge;
+ public int GoalTarget;
+ public LedgerRow[] Ledger = Array.Empty();
+ public StructureSave[] Structures = Array.Empty();
+ public StructureIoRow[] StructureIo = Array.Empty();
+ public long SavedAtMs;
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs.meta
new file mode 100644
index 000000000..b61c4e576
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c2f5cc5646cf9ee4b94d7cebaefb8f30
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs
new file mode 100644
index 000000000..56e11e916
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs
@@ -0,0 +1,61 @@
+using System;
+using System.IO;
+using UnityEngine;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// Host-local persistence for the game save slice () — single slot, versioned JSON at
+ /// Application.persistentDataPath/save_0.json, atomic writes (temp + File.Replace). Read by the
+ /// menu (to offer "Continue" + stage a ) and the server SaveWriteSystem (autosave).
+ /// JsonUtility keeps it dependency-free. Returns null on a missing / corrupt / version-mismatched file —
+ /// never throws to callers (a bad save degrades to "New Game", it never crashes boot).
+ ///
+ public static class SaveService
+ {
+ static string FilePath => Path.Combine(Application.persistentDataPath, "save_0.json");
+
+ public static bool HasSave() => File.Exists(FilePath);
+
+ public static SaveData Load()
+ {
+ try
+ {
+ if (!File.Exists(FilePath)) return null;
+ var data = JsonUtility.FromJson(File.ReadAllText(FilePath));
+ if (data == null || data.Version != SaveData.CurrentVersion) return null;
+ data.Ledger ??= Array.Empty();
+ return data;
+ }
+ catch (Exception e)
+ {
+ Debug.LogWarning($"[SaveService] Load failed ({e.Message}); treating as no save.");
+ return null;
+ }
+ }
+
+ public static void Save(SaveData data)
+ {
+ if (data == null) return;
+ data.Version = SaveData.CurrentVersion;
+ try
+ {
+ var json = JsonUtility.ToJson(data, true);
+ var tmp = FilePath + ".tmp";
+ File.WriteAllText(tmp, json);
+ if (File.Exists(FilePath)) File.Replace(tmp, FilePath, null);
+ else File.Move(tmp, FilePath);
+ }
+ catch (Exception e)
+ {
+ Debug.LogWarning($"[SaveService] Save failed: {e.Message}");
+ }
+ }
+
+ public static void Delete()
+ {
+ try { if (File.Exists(FilePath)) File.Delete(FilePath); }
+ catch (Exception e) { Debug.LogWarning($"[SaveService] Delete failed: {e.Message}"); }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs.meta
new file mode 100644
index 000000000..6159116a1
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4aca4b85393065f45a5c5ff87f35e428
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs
new file mode 100644
index 000000000..601228201
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs
@@ -0,0 +1,70 @@
+using System.Collections.Generic;
+using Unity.Collections;
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// Scans a server world for PLAYER-built structures ( + )
+ /// into the flat SaveData v2 arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the
+ /// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved;
+ /// anything baked into the subscene is the subscene's source of truth, not the save's). Cooldowns are stored as
+ /// REMAINING ticks (epoch-independent). Managed (List/array) — runs only on a save, never in the hot loop.
+ ///
+ public static class SaveStructureScan
+ {
+ public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io)
+ {
+ var structs = new List();
+ var ioRows = new List();
+
+ using var q = em.CreateEntityQuery(
+ ComponentType.ReadOnly(),
+ ComponentType.ReadOnly());
+ using var entities = q.ToEntityArray(Allocator.Temp);
+
+ for (int k = 0; k < entities.Length; k++)
+ {
+ var e = entities[k];
+ var ps = em.GetComponentData(e);
+ int idx = structs.Count;
+
+ var row = new StructureSave
+ {
+ Type = ps.Type,
+ CellX = ps.Cell.x,
+ CellZ = ps.Cell.y,
+ RemainingTicks = ProductionMath.RemainingTicks(ps.NextTick, nowTick),
+ };
+
+ if (em.HasComponent(e))
+ row.Direction = em.GetComponentData(e).Direction;
+
+ if (em.HasComponent(e) && em.IsComponentEnabled(e))
+ {
+ var item = em.GetComponentData(e);
+ row.ConveyorResId = item.ResourceId;
+ row.ConveyorCount = item.Count;
+ }
+
+ structs.Add(row);
+
+ if (em.HasBuffer(e))
+ {
+ var buf = em.GetBuffer(e, true);
+ for (int i = 0; i < buf.Length; i++)
+ ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 0, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
+ }
+ if (em.HasBuffer(e))
+ {
+ var buf = em.GetBuffer(e, true);
+ for (int i = 0; i < buf.Length; i++)
+ ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 1, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
+ }
+ }
+
+ structures = structs.ToArray();
+ io = ioRows.ToArray();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta
new file mode 100644
index 000000000..e4d58c1b2
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 47b137b2d90c6154d8c195f8c491f0d8
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/GameSettingsTests.cs b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs
new file mode 100644
index 000000000..e18a1c827
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs
@@ -0,0 +1,113 @@
+using NUnit.Framework;
+using ProjectM.Client;
+using UnityEngine;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Pure-logic tests for the client settings model (no ECS world): JSON round-trip via JsonUtility,
+ /// defensive clamping of hand-edited/corrupt values, and the GameVolume bus statics. Engine APIs
+ /// (Screen/QualitySettings) resolve in EditMode, so Defaults()/Clamped() are exercisable here.
+ ///
+ public class GameSettingsTests
+ {
+ static GameSettings Sample() => new GameSettings
+ {
+ Version = GameSettings.CurrentVersion,
+ ResWidth = 2560,
+ ResHeight = 1440,
+ RefreshHz = 144,
+ FullScreenMode = (int)FullScreenMode.ExclusiveFullScreen,
+ QualityLevel = 0,
+ VSync = 2,
+ TargetFps = 120,
+ Master = 0.8f,
+ Music = 0.5f,
+ Sfx = 0.3f,
+ };
+
+ [Test]
+ public void Json_RoundTrip_PreservesAllFields()
+ {
+ var src = Sample();
+ var json = JsonUtility.ToJson(src);
+ var dst = JsonUtility.FromJson(json);
+
+ Assert.AreEqual(src.Version, dst.Version);
+ Assert.AreEqual(src.ResWidth, dst.ResWidth);
+ Assert.AreEqual(src.ResHeight, dst.ResHeight);
+ Assert.AreEqual(src.RefreshHz, dst.RefreshHz);
+ Assert.AreEqual(src.FullScreenMode, dst.FullScreenMode);
+ Assert.AreEqual(src.QualityLevel, dst.QualityLevel);
+ Assert.AreEqual(src.VSync, dst.VSync);
+ Assert.AreEqual(src.TargetFps, dst.TargetFps);
+ Assert.AreEqual(src.Master, dst.Master, 1e-5f);
+ Assert.AreEqual(src.Music, dst.Music, 1e-5f);
+ Assert.AreEqual(src.Sfx, dst.Sfx, 1e-5f);
+ }
+
+ [Test]
+ public void Clamped_BoundsOutOfRangeValues()
+ {
+ var bad = new GameSettings
+ {
+ ResWidth = 99999, ResHeight = -5, RefreshHz = -10,
+ FullScreenMode = 99, QualityLevel = 9999, VSync = 7,
+ TargetFps = 5, Master = 2f, Music = -1f, Sfx = 3f,
+ };
+ var c = bad.Clamped();
+
+ Assert.LessOrEqual(c.ResWidth, 7680);
+ Assert.GreaterOrEqual(c.ResHeight, 480);
+ Assert.GreaterOrEqual(c.RefreshHz, 0);
+ Assert.That(c.FullScreenMode, Is.InRange(0, 3));
+ Assert.That(c.VSync, Is.InRange(0, 2));
+ Assert.AreEqual(1f, c.Master, 1e-5f); // 2f -> 1
+ Assert.AreEqual(0f, c.Music, 1e-5f); // -1 -> 0
+ Assert.AreEqual(1f, c.Sfx, 1e-5f); // 3f -> 1
+ Assert.That(c.QualityLevel, Is.GreaterThanOrEqualTo(0)); // clamped to a valid level index
+ }
+
+ [Test]
+ public void Clamped_NonPositiveTargetFps_BecomesUncapped()
+ {
+ Assert.AreEqual(-1, new GameSettings { TargetFps = 0 }.Clamped().TargetFps);
+ Assert.AreEqual(-1, new GameSettings { TargetFps = -50 }.Clamped().TargetFps);
+ Assert.AreEqual(120, new GameSettings { TargetFps = 120, Master = 1f }.Clamped().TargetFps);
+ }
+
+ [Test]
+ public void Defaults_AreValidAndFullVolume()
+ {
+ var d = GameSettings.Defaults();
+ Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
+ Assert.Greater(d.ResWidth, 0);
+ Assert.Greater(d.ResHeight, 0);
+ Assert.AreEqual(1f, d.Master, 1e-5f);
+ Assert.AreEqual(1f, d.Music, 1e-5f);
+ Assert.AreEqual(1f, d.Sfx, 1e-5f);
+ // Defaults survive a clamp unchanged (they are in-range by construction).
+ var c = d.Clamped();
+ Assert.AreEqual(d.Master, c.Master, 1e-5f);
+ Assert.AreEqual(d.VSync, c.VSync);
+ }
+
+ [Test]
+ public void GameVolume_BusesAreSettable_DefaultFull()
+ {
+ float prevMusic = GameVolume.Music, prevSfx = GameVolume.Sfx;
+ try
+ {
+ GameVolume.Music = 0.25f;
+ GameVolume.Sfx = 0.6f;
+ Assert.AreEqual(0.25f, GameVolume.Music, 1e-5f);
+ Assert.AreEqual(0.6f, GameVolume.Sfx, 1e-5f);
+ }
+ finally
+ {
+ GameVolume.Music = prevMusic;
+ GameVolume.Sfx = prevSfx;
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/GameSettingsTests.cs.meta b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs.meta
new file mode 100644
index 000000000..36686034e
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5a097bb2e0683f544944c8a0cc9676ff
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs
new file mode 100644
index 000000000..5ce88e404
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs
@@ -0,0 +1,107 @@
+using NUnit.Framework;
+using ProjectM.Simulation;
+using Unity.Entities;
+using UnityEngine;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe,
+ /// and the born-correct ledger apply () the server spawn system uses to
+ /// overwrite a director's StorageEntry buffer from a staged PendingSave.
+ ///
+ public class SavePersistenceTests
+ {
+ [Test]
+ public void SaveData_Json_RoundTrip_PreservesFields()
+ {
+ var data = new SaveData
+ {
+ GoalCharge = 42,
+ GoalTarget = 10,
+ Ledger = new[]
+ {
+ new LedgerRow { ItemId = 1, Count = 5 },
+ new LedgerRow { ItemId = 2, Count = 9 },
+ },
+ SavedAtMs = 1234567890123L,
+ };
+
+ var json = JsonUtility.ToJson(data);
+ var back = JsonUtility.FromJson(json);
+
+ Assert.AreEqual(SaveData.CurrentVersion, back.Version);
+ Assert.AreEqual(42, back.GoalCharge);
+ Assert.AreEqual(10, back.GoalTarget);
+ Assert.AreEqual(2, back.Ledger.Length);
+ Assert.AreEqual(1, back.Ledger[0].ItemId);
+ Assert.AreEqual(5, back.Ledger[0].Count);
+ Assert.AreEqual(2, back.Ledger[1].ItemId);
+ Assert.AreEqual(9, back.Ledger[1].Count);
+ Assert.AreEqual(1234567890123L, back.SavedAtMs);
+ }
+
+ [Test]
+ public void SaveData_EmptyJson_DoesNotThrow_And_EmptyLedgerRoundTrips()
+ {
+ Assert.DoesNotThrow(() => JsonUtility.FromJson("{}"));
+
+ var empty = new SaveData { GoalCharge = 0, GoalTarget = 10 };
+ var back = JsonUtility.FromJson(JsonUtility.ToJson(empty));
+ Assert.IsNotNull(back.Ledger);
+ Assert.AreEqual(0, back.Ledger.Length);
+ }
+
+ [Test]
+ public void SaveData_OldVersion_IsDetectable()
+ {
+ // A stale-version blob round-trips with its Version intact, so SaveService.Load rejects it (-> New Game).
+ var old = new SaveData { Version = 0, GoalCharge = 7 };
+ var back = JsonUtility.FromJson(JsonUtility.ToJson(old));
+ Assert.AreEqual(0, back.Version);
+ Assert.AreNotEqual(SaveData.CurrentVersion, back.Version);
+ }
+
+ [Test]
+ public void WriteLedger_Overwrites_Destination_From_Staged_Rows()
+ {
+ using var world = new World("SaveApplyTest");
+ var em = world.EntityManager;
+ var e = em.CreateEntity();
+ em.AddBuffer(e);
+ em.AddBuffer(e);
+
+ var src = em.GetBuffer(e);
+ src.Add(new PendingSaveLedgerRow { ItemId = 3, Count = 7 });
+ src.Add(new PendingSaveLedgerRow { ItemId = 5, Count = 12 });
+
+ var dest = em.GetBuffer(e);
+ dest.Add(new StorageEntry { ItemId = 99, Count = 1 }); // pre-existing junk that must be cleared
+
+ SaveApply.WriteLedger(em.GetBuffer(e), em.GetBuffer(e));
+
+ var result = em.GetBuffer(e);
+ Assert.AreEqual(2, result.Length);
+ Assert.AreEqual(3, result[0].ItemId);
+ Assert.AreEqual(7, result[0].Count);
+ Assert.AreEqual(5, result[1].ItemId);
+ Assert.AreEqual(12, result[1].Count);
+ }
+
+ [Test]
+ public void WriteLedger_EmptySource_ClearsDestination()
+ {
+ using var world = new World("SaveApplyEmptyTest");
+ var em = world.EntityManager;
+ var e = em.CreateEntity();
+ em.AddBuffer(e);
+ em.AddBuffer(e);
+ var dest = em.GetBuffer(e);
+ dest.Add(new StorageEntry { ItemId = 1, Count = 1 });
+
+ SaveApply.WriteLedger(em.GetBuffer(e), em.GetBuffer(e));
+
+ Assert.AreEqual(0, em.GetBuffer(e).Length);
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs.meta b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs.meta
new file mode 100644
index 000000000..8e5cd822a
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f637165d4b6be6940b81f5832204e89d
\ No newline at end of file