diff --git a/docs/images/visualizer-worldgen.png b/docs/images/visualizer-worldgen.png
new file mode 100644
index 00000000..d833e252
Binary files /dev/null and b/docs/images/visualizer-worldgen.png differ
diff --git a/src/DedicatedServer/App.config b/src/DedicatedServer/App.config
new file mode 100644
index 00000000..6c3273cf
--- /dev/null
+++ b/src/DedicatedServer/App.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/DedicatedServer/DedicatedServer.csproj b/src/DedicatedServer/DedicatedServer.csproj
index 6f1558ba..f0eacb9c 100644
--- a/src/DedicatedServer/DedicatedServer.csproj
+++ b/src/DedicatedServer/DedicatedServer.csproj
@@ -1,23 +1,45 @@
Exe
+ DedicatedServer.Program
DedicatedServer
ONI Multiplayer Dedicated Server
MIT License (C) ONIMP Team
-
true
+
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DedicatedServer/Game/GameLoader.cs b/src/DedicatedServer/Game/GameLoader.cs
new file mode 100644
index 00000000..088c10ff
--- /dev/null
+++ b/src/DedicatedServer/Game/GameLoader.cs
@@ -0,0 +1,747 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Runtime.InteropServices;
+using Database;
+using HarmonyLib;
+using Klei;
+using MultiplayerMod.Test.Environment.Patches;
+using MultiplayerMod.Test.Environment.Unity;
+using MultiplayerMod.Test.GameRuntime.Patches;
+using ProcGen;
+using ProcGenGame;
+using UnityEngine;
+using Path = System.IO.Path;
+using Random = System.Random;
+
+namespace DedicatedServer.Game;
+
+///
+/// Boots the ONI game world from DLLs with real WorldGen and SimDLL physics.
+///
+public class GameLoader {
+
+ ///
+ /// Path to the game's StreamingAssets directory.
+ /// Required env variable: ONI_STREAMING_ASSETS
+ ///
+ private static readonly string GameStreamingAssetsPath = GetRequiredEnvPath("ONI_STREAMING_ASSETS",
+ "Path to ONI StreamingAssets (e.g. .../OxygenNotIncluded_Data/StreamingAssets)");
+
+ private static string GetRequiredEnvPath(string envVar, string description) {
+ var path = Environment.GetEnvironmentVariable(envVar);
+ if (string.IsNullOrEmpty(path))
+ throw new InvalidOperationException($"Environment variable {envVar} is required. {description}");
+ if (!Directory.Exists(path))
+ throw new DirectoryNotFoundException($"{envVar}={path} — directory not found");
+ return path;
+ }
+
+ private Harmony harmony = null!;
+ private int width;
+ private int height;
+
+ public int Width => width;
+ public int Height => height;
+ public bool IsLoaded { get; private set; }
+ public bool SimRunning { get; private set; }
+ public int SimTick { get; private set; }
+ public ProcGenGame.GameSpawnData SpawnData { get; private set; }
+
+ // GC handles to keep pinned arrays alive for the server lifetime
+ private static GCHandle elementIdxHandle;
+ private static GCHandle temperatureHandle;
+ private static GCHandle radiationHandle;
+ private static GCHandle massHandle;
+
+ public void Boot() {
+ Console.WriteLine("[GameLoader] Installing patches...");
+
+ // Pre-set MonoMod's platform detection to avoid DeterminePlatform() hanging.
+ // On macOS under Rosetta, DeterminePlatform() spawns a process (uname) and
+ // StreamReader.ReadLine() blocks forever reading its stdout.
+ // Setting Current before any Harmony use skips the broken auto-detection.
+ try {
+ var platformHelper = typeof(Harmony).Assembly.GetType("MonoMod.Utils.PlatformHelper");
+ var currentProp = platformHelper?.GetProperty("Current", BindingFlags.Public | BindingFlags.Static);
+ if (currentProp?.GetSetMethod() != null) {
+ var platformEnum = typeof(Harmony).Assembly.GetType("MonoMod.Utils.Platform");
+ // MacOS = 73 (OS | Unix | MacOS-specific bits)
+ currentProp.SetValue(null, Enum.ToObject(platformEnum, 73));
+ Console.WriteLine("[GameLoader] Pre-set MonoMod platform to MacOS");
+ }
+ } catch (Exception ex) {
+ Console.WriteLine($"[GameLoader] Platform pre-set failed (non-fatal): {ex.Message}");
+ }
+
+ System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeof(System.Reflection.Emit.DynamicMethod).TypeHandle);
+ System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeof(System.Reflection.Emit.ILGenerator).TypeHandle);
+ harmony = new Harmony("DedicatedServer");
+ InstallPatches();
+
+ Console.WriteLine("[GameLoader] Fixing streaming assets path...");
+ FixStreamingAssetsPath();
+
+ Console.WriteLine("[GameLoader] Loading elements from game YAML...");
+ LoadElementsFromGame();
+
+ // Temporary grid size for game system init — overridden by WorldGen settings
+ width = 256;
+ height = 384;
+
+ Console.WriteLine("[GameLoader] Initializing game world...");
+ InitializeWorld();
+
+ Console.WriteLine("[GameLoader] Loading WorldGen settings...");
+ LoadWorldGenSettings();
+
+ Console.WriteLine("[GameLoader] Generating world...");
+ var cells = GenerateWorld();
+
+ if (cells == null) {
+ throw new Exception("WorldGen failed — cannot start server without a generated world");
+ }
+
+ Console.WriteLine("[GameLoader] Initializing SimDLL with WorldGen cells...");
+ InitSimDLL(cells.Value.cells, cells.Value.bgTemp, cells.Value.dc);
+
+ IsLoaded = true;
+ Console.WriteLine($"[GameLoader] World ready: {width}x{height} ({width * height} cells), SimDLL: {SimRunning}");
+ }
+
+ private void InstallPatches() {
+ // Unity patches from test assembly
+ var unityPatchTypes = typeof(UnityTestRuntime).Assembly.GetTypes()
+ .Where(type => type.Namespace?.StartsWith(typeof(UnityTestRuntime).Namespace + ".Patches") == true)
+ .ToList();
+
+ Console.WriteLine($"[GameLoader] Found {unityPatchTypes.Count} Unity patch types");
+
+ foreach (var patchType in unityPatchTypes) {
+ try {
+ Console.WriteLine($"[GameLoader] Patching {patchType.Name}...");
+ Console.Out.Flush();
+ harmony.CreateClassProcessor(patchType).Patch();
+ } catch (Exception ex) {
+ Console.WriteLine($"[GameLoader] Patch FAIL: {patchType.Name}: {ex.Message}");
+ }
+ }
+
+ // Game-specific patches
+ var gamePatches = new[] {
+ typeof(DbPatch),
+ typeof(AssetsPatch),
+ typeof(SensorsPatch),
+ typeof(ChoreConsumerStatePatch)
+ };
+ foreach (var patchType in gamePatches) {
+ try {
+ harmony.CreateClassProcessor(patchType).Patch();
+ } catch (Exception ex) {
+ Console.WriteLine($"[GameLoader] Patch FAIL: {patchType.Name}: {ex.Message}");
+ }
+ }
+ // NOTE: We intentionally skip ElementLoaderPatch — we want real FindElementByHash
+
+ // Override DebugLogHandlerPatch — it throws on LogError, which kills WorldGen
+ // (WorldGen uses Debug.Assert which logs errors for non-fatal conditions)
+ var logMethod = typeof(DebugLogHandler).GetMethod(
+ nameof(DebugLogHandler.LogFormat),
+ new[] { typeof(LogType), typeof(UnityEngine.Object), typeof(string), typeof(object[]) }
+ );
+ if (logMethod != null) {
+ harmony.Patch(logMethod,
+ prefix: new HarmonyMethod(typeof(GameLoader), nameof(SoftDebugLogHandler)) { priority = Priority.First });
+ }
+
+ // Patch ReportWorldGenError to not crash on GenericGameSettings.instance == null
+ var reportMethod = typeof(WorldGen).GetMethod("ReportWorldGenError", BindingFlags.Public | BindingFlags.Instance);
+ if (reportMethod != null) {
+ harmony.Patch(reportMethod,
+ prefix: new HarmonyMethod(typeof(GameLoader), nameof(SafeReportWorldGenError)));
+ }
+
+ // Provide GenericGameSettings._instance to prevent NPEs in WorldGen error reporting
+ var ggsField = typeof(GenericGameSettings).GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static);
+ if (ggsField != null && ggsField.GetValue(null) == null) {
+ ggsField.SetValue(null, new GenericGameSettings());
+ }
+
+ // Patch ManifestSubstanceForElement — SubstanceTable requires Unity textures,
+ // we just create a stub Substance for each element instead
+ var manifestMethod = AccessTools.Method(typeof(ElementLoader), "ManifestSubstanceForElement");
+ if (manifestMethod != null) {
+ harmony.Patch(manifestMethod,
+ prefix: new HarmonyMethod(typeof(GameLoader), nameof(StubManifestSubstance)));
+ Console.WriteLine("[GameLoader] Patched ManifestSubstanceForElement");
+ } else {
+ Console.WriteLine("[GameLoader] WARNING: ManifestSubstanceForElement not found!");
+ }
+ }
+
+ ///
+ /// Non-throwing debug log handler. The test DebugLogHandlerPatch throws on LogError,
+ /// which is fatal for WorldGen (it uses Debug.Assert for non-fatal warnings).
+ ///
+ private static bool SoftDebugLogHandler(LogType logType, string format, object[] args) {
+ var message = string.Format(format, args);
+ switch (logType) {
+ case LogType.Error:
+ Console.WriteLine($"[ERROR] {message}");
+ break;
+ case LogType.Warning:
+ Console.WriteLine($"[WARNING] {message}");
+ break;
+ default:
+ Console.WriteLine($"[INFO] {message}");
+ break;
+ }
+ return false; // skip original
+ }
+
+ ///
+ /// Fix ElementLoader.path before CollectElementsFromYAML reads it.
+ ///
+ private static void FixElementPath() {
+ var field = AccessTools.Field(typeof(ElementLoader), "path");
+ Console.WriteLine($"[FixElementPath] field={field}, current='{field?.GetValue(null)}'");
+ Console.WriteLine($"[FixElementPath] streamingAssetsPath='{Application.streamingAssetsPath}'");
+ if (field != null) {
+ var newPath = Application.streamingAssetsPath + "/elements/";
+ field.SetValue(null, newPath);
+ Console.WriteLine($"[FixElementPath] set to '{field.GetValue(null)}'");
+ }
+ }
+
+ ///
+ /// Stub substance creation — replaces ManifestSubstanceForElement which needs Unity textures.
+ ///
+ private static bool StubManifestSubstance(Element elem, ref Hashtable substanceList) {
+ if (substanceList.ContainsKey(elem.id)) {
+ elem.substance = substanceList[elem.id] as Substance;
+ } else {
+ elem.substance = new Substance();
+ elem.substance.elementID = elem.id;
+ elem.substance.renderedByWorld = elem.IsSolid;
+ elem.substance.idx = substanceList.Count;
+ elem.substance.nameTag = elem.tag;
+ elem.substance.anim = new KAnimFile { IsBuildLoaded = true };
+ substanceList[elem.id] = elem.substance;
+ }
+ return false; // skip original
+ }
+
+ ///
+ /// Safe WorldGen error reporter — logs to console instead of crashing.
+ ///
+ private static bool SafeReportWorldGenError(Exception e, string errorMessage) {
+ Console.WriteLine($"[WorldGen] ERROR: {errorMessage ?? "WorldGen failure"}");
+ Console.WriteLine($"[WorldGen] Exception: {e?.GetType().Name}: {e?.Message}");
+ if (e?.StackTrace != null)
+ Console.WriteLine($"[WorldGen] Stack: {e.StackTrace.Split('\n').FirstOrDefault()}");
+ return false; // skip original (which crashes on GenericGameSettings.instance)
+ }
+
+ ///
+ /// Override Application.streamingAssetsPath to point to the real game's StreamingAssets.
+ /// The test patches set it to "" — we use a high-priority Prefix to override.
+ ///
+ private void FixStreamingAssetsPath() {
+ var prop = typeof(Application).GetProperty("streamingAssetsPath", BindingFlags.Public | BindingFlags.Static);
+ if (prop == null) {
+ Console.WriteLine("[GameLoader] WARNING: Cannot find Application.streamingAssetsPath property");
+ return;
+ }
+
+ var getter = prop.GetGetMethod();
+ harmony.Patch(getter,
+ prefix: new HarmonyMethod(typeof(GameLoader), nameof(StreamingAssetsPathPrefix)) { priority = Priority.Last });
+
+ // Also fix dataPath (used by some subsystems)
+ var dataProp = typeof(Application).GetProperty("dataPath", BindingFlags.Public | BindingFlags.Static);
+ if (dataProp != null) {
+ harmony.Patch(dataProp.GetGetMethod(),
+ prefix: new HarmonyMethod(typeof(GameLoader), nameof(DataPathPrefix)) { priority = Priority.Last });
+ }
+
+ Console.WriteLine($"[GameLoader] streamingAssetsPath → {GameStreamingAssetsPath}");
+
+ // Fix ElementLoader's static path field (initialized at class load time with old value)
+ var pathField = typeof(ElementLoader).GetField("path", BindingFlags.NonPublic | BindingFlags.Static);
+ if (pathField != null) {
+ pathField.SetValue(null, GameStreamingAssetsPath + "/elements/");
+ Console.WriteLine($"[GameLoader] ElementLoader.path fixed");
+ }
+ }
+
+ private static bool StreamingAssetsPathPrefix(ref string __result) {
+ __result = GameStreamingAssetsPath;
+ return false; // skip original
+ }
+
+ private static bool DataPathPrefix(ref string __result) {
+ __result = Path.GetDirectoryName(GameStreamingAssetsPath) ?? GameStreamingAssetsPath;
+ return false;
+ }
+
+ ///
+ /// Load elements using the game's ElementLoader with stub SubstanceTables.
+ /// This calls the game's own YAML parsing + element creation logic.
+ ///
+ private void LoadElementsFromGame() {
+ // Provide stub SubstanceTable for each DLC — ElementLoader.Load() checks
+ // substanceTablesByDlc.ContainsKey(entry.dlcId) to decide which elements to load.
+ // Ensure FileSystem is initialized (ElementLoader.Load uses FileSystem.GetFiles)
+ Klei.FileSystem.Initialize();
+
+ var substanceList = new Hashtable();
+ var substanceTables = new Dictionary { { "", CreateStubSubstanceTable() } };
+
+ // Add DLC substance tables if DLCs are present
+ foreach (var dlcId in DlcManager.RELEASED_VERSIONS) {
+ if (!substanceTables.ContainsKey(dlcId)) {
+ substanceTables[dlcId] = CreateStubSubstanceTable();
+ }
+ }
+
+ // Patch CollectElementsFromYAML — the static `path` field reads Application.streamingAssetsPath
+ // at class load time, which may be empty. We override it via Harmony prefix.
+ var collectMethod = AccessTools.Method(typeof(ElementLoader), "CollectElementsFromYAML");
+ if (collectMethod != null) {
+ harmony.Patch(collectMethod,
+ prefix: new HarmonyMethod(typeof(GameLoader), nameof(FixElementPath)));
+ }
+
+ ElementLoader.Load(ref substanceList, substanceTables);
+
+ // Fix element names — use tag name if localization didn't resolve
+ foreach (var elem in ElementLoader.elements) {
+ if (elem.name != null && elem.name.Contains("MISSING.STRINGS")) {
+ elem.name = elem.tag.Name;
+ elem.nameUpperCase = elem.name.ToUpper();
+ }
+ if (elem.substance == null) {
+ elem.substance = new Substance {
+ nameTag = elem.tag,
+ anim = new KAnimFile { IsBuildLoaded = true }
+ };
+ }
+ }
+
+ // Build elementTagTable if not already populated
+ if (ElementLoader.elementTagTable == null || ElementLoader.elementTagTable.Count == 0) {
+ ElementLoader.elementTagTable = new Dictionary();
+ foreach (var elem in ElementLoader.elements) {
+ ElementLoader.elementTagTable[elem.tag] = elem;
+ }
+ }
+
+ WorldGen.SetupDefaultElements();
+ Console.WriteLine($"[GameLoader] Registered {ElementLoader.elements.Count} elements");
+ }
+
+ ///
+ /// Create a SubstanceTable with an initialized (empty) list.
+ /// The default ScriptableObject constructor leaves 'list' null.
+ ///
+ private static SubstanceTable CreateStubSubstanceTable() {
+ var table = ScriptableObject.CreateInstance();
+ var listField = typeof(SubstanceTable).GetField("list", BindingFlags.NonPublic | BindingFlags.Instance);
+ listField?.SetValue(table, new List());
+ return table;
+ }
+
+ private void InitializeWorld() {
+ var worldGameObject = new GameObject();
+ KObjectManager.Instance?.OnDestroy();
+ var kObjectManager = worldGameObject.AddComponent();
+ kObjectManager.Awake();
+ DistributionPlatform.sImpl = worldGameObject.AddComponent();
+
+ InitGame(worldGameObject);
+ worldGameObject.AddComponent();
+ ReportManager.Instance = worldGameObject.AddComponent();
+ ReportManager.Instance.Awake();
+ ReportManager.Instance.todaysReport = new ReportManager.DailyReport(ReportManager.Instance);
+
+ StateMachineDebuggerSettings._Instance = new StateMachineDebuggerSettings();
+ StateMachineDebuggerSettings._Instance.Initialize();
+
+ StateMachineManager.Instance.Clear();
+ StateMachine.Instance.error = false;
+
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+ World.Instance = null;
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+ PathFinder.Initialize();
+ new GameNavGrids(Pathfinding.Instance);
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+ SetupAssets(worldGameObject);
+ worldGameObject.AddComponent().Awake();
+ GameComps.InfraredVisualizers = new InfraredVisualizerComponents();
+ GameScreenManager.Instance = new GameScreenManager();
+ GameScreenManager.Instance.worldSpaceCanvas = new GameObject();
+
+ Console.WriteLine("[GameLoader] Game singletons initialized.");
+ }
+
+ private void SetupAssets(GameObject worldGameObject) {
+ worldGameObject.AddComponent().Awake();
+ worldGameObject.AddComponent().Awake();
+
+ var assets = worldGameObject.AddComponent();
+ assets.AnimAssets = new List();
+ assets.SpriteAssets = new List();
+ assets.TintedSpriteAssets = new List();
+ assets.MaterialAssets = new List();
+ assets.TextureAssets = new List();
+ assets.TextureAtlasAssets = new List();
+ assets.BlockTileDecorInfoAssets = new List();
+ Assets.ModLoadedKAnims = new List() { ScriptableObject.CreateInstance() };
+ assets.elementAudio = new TextAsset("");
+ assets.personalitiesFile = new TextAsset(
+ "Name,Gender,PersonalityType,StressTrait,JoyTrait,StickerType,CongenitalTrait," +
+ "HeadShape,Mouth,Neck,Eyes,Hair,Body,Belt,Cuff,Foot,Hand,Pelvis,Leg,Arm_Skin,Leg_Skin," +
+ "ValidStarter,Grave,Model,SpeechMouth,RequiredDlcId\n" +
+ "TestDupe,Male,Sweet,UglyCrier,BalloonArtist,,,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,testdupe,Minion,0,"
+ );
+ Assets.instance = assets;
+ AsyncLoadManager.Run();
+ }
+
+ private unsafe void InitGame(GameObject worldGameObject) {
+ new GameObject { name = "Canvas" };
+ Singleton.CreateInstance();
+
+ Global.Instance?.OnDestroy();
+ worldGameObject.AddComponent().Awake();
+
+ var game = worldGameObject.AddComponent();
+ game.maleNamesFile = new TextAsset("Bob");
+ game.femaleNamesFile = new TextAsset("Alisa");
+ try { game.assignmentManager = new AssignmentManager(); }
+ catch (ArgumentException) { /* AssignmentGroups already initialized by Db */ }
+ global::Game.Instance = game;
+ game.obj = KObjectManager.Instance.GetOrCreateObject(game.gameObject);
+
+ TuningData._TuningData = new CPUBudget.Tuning();
+
+ game.gasConduitSystem = new UtilityNetworkManager(width, height, 13);
+ game.liquidConduitSystem = new UtilityNetworkManager(width, height, 17);
+ game.electricalConduitSystem =
+ new UtilityNetworkManager(width, height, 27);
+ game.travelTubeSystem = new UtilityNetworkTubesManager(width, height, 35);
+ game.gasConduitFlow = new ConduitFlow(ConduitType.Gas, width * height, game.gasConduitSystem, 1f, 0.25f);
+ game.liquidConduitFlow = new ConduitFlow(ConduitType.Liquid, width * height, game.liquidConduitSystem, 10f, 0.75f);
+ game.mingleCellTracker = worldGameObject.AddComponent();
+
+ game.statusItemRenderer = new StatusItemRenderer();
+ game.fetchManager = new FetchManager();
+ GameScheduler.Instance = worldGameObject.AddComponent();
+
+ // Allocate temporary Grid for game init — will be replaced by WorldGen/SimDLL
+ AllocatePinnedGrid(width, height);
+
+ GameScenePartitioner.instance?.OnForcedCleanUp();
+ Console.WriteLine("[GameLoader] Grid initialized.");
+ }
+
+ ///
+ /// Load worldgen settings from the game's YAML files via SettingsCache.
+ ///
+ private void LoadWorldGenSettings() {
+ try {
+ // Clear any cached paths (they may have been set with old streamingAssetsPath)
+ var cachedPaths = typeof(SettingsCache).GetField("s_cachedPaths", BindingFlags.NonPublic | BindingFlags.Static);
+ if (cachedPaths != null) {
+ ((Dictionary)cachedPaths.GetValue(null)!).Clear();
+ }
+
+ var errors = new List();
+ SettingsCache.LoadFiles(errors);
+ if (errors.Count > 0) {
+ Console.WriteLine($"[GameLoader] WorldGen settings loaded with {errors.Count} errors:");
+ foreach (var err in errors.Take(5)) {
+ Console.WriteLine($" {err.file}: {err.text}");
+ }
+ } else {
+ Console.WriteLine("[GameLoader] WorldGen settings loaded successfully");
+ }
+ } catch (Exception ex) {
+ Console.WriteLine($"[GameLoader] Failed to load WorldGen settings: {ex.Message}");
+ Console.WriteLine($" {ex.StackTrace?.Split('\n').FirstOrDefault()}");
+ }
+ }
+
+ public struct WorldGenResult {
+ public Sim.Cell[] cells;
+ public float[] bgTemp;
+ public Sim.DiseaseCell[] dc;
+ }
+
+ ///
+ /// Generate a real ONI world using the game's WorldGen pipeline.
+ ///
+ private WorldGenResult? GenerateWorld() {
+ try {
+ var worldName = "worlds/SandstoneDefault";
+ var seed = 42;
+
+ Console.WriteLine($"[GameLoader] Creating WorldGen for {worldName}, seed {seed}...");
+ var wg = new WorldGen(worldName, new List(), new List(), false);
+
+ // Set world size in Grid
+ var worldSize = wg.Settings.world.worldsize;
+ width = worldSize.x;
+ height = worldSize.y;
+ Console.WriteLine($"[GameLoader] World size from settings: {width}x{height}");
+
+ // Re-allocate Grid for the actual world size
+ AllocatePinnedGrid(width, height);
+
+ // Reinitialize game systems that depend on world size
+ var game = global::Game.Instance;
+ if (game != null) {
+ game.gasConduitSystem = new UtilityNetworkManager(width, height, 13);
+ game.liquidConduitSystem = new UtilityNetworkManager(width, height, 17);
+ game.electricalConduitSystem = new UtilityNetworkManager(width, height, 27);
+ game.travelTubeSystem = new UtilityNetworkTubesManager(width, height, 35);
+ game.gasConduitFlow = new ConduitFlow(ConduitType.Gas, width * height, game.gasConduitSystem, 1f, 0.25f);
+ game.liquidConduitFlow = new ConduitFlow(ConduitType.Liquid, width * height, game.liquidConduitSystem, 10f, 0.75f);
+ }
+
+ // Must set world size before generation (like Cluster.BeginGeneration does)
+ wg.SetWorldSize(width, height);
+ wg.SetHiddenYOffset(wg.Settings.world.hiddenY);
+
+ wg.Initialise(
+ (key, pct, stage) => {
+ Console.WriteLine($"[WorldGen] {stage}: {pct:P0}");
+ return true;
+ },
+ error => Console.WriteLine($"[WorldGen] ERROR: {error.errorDesc}"),
+ worldSeed: seed, layoutSeed: seed, terrainSeed: seed, noiseSeed: seed,
+ skipPlacingTemplates: false
+ );
+
+ Console.WriteLine("[GameLoader] Running noise + layout generation...");
+ if (!wg.GenerateOffline()) {
+ Console.WriteLine("[GameLoader] WorldGen.GenerateOffline failed!");
+ return null;
+ }
+
+ Console.WriteLine("[GameLoader] Rendering world to cells...");
+ Sim.Cell[] cells = null;
+ Sim.DiseaseCell[] dc = null;
+ var placedStoryTraits = new List();
+
+ // RenderOffline with doSettle: true (enables template/mob spawning)
+ using var ms = new System.IO.MemoryStream();
+ using var writer = new System.IO.BinaryWriter(ms);
+
+ var result = wg.RenderOffline(
+ doSettle: true,
+ simSeed: (uint)seed,
+ writer: writer,
+ cells: ref cells,
+ dc: ref dc,
+ baseId: 0,
+ placedStoryTraits: ref placedStoryTraits,
+ isStartingWorld: true
+ );
+
+ if (!result || cells == null) {
+ Console.WriteLine("[GameLoader] WorldGen.RenderOffline failed!");
+ return null;
+ }
+
+ Console.WriteLine($"[GameLoader] WorldGen complete: {cells.Length} cells generated");
+
+ // Capture spawn data (buildings, mobs, geysers, POIs)
+ SpawnData = wg.SpawnData;
+ if (SpawnData != null) {
+ var bldg = SpawnData.buildings?.Count ?? 0;
+ var ores = SpawnData.elementalOres?.Count ?? 0;
+ var other = SpawnData.otherEntities?.Count ?? 0;
+ var picks = SpawnData.pickupables?.Count ?? 0;
+ Console.WriteLine($"[GameLoader] SpawnData: {bldg} buildings, {ores} ores, {other} entities, {picks} pickupables");
+ Console.WriteLine($"[GameLoader] Start position: {SpawnData.baseStartPos}");
+
+ // Add 3 starter duplicants near the start position
+ // Real duplicant spawning requires full Unity prefab system (NewBaseScreen.SpawnMinions)
+ // which isn't available headless. For now, add them as spawn markers.
+ var startX = SpawnData.baseStartPos.x;
+ var startY = SpawnData.baseStartPos.y;
+ for (var i = 0; i < 3; i++) {
+ SpawnData.otherEntities.Add(new TemplateClasses.Prefab(
+ "Minion", TemplateClasses.Prefab.Type.Other,
+ startX + i, startY, (SimHashes)0));
+ }
+ Console.WriteLine($"[GameLoader] Added 3 starter duplicants at ({startX},{startY})");
+ }
+
+ // Build bgTemp from cells
+ var bgTemp = new float[cells.Length];
+ for (var i = 0; i < cells.Length; i++) {
+ bgTemp[i] = cells[i].temperature;
+ }
+
+ return new WorldGenResult { cells = cells, bgTemp = bgTemp, dc = dc ?? new Sim.DiseaseCell[cells.Length] };
+ } catch (Exception ex) {
+ Console.WriteLine($"[GameLoader] WorldGen failed: {ex.GetType().Name}: {ex.Message}");
+ Console.WriteLine($" {ex.StackTrace}");
+ return null;
+ }
+ }
+
+ ///
+ /// Initialize SimDLL with the generated world cells.
+ /// After this, Grid pointers point to DLL-owned memory and physics simulation runs.
+ ///
+ private unsafe void InitSimDLL(Sim.Cell[] cells, float[] bgTemp, Sim.DiseaseCell[] dc) {
+ try {
+ Console.WriteLine("[SimDLL] Initializing...");
+ Sim.SIM_Initialize(ServerDllMessageHandler);
+ Console.WriteLine("[SimDLL] SIM_Initialize OK");
+
+ Console.WriteLine($"[SimDLL] Creating element table ({ElementLoader.elements.Count} elements)...");
+ SimMessages.CreateSimElementsTable(ElementLoader.elements);
+ Console.WriteLine("[SimDLL] Element table created");
+
+ // Create empty disease table (no diseases in headless mode)
+ Console.WriteLine("[SimDLL] Creating disease table...");
+ var diseases = new Diseases(null, statsOnly: true);
+ SimMessages.CreateDiseaseTable(diseases);
+ Console.WriteLine("[SimDLL] Disease table created");
+
+ Console.WriteLine($"[SimDLL] Initializing from cells ({width}x{height})...");
+ SimMessages.SimDataInitializeFromCells(width, height, 42, cells, bgTemp, dc, headless: true);
+ Console.WriteLine("[SimDLL] Cell data sent to SimDLL");
+
+ Console.WriteLine("[SimDLL] Starting simulation...");
+ Sim.Start();
+ Console.WriteLine("[SimDLL] Simulation started — Grid now points to DLL-owned memory");
+
+ SimRunning = true;
+ } catch (Exception ex) {
+ Console.WriteLine($"[SimDLL] Failed: {ex.GetType().Name}: {ex.Message}");
+ Console.WriteLine($" {ex.StackTrace}");
+ Console.WriteLine("[SimDLL] Falling back to pinned arrays (no physics)");
+ SimRunning = false;
+
+ // Restore pinned Grid arrays since SimDLL failed
+ AllocatePinnedGrid(width, height);
+ // Copy cell data into managed arrays
+ for (var i = 0; i < cells.Length && i < width * height; i++) {
+ Grid.elementIdx[i] = cells[i].elementIdx;
+ Grid.temperature[i] = cells[i].temperature;
+ }
+ }
+ }
+
+ ///
+ /// Tick the SimDLL physics by one step (200ms game time).
+ /// Call this in a loop to advance the simulation.
+ ///
+ public unsafe void TickSimulation() {
+ if (!SimRunning) return;
+ SimTick++;
+
+ var activeRegions = new List {
+ new() {
+ region = new Pair(
+ new Vector2I(0, 0),
+ new Vector2I(width, height)
+ )
+ }
+ };
+
+ SimMessages.NewGameFrame(0.2f, activeRegions);
+
+ var visible = new byte[Grid.CellCount];
+ for (var i = 0; i < visible.Length; i++) visible[i] = byte.MaxValue;
+
+ var ptr = Sim.HandleMessage(SimMessageHashes.PrepareGameData, visible.Length, visible);
+ if (ptr != IntPtr.Zero) {
+ var update = (Sim.GameDataUpdate*)(void*)ptr;
+ Grid.elementIdx = update->elementIdx;
+ Grid.temperature = update->temperature;
+ Grid.mass = update->mass;
+ Grid.radiation = update->radiation;
+ Grid.properties = update->properties;
+ Grid.strengthInfo = update->strengthInfo;
+ Grid.insulation = update->insulation;
+ }
+ }
+
+ ///
+ /// Allocate pinned Grid arrays that survive GC for long-running server.
+ ///
+ public static unsafe void AllocatePinnedGrid(int gridWidth, int gridHeight) {
+ var numCells = gridWidth * gridHeight;
+ GridSettings.Reset(gridWidth, gridHeight);
+
+ // Free previous handles if any
+ if (elementIdxHandle.IsAllocated) elementIdxHandle.Free();
+ if (temperatureHandle.IsAllocated) temperatureHandle.Free();
+ if (radiationHandle.IsAllocated) radiationHandle.Free();
+ if (massHandle.IsAllocated) massHandle.Free();
+
+ var elementIdxArr = new ushort[numCells];
+ elementIdxHandle = GCHandle.Alloc(elementIdxArr, GCHandleType.Pinned);
+ Grid.elementIdx = (ushort*)elementIdxHandle.AddrOfPinnedObject();
+
+ var tempArr = new float[numCells];
+ temperatureHandle = GCHandle.Alloc(tempArr, GCHandleType.Pinned);
+ Grid.temperature = (float*)temperatureHandle.AddrOfPinnedObject();
+
+ var radArr = new float[numCells];
+ radiationHandle = GCHandle.Alloc(radArr, GCHandleType.Pinned);
+ Grid.radiation = (float*)radiationHandle.AddrOfPinnedObject();
+
+ var massArr = new float[numCells];
+ massHandle = GCHandle.Alloc(massArr, GCHandleType.Pinned);
+ Grid.mass = (float*)massHandle.AddrOfPinnedObject();
+
+ Grid.InitializeCells();
+ Console.WriteLine($"[GameLoader] Pinned Grid allocated: {gridWidth}x{gridHeight}");
+ }
+
+ ///
+ /// Safe SimDLL message handler — logs to console instead of crashing via KCrashReporter.
+ ///
+ private static int ServerDllMessageHandler(int messageId, IntPtr data) {
+ Console.WriteLine($"[SimDLL] Message from DLL: id={messageId}");
+ return 0;
+ }
+
+ public void Shutdown() {
+ if (!IsLoaded) return;
+ Console.WriteLine("[GameLoader] Shutting down...");
+
+ if (SimRunning) {
+ try { Sim.SIM_Shutdown(); } catch { /* ignore */ }
+ SimRunning = false;
+ }
+
+ UnityTestRuntime.Uninstall();
+ PatchesSetup.Uninstall(harmony);
+
+ global::Game.Instance = null;
+ Global.Instance = null;
+ KObjectManager.Instance = null;
+ World.Instance = null;
+ IsLoaded = false;
+ }
+}
diff --git a/src/DedicatedServer/Game/RealWorldState.cs b/src/DedicatedServer/Game/RealWorldState.cs
new file mode 100644
index 00000000..c08fa97e
--- /dev/null
+++ b/src/DedicatedServer/Game/RealWorldState.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ProcGenGame;
+
+namespace DedicatedServer.Game;
+
+///
+/// Reads real world state from the loaded game's Grid.
+/// Works with both SimDLL-owned memory and pinned managed arrays.
+///
+public class RealWorldState {
+
+ private readonly int width;
+ private readonly int height;
+ private readonly GameLoader loader;
+
+ public RealWorldState(int width, int height, GameLoader loader) {
+ this.width = width;
+ this.height = height;
+ this.loader = loader;
+ }
+
+ public unsafe object GetWorldSnapshot() {
+ var numCells = width * height;
+
+ var cells = new object[numCells];
+ for (var i = 0; i < numCells; i++) {
+ ushort elementIdx = 0;
+ float temp = 0f;
+ float mass = 0f;
+
+ if (Grid.elementIdx != null) elementIdx = Grid.elementIdx[i];
+ if (Grid.temperature != null) temp = Grid.temperature[i];
+ if (Grid.mass != null) mass = Grid.mass[i];
+ else if (elementIdx < ElementLoader.elements?.Count) {
+ mass = ElementLoader.elements[elementIdx].defaultValues.mass;
+ }
+
+ cells[i] = new {
+ element = (int)elementIdx,
+ temperature = Math.Round(temp, 1),
+ mass = Math.Round(mass, 1)
+ };
+ }
+
+ return new {
+ width,
+ height,
+ tick = loader.SimTick,
+ cells
+ };
+ }
+
+ public object GetElements() {
+ var elements = new List