Files
gregCore/src/Compatibility/DataCenterModLoader/MultiplayerBridge.cs
T
Marvin 8634792e2b
gregCore CI / build (push) Has been cancelled
feat(CableRemoval): Implement mass cable removal feature with user hints and settings
feat(CommonShop): Add custom shop item registration and checkout handling
feat(CommonShop): Create CustomShopItem class for better item management
feat(CommonShop): Develop ShopAPI for injecting custom items into the shop
feat(FasterSFP): Introduce faster SFP modules with custom speeds and prices
feat(NoMoreEOL): Add patch to control visibility of error warning signs
feat(QoL): Implement various quality of life improvements including shop layout fixes and item deletion
refactor(Sdk): Create legacy event dispatcher for backward compatibility
2026-04-23 21:05:49 +02:00

2151 lines
85 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using MelonLoader;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using Il2Cpp;
using Il2CppTMPro;
using Il2CppUMA;
using Il2CppUMA.CharacterSystem;
using UnityEngine.AI;
namespace DataCenterModLoader;
/// <summary>
/// Manages the multiplayer bridge between C# (MelonLoader) and the Rust DLL (dc_multiplayer.dll).
/// Handles relay-based networking, UI panel, and main menu button injection.
/// </summary>
using UnityEngine.SceneManagement;
public class MultiplayerBridge
{
[DllImport("kernel32.dll")]
private static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("kernel32.dll")]
private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
// ═══════════════════════════════════════════════════════════════════════
// FFI Delegates (dc_multiplayer.dll exports)
// ═══════════════════════════════════════════════════════════════════════
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpIsConnectedDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpIsRelayActiveDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpGetPlayerCountDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate ulong MpGetMySteamIdDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int MpHostDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int MpConnectDelegate(IntPtr roomCode, uint roomCodeLen);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int MpDisconnectDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate IntPtr MpGetRoomCodeDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpShouldSendSaveDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int MpSendSaveDataDelegate(IntPtr data, uint len);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpHasPendingSaveDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpGetSaveDataSizeDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpGetSaveDataDelegate(IntPtr buf, uint maxLen);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int MpSaveLoadCompleteDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MpSkipNextSaveRequestDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MpSetLocalSaveHashDelegate(IntPtr data, uint len);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate float MpGetSaveTransferProgressDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpGetSaveTransferTotalBytesDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpIsSaveUpToDateDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint MpGetJoinStateDelegate();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MpSetJoinStateDelegate(uint state);
private readonly MelonLogger.Instance _logger;
private MpIsConnectedDelegate _isConnected = null!;
private MpIsRelayActiveDelegate? _isRelayActive;
private MpGetPlayerCountDelegate? _getPlayerCount;
private MpGetMySteamIdDelegate? _getMySteamId;
private MpHostDelegate? _host;
private MpConnectDelegate? _connect;
private MpDisconnectDelegate? _disconnect;
private MpGetRoomCodeDelegate? _getRoomCode;
private MpShouldSendSaveDelegate? _shouldSendSave;
private MpSendSaveDataDelegate? _sendSaveData;
private MpHasPendingSaveDelegate? _hasPendingSave;
private MpGetSaveDataSizeDelegate? _getSaveDataSize;
private MpGetSaveDataDelegate? _getSaveData;
private MpSaveLoadCompleteDelegate? _saveLoadComplete;
private MpSkipNextSaveRequestDelegate? _skipNextSaveRequest;
private MpSetLocalSaveHashDelegate? _setLocalSaveHash;
private MpGetSaveTransferProgressDelegate? _getSaveTransferProgress;
private MpGetSaveTransferTotalBytesDelegate? _getSaveTransferTotalBytes;
private MpIsSaveUpToDateDelegate? _isSaveUpToDate;
private MpGetJoinStateDelegate? _getJoinState;
private MpSetJoinStateDelegate? _setJoinState;
private bool _initialized = false;
private float _initTimer = 0f;
private bool _isHosting = false;
private bool _isConnectedState = false;
private string? _discoveredSavePath = null;
// Join state constants (must match Rust dc_multiplayer JOIN_* constants)
private const uint JOIN_IDLE = 0;
private const uint JOIN_WAITING_FOR_SAVE = 1;
private const uint JOIN_SAVE_READY = 2;
private const uint JOIN_SAVE_UP_TO_DATE = 3;
private const uint JOIN_LOADING_SCENE = 4;
private const uint JOIN_LOADED = 5;
private string _currentSceneName = "";
private byte[]? _pendingSaveBytes = null;
private string? _pendingSaveName = null; // save name (without extension) written to disk
private string? _pendingSaveFullPath = null; // full path to the written save file
private float _deferredLoadDelay = 0f; // small delay after scene load before triggering Load()
private string? _reconnectRoomCode = null; // room code to auto-reconnect after scene transition
private bool _skipSaveOnReconnect = false; // skip save processing when reconnecting after load
private float _reconnectCooldown = 0f; // cooldown to prevent rapid-fire reconnect attempts
private bool _gameHandledSaveLoad = false; // true when MainMenu.Continue() handles the load
// Host: cached save bytes so multiple client joins don't re-trigger SaveGame()
private byte[]? _cachedSaveData = null;
private float _cachedSaveAge = 0f;
private const float SAVE_CACHE_LIFETIME = 30f; // seconds before cache expires
// ═══════════════════════════════════════════════════════════════════════
// Fields: Relay / Room Code
// ═══════════════════════════════════════════════════════════════════════
private string _roomCode = ""; // room code for joining
private string _displayRoomCode = ""; // room code received after hosting
private bool _showPanel;
private UnityEngine.EventSystems.EventSystem? _mpDisabledEventSystem;
private int _mpReenableCountdown;
private bool _pendingMenuInjection;
private float _menuInjectionTimer;
private GameObject? _menuButton;
private Rect _panelRect;
private bool _stylesInitialized;
private GUIStyle _windowStyle = null!, _buttonStyle = null!, _labelStyle = null!, _textFieldStyle = null!, _titleStyle = null!, _statusStyle = null!, _stopHostButtonStyle = null!, _fieldFocusedStyle = null!;
private Texture2D _windowBg = null!, _buttonBg = null!, _buttonHoverBg = null!, _fieldBg = null!, _stopBtnBg = null!, _stopBtnHoverBg = null!, _fieldActiveBg = null!;
// Custom text field state (GUI.TextField doesn't work with new Input System)
private bool _roomCodeFieldFocused;
private float _cursorBlinkTimer;
private bool _cursorVisible = true;
private float _keyRepeatTimer;
private Key _lastHeldKey = Key.None;
private const float KEY_REPEAT_DELAY = 0.4f;
private const float KEY_REPEAT_RATE = 0.05f;
public MultiplayerBridge(MelonLogger.Instance logger)
{
_logger = logger;
}
public bool TryInitialize()
{
if (_initialized) return true;
// Match how Windows registers the module (with or without .dll).
var handle = GetModuleHandle("dc_multiplayer.dll");
if (handle == IntPtr.Zero)
handle = GetModuleHandle("dc_multiplayer");
if (handle == IntPtr.Zero) return false;
var isConnectedPtr = GetProcAddress(handle, "mp_is_connected");
var isRelayActivePtr = GetProcAddress(handle, "mp_is_relay_active");
var playerCountPtr = GetProcAddress(handle, "mp_get_player_count");
var steamIdPtr = GetProcAddress(handle, "mp_get_my_steam_id");
var hostPtr = GetProcAddress(handle, "mp_host");
var connectPtr = GetProcAddress(handle, "mp_connect");
var disconnectPtr = GetProcAddress(handle, "mp_disconnect");
var roomCodePtr = GetProcAddress(handle, "mp_get_room_code");
var shouldSendSavePtr = GetProcAddress(handle, "mp_should_send_save");
var sendSaveDataPtr = GetProcAddress(handle, "mp_send_save_data");
var hasPendingSavePtr = GetProcAddress(handle, "mp_has_pending_save");
var getSaveDataSizePtr = GetProcAddress(handle, "mp_get_save_data_size");
var getSaveDataPtr = GetProcAddress(handle, "mp_get_save_data");
var saveLoadCompletePtr = GetProcAddress(handle, "mp_save_load_complete");
if (isConnectedPtr == IntPtr.Zero) return false;
_isConnected = Marshal.GetDelegateForFunctionPointer<MpIsConnectedDelegate>(isConnectedPtr);
_isRelayActive = isRelayActivePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpIsRelayActiveDelegate>(isRelayActivePtr) : null;
_getPlayerCount = playerCountPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpGetPlayerCountDelegate>(playerCountPtr) : null;
_getMySteamId = steamIdPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpGetMySteamIdDelegate>(steamIdPtr) : null;
_host = hostPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpHostDelegate>(hostPtr) : null;
_connect = connectPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpConnectDelegate>(connectPtr) : null;
_disconnect = disconnectPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpDisconnectDelegate>(disconnectPtr) : null;
_getRoomCode = roomCodePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpGetRoomCodeDelegate>(roomCodePtr) : null;
_shouldSendSave = shouldSendSavePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpShouldSendSaveDelegate>(shouldSendSavePtr) : null;
_sendSaveData = sendSaveDataPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpSendSaveDataDelegate>(sendSaveDataPtr) : null;
_hasPendingSave = hasPendingSavePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpHasPendingSaveDelegate>(hasPendingSavePtr) : null;
_getSaveDataSize = getSaveDataSizePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpGetSaveDataSizeDelegate>(getSaveDataSizePtr) : null;
_getSaveData = getSaveDataPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpGetSaveDataDelegate>(getSaveDataPtr) : null;
_saveLoadComplete = saveLoadCompletePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer<MpSaveLoadCompleteDelegate>(saveLoadCompletePtr) : null;
// Optional: may not exist in older DLLs — fail gracefully
var skipNextSaveRequestPtr = GetProcAddress(handle, "mp_skip_next_save_request");
_skipNextSaveRequest = skipNextSaveRequestPtr != IntPtr.Zero
? Marshal.GetDelegateForFunctionPointer<MpSkipNextSaveRequestDelegate>(skipNextSaveRequestPtr)
: null;
var setLocalSaveHashPtr = GetProcAddress(handle, "mp_set_local_save_hash");
_setLocalSaveHash = setLocalSaveHashPtr != IntPtr.Zero
? Marshal.GetDelegateForFunctionPointer<MpSetLocalSaveHashDelegate>(setLocalSaveHashPtr)
: null;
var getSaveTransferProgressPtr = GetProcAddress(handle, "mp_get_save_transfer_progress");
_getSaveTransferProgress = getSaveTransferProgressPtr != IntPtr.Zero
? Marshal.GetDelegateForFunctionPointer<MpGetSaveTransferProgressDelegate>(getSaveTransferProgressPtr)
: null;
var getSaveTransferTotalBytesPtr = GetProcAddress(handle, "mp_get_save_transfer_total_bytes");
_getSaveTransferTotalBytes = getSaveTransferTotalBytesPtr != IntPtr.Zero
? Marshal.GetDelegateForFunctionPointer<MpGetSaveTransferTotalBytesDelegate>(getSaveTransferTotalBytesPtr)
: null;
var isSaveUpToDatePtr = GetProcAddress(handle, "mp_is_save_up_to_date");
_isSaveUpToDate = isSaveUpToDatePtr != IntPtr.Zero
? Marshal.GetDelegateForFunctionPointer<MpIsSaveUpToDateDelegate>(isSaveUpToDatePtr)
: null;
var getJoinStatePtr = GetProcAddress(handle, "mp_get_join_state");
_getJoinState = getJoinStatePtr != IntPtr.Zero
? Marshal.GetDelegateForFunctionPointer<MpGetJoinStateDelegate>(getJoinStatePtr)
: null;
var setJoinStatePtr = GetProcAddress(handle, "mp_set_join_state");
_setJoinState = setJoinStatePtr != IntPtr.Zero
? Marshal.GetDelegateForFunctionPointer<MpSetJoinStateDelegate>(setJoinStatePtr)
: null;
_initialized = true;
_logger.Msg("[MP Bridge] dc_multiplayer detected, bridge active.");
_logger.Msg("[MP Bridge] Keybinds: F9=Host, F10=Multiplayer Panel, F11=Disconnect");
return true;
}
public void OnUpdate(float dt)
{
if (_mpReenableCountdown > 0)
{
_mpReenableCountdown--;
if (_mpReenableCountdown <= 0 && _mpDisabledEventSystem != null)
{
_mpDisabledEventSystem.enabled = true;
_mpDisabledEventSystem = null;
}
}
if (_reconnectCooldown > 0f) _reconnectCooldown -= dt;
if (_pendingMenuInjection)
{
_menuInjectionTimer -= dt;
if (_menuInjectionTimer <= 0f)
{
_pendingMenuInjection = false;
if (_initialized)
InjectMainMenuButton();
}
}
// --- Retry DLL detection until initialized ---
if (!_initialized)
{
_initTimer += dt;
if (_initTimer >= 2f)
{
_initTimer = 0f;
if (TryInitialize())
{
CrashLog.Log("[MP Bridge] dc_multiplayer.dll detected and initialized.");
if (_currentSceneName == "MainMenu" && _menuButton == null)
{
InjectMainMenuButton();
}
}
else
{
CrashLog.Log("[MP Bridge] dc_multiplayer.dll not found yet, will retry...");
}
}
// Give feedback if the user presses keybinds before DLL is loaded
var kb = Keyboard.current;
if (kb != null && (kb.f9Key.wasPressedThisFrame || kb.f10Key.wasPressedThisFrame || kb.f11Key.wasPressedThisFrame))
{
_logger.Warning("[MP Bridge] dc_multiplayer.dll is not loaded — multiplayer keybinds (F9/F10/F11) are unavailable.");
_logger.Warning("[MP Bridge] Make sure dc_multiplayer.dll is in your Mods/native folder and has been loaded.");
try
{
var ui = StaticUIElements.instance;
if (ui != null)
ui.AddMeesageInField("Multiplayer: dc_multiplayer.dll not loaded! Check Mods/native folder.");
}
catch { }
}
return;
}
// --- Main update loop (only when initialized) ---
try
{
HandleKeybinds();
// Check for room code when hosting and we don't have one yet
if (_isHosting && string.IsNullOrEmpty(_displayRoomCode) && _getRoomCode != null)
{
IntPtr codePtr = _getRoomCode();
if (codePtr != IntPtr.Zero)
{
string? code = Marshal.PtrToStringAnsi(codePtr);
if (!string.IsNullOrEmpty(code))
{
_displayRoomCode = code;
CrashLog.Log($"[MP Bridge] Room code: {_displayRoomCode}");
_logger.Msg($"[MP Bridge] Room code: {_displayRoomCode}");
try
{
var ui = StaticUIElements.instance;
if (ui != null) ui.AddMeesageInField($"Multiplayer: Room code: {_displayRoomCode}");
}
catch { }
}
}
}
bool connected = _isConnected() != 0;
// Log state transitions and show in-game notifications
if (connected && !_isConnectedState)
{
_isConnectedState = true;
_logger.Msg("[MP Bridge] Connected! Remote players will now be rendered.");
try
{
uint playerCount = _getPlayerCount != null ? _getPlayerCount() : 0;
var ui = StaticUIElements.instance;
if (ui != null)
{
if (_isHosting)
ui.AddMeesageInField($"Multiplayer: A player connected! ({playerCount} player(s) in session)");
else
ui.AddMeesageInField("Multiplayer: Connected to host!");
}
}
catch { }
}
else if (!connected && _isConnectedState)
{
_isConnectedState = false;
_logger.Msg("[MP Bridge] Disconnected.");
try
{
var ui = StaticUIElements.instance;
if (ui != null)
ui.AddMeesageInField("Multiplayer: Player disconnected.");
}
catch { }
}
bool relayAlive = _isRelayActive != null ? _isRelayActive() != 0 : connected;
if (!relayAlive && (_isHosting || _isConnectedState))
{
// Only reset once on transition
if (_isHosting)
{
_isHosting = false;
_displayRoomCode = "";
_logger.Msg("[MP Bridge] Relay disconnected while hosting, state reset.");
}
if (_isConnectedState)
{
_isConnectedState = false;
_logger.Msg("[MP Bridge] Relay disconnected while connected, state reset.");
}
}
if (!connected)
{
CleanupAll();
return;
}
// Save sync: Host sends save when requested
if (_isHosting && _shouldSendSave != null && _shouldSendSave() != 0)
{
SendSaveToClients();
}
if (_isHosting && _cachedSaveData != null)
{
_cachedSaveAge += dt;
if (_cachedSaveAge >= SAVE_CACHE_LIFETIME)
{
_cachedSaveData = null;
_cachedSaveAge = 0f;
CrashLog.Log("[MP Save] Save cache expired");
}
}
if (!_isHosting)
{
uint joinState = GetJoinState();
switch (joinState)
{
case JOIN_WAITING_FOR_SAVE:
// Rust automatically transitions to SaveReady or SaveUpToDate.
// Nothing to do here — just wait.
break;
case JOIN_SAVE_READY:
CrashLog.Log("[MP Join] Save data ready from Rust — fetching and processing");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Received save from host, loading..."); } catch { }
FetchAndProcessSave();
break;
case JOIN_SAVE_UP_TO_DATE:
{
CrashLog.Log("[MP Join] Save is up to date — no download needed!");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Save is up to date!"); } catch { }
string? savePath = DiscoverSaveFile();
if (savePath != null)
{
_pendingSaveName = Path.GetFileNameWithoutExtension(savePath);
_pendingSaveFullPath = savePath;
if (IsInMainMenu())
{
CrashLog.Log("[MP Join] In MainMenu with up-to-date save — initiating scene transition");
InitiateSceneTransition();
}
else
{
CrashLog.Log("[MP Join] Ingame with up-to-date save transitioning to Loaded");
SetJoinState(JOIN_LOADED);
_logger.Msg("[MP Join] Save up to date, already ingame!");
}
}
else
{
CrashLog.Log("[MP Join] Save up to date but couldn't find local file staying in state");
}
break;
}
case JOIN_LOADING_SCENE:
if (_deferredLoadDelay > 0f)
{
_deferredLoadDelay -= dt;
}
else if (!IsInMainMenu())
{
if (_gameHandledSaveLoad)
{
CrashLog.Log("[MP Join] Game scene loaded via MainMenu.Continue() — transitioning to Loaded");
SetJoinState(JOIN_LOADED);
_gameHandledSaveLoad = false;
_pendingSaveBytes = null;
_pendingSaveName = null;
_logger.Msg("[MP Join] Save loaded from host (via game Continue)!");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Save loaded from host!"); } catch { }
}
else
{
CrashLog.Log("[MP Join] Game scene detected after deferred wait, attempting load...");
AttemptSaveLoad();
}
}
break;
case JOIN_LOADED:
if (_hasPendingSave != null && _hasPendingSave() != 0)
{
CrashLog.Log("[MP Join] Discarding late save data (already loaded)");
if (_saveLoadComplete != null) _saveLoadComplete();
}
if (_reconnectRoomCode != null && !relayAlive && _reconnectCooldown <= 0f)
{
_reconnectCooldown = 5f;
CrashLog.Log($"[MP Join] Relay not alive in Loaded state — auto-reconnecting to {_reconnectRoomCode}");
AutoReconnect();
}
break;
case JOIN_IDLE:
default:
break;
}
}
}
catch (Exception ex)
{
CrashLog.LogException("MultiplayerBridge.OnUpdate", ex);
}
}
private uint GetJoinState()
{
if (_getJoinState != null) return _getJoinState();
return JOIN_IDLE;
}
private void SetJoinState(uint state)
{
if (_setJoinState != null) _setJoinState(state);
}
public void OnSceneLoaded(string sceneName)
{
_currentSceneName = sceneName ?? "";
CrashLog.Log($"[MP Join] OnSceneLoaded: \"{_currentSceneName}\" (joinState={GetJoinState()})");
if (sceneName == "MainMenu" && _initialized)
{
_pendingMenuInjection = true;
_menuInjectionTimer = 0.5f;
}
else
{
_menuButton = null;
if (GetJoinState() == JOIN_LOADING_SCENE)
{
if (_gameHandledSaveLoad)
{
CrashLog.Log($"[MP Join] Game scene \"{sceneName}\" loaded (via Continue) — waiting for initialization");
_deferredLoadDelay = 2.0f;
}
else if (_pendingSaveName != null)
{
CrashLog.Log($"[MP Join] Game scene \"{sceneName}\" loaded — will attempt save load after short delay");
_deferredLoadDelay = 1.0f;
}
}
}
}
private void HandleKeybinds()
{
var kb = Keyboard.current;
if (kb == null) return;
// F9 = Host game
if (kb.f9Key.wasPressedThisFrame)
{
DoHost();
}
// F10 = Toggle multiplayer panel
if (kb.f10Key.wasPressedThisFrame)
{
if (_showPanel)
HideMultiplayerPanel();
else
ShowMultiplayerPanel();
}
// F11 = Disconnect
if (kb.f11Key.wasPressedThisFrame)
{
DoDisconnect();
}
// Handle custom text field input when focused
if (_showPanel && _roomCodeFieldFocused)
{
HandleTextFieldInput(kb);
}
}
/// <summary>
/// Manually handles keyboard input for the room code text field since GUI.TextField
/// doesn't work when the game uses the new Input System exclusively.
/// </summary>
private void HandleTextFieldInput(Keyboard kb)
{
bool ctrl = kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed;
int maxLen = 16;
// Ctrl+V = Paste
if (ctrl && kb.vKey.wasPressedThisFrame)
{
string clip = GUIUtility.systemCopyBuffer;
if (!string.IsNullOrEmpty(clip))
{
// Room codes: alphanumeric only, uppercase
var filtered = new System.Text.StringBuilder();
foreach (char c in clip)
{
if (char.IsLetterOrDigit(c)) filtered.Append(char.ToUpper(c));
}
_roomCode = (_roomCode ?? "") + filtered.ToString();
if (_roomCode.Length > maxLen) _roomCode = _roomCode.Substring(0, maxLen);
}
return;
}
// Ctrl+A = Select all (clear for simplicity)
if (ctrl && kb.aKey.wasPressedThisFrame)
{
_roomCode = "";
return;
}
// Escape = unfocus
if (kb.escapeKey.wasPressedThisFrame)
{
_roomCodeFieldFocused = false;
return;
}
// Enter = trigger join
if (kb.enterKey.wasPressedThisFrame || kb.numpadEnterKey.wasPressedThisFrame)
{
_roomCodeFieldFocused = false;
DoConnect();
return;
}
// Room code field: alphanumeric, auto-uppercase
var alphaKeys = new (Key key, char ch)[]
{
(Key.A, 'A'), (Key.B, 'B'), (Key.C, 'C'), (Key.D, 'D'),
(Key.E, 'E'), (Key.F, 'F'), (Key.G, 'G'), (Key.H, 'H'),
(Key.I, 'I'), (Key.J, 'J'), (Key.K, 'K'), (Key.L, 'L'),
(Key.M, 'M'), (Key.N, 'N'), (Key.O, 'O'), (Key.P, 'P'),
(Key.Q, 'Q'), (Key.R, 'R'), (Key.S, 'S'), (Key.T, 'T'),
(Key.U, 'U'), (Key.V, 'V'), (Key.W, 'W'), (Key.X, 'X'),
(Key.Y, 'Y'), (Key.Z, 'Z'),
(Key.Digit0, '0'), (Key.Digit1, '1'), (Key.Digit2, '2'),
(Key.Digit3, '3'), (Key.Digit4, '4'), (Key.Digit5, '5'),
(Key.Digit6, '6'), (Key.Digit7, '7'), (Key.Digit8, '8'),
(Key.Digit9, '9'),
(Key.Numpad0, '0'), (Key.Numpad1, '1'), (Key.Numpad2, '2'),
(Key.Numpad3, '3'), (Key.Numpad4, '4'), (Key.Numpad5, '5'),
(Key.Numpad6, '6'), (Key.Numpad7, '7'), (Key.Numpad8, '8'),
(Key.Numpad9, '9'),
};
foreach (var (key, ch) in alphaKeys)
{
if (ShouldProcessKey(kb, key))
{
if ((_roomCode ?? "").Length < maxLen)
_roomCode = (_roomCode ?? "") + ch;
return;
}
}
// Backspace
if (ShouldProcessKey(kb, Key.Backspace))
{
if (!string.IsNullOrEmpty(_roomCode))
_roomCode = _roomCode.Substring(0, _roomCode.Length - 1);
return;
}
// Delete = clear all
if (kb.deleteKey.wasPressedThisFrame)
{
_roomCode = "";
return;
}
}
/// <summary>
/// Returns true if a key should be processed this frame (initial press or held-repeat).
/// </summary>
private bool ShouldProcessKey(Keyboard kb, Key key)
{
var control = kb[key];
if (control.wasPressedThisFrame)
{
_lastHeldKey = key;
_keyRepeatTimer = KEY_REPEAT_DELAY;
return true;
}
if (control.isPressed && _lastHeldKey == key)
{
_keyRepeatTimer -= Time.deltaTime;
if (_keyRepeatTimer <= 0f)
{
_keyRepeatTimer = KEY_REPEAT_RATE;
return true;
}
}
else if (_lastHeldKey == key && !control.isPressed)
{
_lastHeldKey = Key.None;
}
return false;
}
// ═══════════════════════════════════════════════════════════════════════
// Actions (shared by keybinds and UI buttons)
// ═══════════════════════════════════════════════════════════════════════
private void DoHost()
{
if (_host == null)
{
_logger.Warning("[MP Bridge] mp_host export not available.");
return;
}
if (_isHosting)
{
_logger.Msg("[MP Bridge] Already hosting.");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Already hosting!"); } catch { }
return;
}
CrashLog.Log("[MP Bridge] DoHost: calling mp_host()");
int result = _host();
CrashLog.Log($"[MP Bridge] DoHost: mp_host returned {result}");
if (result == 1)
{
_isHosting = true;
_displayRoomCode = ""; // Reset — will be polled in OnUpdate
_logger.Msg("[MP Bridge] Connecting to relay for hosting...");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Connecting to relay..."); } catch { }
}
else
{
_logger.Warning("[MP Bridge] Failed to connect to relay server.");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to connect to relay!"); } catch { }
}
}
private void DoConnect()
{
if (_connect == null)
{
_logger.Warning("[MP Bridge] mp_connect export not available.");
return;
}
string code = _roomCode != null ? _roomCode.Trim().ToUpper() : "";
if (string.IsNullOrEmpty(code))
{
_logger.Warning("[MP Bridge] No room code entered.");
CrashLog.Log("[MP Bridge] DoConnect: empty room code");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Enter a room code!"); } catch { }
return;
}
CrashLog.Log($"[MP Bridge] DoConnect: room={code}");
// Prevent joining when already busy
uint currentJoinState = GetJoinState();
if (currentJoinState != JOIN_IDLE && currentJoinState != JOIN_LOADED)
{
CrashLog.Log($"[MP Bridge] DoConnect: blocked already in state {currentJoinState}");
_logger.Msg("[MP Bridge] Already joining, please wait...");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Already joining, please wait..."); } catch { }
return;
}
// Reset join state for a fresh attempt
ResetJoinState();
// Compute local save hash for save versioning
if (_setLocalSaveHash != null)
{
try
{
string? savePath = DiscoverSaveFile();
if (savePath != null && File.Exists(savePath))
{
byte[] localSave = File.ReadAllBytes(savePath);
IntPtr hashPtr = Marshal.AllocHGlobal(localSave.Length);
try
{
Marshal.Copy(localSave, 0, hashPtr, localSave.Length);
_setLocalSaveHash(hashPtr, (uint)localSave.Length);
CrashLog.Log($"[MP Join] Local save hash computed from {savePath} ({localSave.Length} bytes)");
}
finally
{
Marshal.FreeHGlobal(hashPtr);
}
}
else
{
CrashLog.Log("[MP Join] No local save found — hash not set");
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Error computing local save hash: {ex.Message}");
}
}
byte[] codeBytes = System.Text.Encoding.UTF8.GetBytes(code);
IntPtr codePtr = Marshal.AllocHGlobal(codeBytes.Length);
try
{
Marshal.Copy(codeBytes, 0, codePtr, codeBytes.Length);
int result = _connect(codePtr, (uint)codeBytes.Length);
CrashLog.Log($"[MP Bridge] DoConnect: mp_connect returned {result}");
if (result == 1)
{
SetJoinState(JOIN_WAITING_FOR_SAVE);
_logger.Msg($"[MP Bridge] Joining room {code}...");
CrashLog.Log($"[MP Join] State → WaitingForSave");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField($"Multiplayer: Joining room {code}..."); } catch { }
HideMultiplayerPanel();
}
else
{
_logger.Warning("[MP Bridge] Failed to connect to relay server.");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to connect!"); } catch { }
}
}
finally
{
Marshal.FreeHGlobal(codePtr);
}
}
private void DoDisconnect()
{
ResetJoinState();
if (_disconnect == null)
{
_logger.Warning("[MP Bridge] mp_disconnect export not available.");
return;
}
_disconnect();
_isHosting = false;
_displayRoomCode = "";
_discoveredSavePath = null;
_cachedSaveData = null;
_cachedSaveAge = 0f;
// Clean up MP save files and restore originals
CleanupMpSaveFiles();
_logger.Msg("[MP Bridge] Disconnected.");
try
{
var ui = StaticUIElements.instance;
if (ui != null)
ui.AddMeesageInField("Multiplayer: Disconnected.");
}
catch { }
}
private void DoStopHosting()
{
ResetJoinState();
if (_disconnect == null)
{
_logger.Warning("[MP Bridge] mp_disconnect export not available.");
return;
}
_disconnect();
_isHosting = false;
_displayRoomCode = "";
_discoveredSavePath = null;
_cachedSaveData = null;
_cachedSaveAge = 0f;
// Clean up MP save files (host might have _mp_temp leftovers)
CleanupMpSaveFiles();
_logger.Msg("[MP Bridge] Stopped hosting.");
try
{
var ui = StaticUIElements.instance;
if (ui != null)
ui.AddMeesageInField("Multiplayer: Stopped hosting.");
}
catch { }
}
private void SendSaveToClients()
{
try
{
CrashLog.Log("[MP Save] Host: sending save to clients...");
byte[]? saveData;
// Check if we have a recent cached save (avoids re-saving when multiple clients join)
if (_cachedSaveData != null && _cachedSaveAge < SAVE_CACHE_LIFETIME)
{
saveData = _cachedSaveData;
CrashLog.Log($"[MP Save] Using cached save data ({saveData.Length} bytes, {_cachedSaveAge:F1}s old)");
}
else
{
// Save to a temp name so we don't pollute the save directory
string tempSaveName = "_mp_temp";
try
{
SaveSystem.SaveGame(tempSaveName, tempSaveName);
CrashLog.Log("[MP Save] SaveGame(\"_mp_temp\", \"_mp_temp\") OK");
}
catch (Exception ex)
{
CrashLog.Log($"[MP Save] SaveGame with temp name failed: {ex.Message} — falling back to parameterless SaveGame");
try { SaveSystem.SaveGame(); }
catch (Exception ex2) { CrashLog.LogException("MP Save: SaveGame()", ex2); return; }
}
// Give the save a moment to flush to disk
System.Threading.Thread.Sleep(300);
// Try to find the temp save file first; fall back to newest save
string? saveDirPath = null;
try { saveDirPath = SaveSystem.saveDirPath; }
catch { }
string? savePath = null;
bool isTempFile = false;
if (!string.IsNullOrEmpty(saveDirPath))
{
string tempPath = Path.Combine(saveDirPath, tempSaveName + ".save");
if (File.Exists(tempPath))
{
savePath = tempPath;
isTempFile = true;
CrashLog.Log($"[MP Save] Found temp save: {tempPath}");
}
}
if (savePath == null)
savePath = DiscoverSaveFile();
if (savePath == null)
{
CrashLog.Log("[MP Save] ERROR: Could not find any save file!");
_logger.Error("[MP Save] Could not locate save file to send.");
return;
}
saveData = File.ReadAllBytes(savePath);
CrashLog.Log($"[MP Save] Read {saveData.Length} bytes from {savePath}");
if (saveData.Length == 0)
{
CrashLog.Log("[MP Save] ERROR: Save file is empty!");
if (isTempFile) TryDeleteFile(savePath);
return;
}
// Clean up temp file
if (isTempFile) TryDeleteFile(savePath);
try { SaveSystem.DeleteSaveFile(tempSaveName); } catch { }
// Cache for future requests
_cachedSaveData = saveData;
_cachedSaveAge = 0f;
CrashLog.Log($"[MP Save] Cached {saveData.Length} bytes for {SAVE_CACHE_LIFETIME}s");
}
// Pass to Rust for chunked transfer
if (_sendSaveData != null)
{
IntPtr ptr = Marshal.AllocHGlobal(saveData.Length);
try
{
Marshal.Copy(saveData, 0, ptr, saveData.Length);
int result = _sendSaveData(ptr, (uint)saveData.Length);
CrashLog.Log($"[MP Save] mp_send_save_data returned {result}");
if (result == 1)
{
_logger.Msg("[MP Save] Save data queued for transfer.");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Sending save to client..."); } catch { }
}
}
finally
{
Marshal.FreeHGlobal(ptr);
}
}
}
catch (Exception ex)
{
CrashLog.LogException("SendSaveToClients", ex);
}
}
private void TryDeleteFile(string path)
{
try
{
File.Delete(path);
CrashLog.Log($"[MP Save] Deleted temp file: {path}");
}
catch (Exception ex)
{
CrashLog.Log($"[MP Save] Could not delete temp file: {ex.Message}");
}
}
/// Cleans up multiplayer save artifacts: _mp_sync files, .mp_backup files.
/// Restores original saves from backups so the player's own world is intact after disconnect.
private void CleanupMpSaveFiles()
{
string? saveDir = DiscoverSaveDirectory();
if (saveDir == null)
{
CrashLog.Log("[MP Cleanup] No save directory found — skipping cleanup");
return;
}
int cleaned = 0;
try
{
// Delete _mp_sync.* and _mp_temp.* files
foreach (var file in Directory.GetFiles(saveDir))
{
string name = Path.GetFileNameWithoutExtension(file).ToLower();
if (name == "_mp_sync" || name == "_mp_temp")
{
TryDeleteFile(file);
cleaned++;
}
}
// Restore .mp_backup files → undo the overwrite from WriteSaveToDisk
foreach (var backupFile in Directory.GetFiles(saveDir, "*.mp_backup"))
{
string originalPath = backupFile.Substring(0, backupFile.Length - ".mp_backup".Length);
try
{
File.Copy(backupFile, originalPath, true);
File.Delete(backupFile);
CrashLog.Log($"[MP Cleanup] Restored original save: {Path.GetFileName(originalPath)}");
cleaned++;
}
catch (Exception ex)
{
CrashLog.Log($"[MP Cleanup] Failed to restore {Path.GetFileName(backupFile)}: {ex.Message}");
}
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Cleanup] Error during cleanup: {ex.Message}");
}
if (cleaned > 0)
CrashLog.Log($"[MP Cleanup] Cleaned up {cleaned} multiplayer save file(s)");
else
CrashLog.Log("[MP Cleanup] No multiplayer save files to clean up");
}
// ═══════════════════════════════════════════════════════════════════════
// Client Join: State Machine Helpers
// ═══════════════════════════════════════════════════════════════════════
private bool IsInMainMenu()
{
if (!string.IsNullOrEmpty(_currentSceneName))
return _currentSceneName.Equals("MainMenu", StringComparison.OrdinalIgnoreCase);
// Fallback: query scene manager directly
try
{
string scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name ?? "";
_currentSceneName = scene;
return scene.Equals("MainMenu", StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
/// <summary>
/// Called from OnUpdate when joinState == SaveReceived.
/// Fetches bytes from Rust, writes them to disk, then decides how to load.
/// </summary>
private void FetchAndProcessSave()
{
try
{
// ── 1. Grab the raw bytes from Rust ──
uint size = _getSaveDataSize != null ? _getSaveDataSize() : 0;
if (size == 0)
{
CrashLog.Log("[MP Join] Pending save has 0 bytes — aborting");
ResetJoinState();
return;
}
CrashLog.Log($"[MP Join] Fetching {size} bytes from Rust...");
byte[] saveData = new byte[size];
IntPtr ptr = Marshal.AllocHGlobal((int)size);
try
{
uint copied = _getSaveData != null ? _getSaveData(ptr, size) : 0;
Marshal.Copy(ptr, saveData, 0, (int)copied);
CrashLog.Log($"[MP Join] Got {copied} bytes");
}
finally
{
Marshal.FreeHGlobal(ptr);
}
// Peek first bytes for diagnostics
if (saveData.Length > 0)
{
int peekLen = Math.Min(saveData.Length, 200);
string peekText = System.Text.Encoding.UTF8.GetString(saveData, 0, peekLen).Replace("\r", "").Replace("\n", "\\n");
CrashLog.Log($"[MP Join] First {peekLen} bytes: {peekText}");
}
_pendingSaveBytes = saveData;
// Tell Rust we consumed the buffer
if (_saveLoadComplete != null) _saveLoadComplete();
// ── 2. Write to disk ──
WriteSaveToDisk();
// ── 3. Attempt load (scene-aware) ──
if (_pendingSaveName == null)
{
CrashLog.Log("[MP Join] ERROR: WriteSaveToDisk failed to produce a save name");
_logger.Error("[MP Join] Failed to write save to disk.");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to write host save!"); } catch { }
ResetJoinState();
return;
}
// Decide how to load based on current scene
CrashLog.Log($"[MP Join] Current scene: \"{_currentSceneName}\"");
if (IsInMainMenu())
{
// From MainMenu: SaveSystem.Load() does NOT trigger a scene transition
// (onLoadingData callbacks aren't registered yet).
// We must: set loadSaveName, then manually load the game scene.
CrashLog.Log("[MP Join] In MainMenu — initiating manual scene transition");
InitiateSceneTransition();
}
else
{
// Already in-game: SaveSystem.Load() should work (callbacks are registered)
AttemptSaveLoad();
}
}
catch (Exception ex)
{
CrashLog.LogException("FetchAndProcessSave", ex);
_logger.Error($"[MP Join] Exception during save processing: {ex.Message}");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Error processing host save!"); } catch { }
ResetJoinState();
}
}
/// <summary>
/// Writes _pendingSaveBytes to disk. Handles both "overwrite existing" and "fresh install" cases.
/// Sets _pendingSaveName and _pendingSaveFullPath on success.
/// </summary>
private void WriteSaveToDisk()
{
DumpSaveSystemMethods();
if (_pendingSaveBytes == null) return;
string? saveDir = DiscoverSaveDirectory();
if (saveDir == null)
{
CrashLog.Log("[MP Join] ERROR: Could not find save directory!");
// Last resort: use persistentDataPath directly
saveDir = Application.persistentDataPath;
try { Directory.CreateDirectory(saveDir); } catch { }
}
// ── Scan existing saves ──
string? existingSaveName = null;
string? existingSavePath = null;
string ext = ".save";
try
{
var existingFiles = Directory.GetFiles(saveDir);
CrashLog.Log($"[MP Join] Save directory has {existingFiles.Length} files:");
foreach (var f in existingFiles)
{
string fname = Path.GetFileName(f);
string fext = Path.GetExtension(f).ToLower();
var finfo = new FileInfo(f);
CrashLog.Log($"[MP Join] {fname} ({finfo.Length} bytes, {finfo.LastWriteTime:HH:mm:ss})");
if (fext == ".save" || fext == ".json" || fext == ".sav" || fext == ".dat")
{
ext = fext;
string nameNoExt = Path.GetFileNameWithoutExtension(f);
if (nameNoExt.StartsWith("_mp_")) continue;
if (fext == ".vdf") continue;
if (existingSavePath == null || finfo.LastWriteTime > new FileInfo(existingSavePath).LastWriteTime)
{
existingSaveName = nameNoExt;
existingSavePath = f;
}
}
}
}
catch (Exception ex) { CrashLog.Log($"[MP Join] Error scanning save dir: {ex.Message}"); }
// ── Always write a debug/_mp_sync copy ──
string tempPath = Path.Combine(saveDir, "_mp_sync" + ext);
File.WriteAllBytes(tempPath, _pendingSaveBytes);
CrashLog.Log($"[MP Join] Wrote debug copy: {tempPath}");
// ── Strategy A: Overwrite an existing save (game already knows about it) ──
if (existingSaveName != null && existingSavePath != null)
{
// Backup the original
string backupPath = existingSavePath + ".mp_backup";
try
{
File.Copy(existingSavePath, backupPath, true);
CrashLog.Log($"[MP Join] Backed up: {existingSavePath} -> {backupPath}");
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Backup warning: {ex.Message}");
}
File.WriteAllBytes(existingSavePath, _pendingSaveBytes);
_pendingSaveName = existingSaveName;
_pendingSaveFullPath = existingSavePath;
CrashLog.Log($"[MP Join] Overwrote existing save: \"{existingSaveName}\" at {existingSavePath} ({_pendingSaveBytes.Length} bytes)");
return;
}
// ── Strategy B: No existing save (fresh install) — create one with a timestamp name ──
CrashLog.Log("[MP Join] No existing save found — creating new save file for fresh install");
string timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
string newPath = Path.Combine(saveDir, timestamp + ext);
File.WriteAllBytes(newPath, _pendingSaveBytes);
_pendingSaveName = timestamp;
_pendingSaveFullPath = newPath;
CrashLog.Log($"[MP Join] Created new save: \"{timestamp}\" at {newPath} ({_pendingSaveBytes.Length} bytes)");
}
/// <summary>
/// Called when client is in MainMenu: sets loadSaveName on SaveSystem and
/// triggers a scene transition to the game scene. After the scene loads,
/// OnSceneLoaded → deferred delay → AttemptSaveLoad() will apply the save.
/// </summary>
private void InitiateSceneTransition()
{
// Store room code for auto-reconnect after scene transition
_reconnectRoomCode = _roomCode?.Trim().ToUpper();
if (string.IsNullOrEmpty(_reconnectRoomCode))
{
// Try to get it from Rust state
try
{
IntPtr codePtr = _getRoomCode != null ? _getRoomCode() : IntPtr.Zero;
if (codePtr != IntPtr.Zero)
{
string? code = Marshal.PtrToStringAnsi(codePtr);
if (!string.IsNullOrEmpty(code)) _reconnectRoomCode = code;
}
}
catch { }
}
CrashLog.Log($"[MP Join] Stored room code for reconnect: \"{_reconnectRoomCode}\"");
// ── Approach 1: Use the game's own MainMenu.Continue() ──
// This replicates what happens when the player presses "Continue" on the
// main menu. The game handles isQuitting, scene transitions, save loading,
// callbacks, and all internal state setup. Much safer than manual scene load.
try
{
var menus = Resources.FindObjectsOfTypeAll<Il2Cpp.MainMenu>();
if (menus != null && menus.Count > 0)
{
var mainMenu = menus[0];
CrashLog.Log("[MP Join] Found MainMenu instance — using game's Continue() flow");
// The save we overwrote in WriteSaveToDisk is the newest save,
// so Continue() will load it through the normal game path.
_gameHandledSaveLoad = true;
SetJoinState(JOIN_LOADING_SCENE);
_pendingSaveBytes = null; // free memory
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
mainMenu.Continue();
CrashLog.Log("[MP Join] MainMenu.Continue() called — game will handle scene transition and save load");
return;
}
else
{
CrashLog.Log("[MP Join] No MainMenu instance found — falling back to manual approach");
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] MainMenu.Continue() failed: {ex.GetType().Name}: {ex.Message} — falling back to manual approach");
_gameHandledSaveLoad = false;
}
// ── Approach 2: Manual scene transition (fallback) ──
CrashLog.Log("[MP Join] Using manual scene transition fallback");
// Reset isQuitting flag — the game sets this when quitting to MainMenu
// and never resets it, which causes crashes during save load
try
{
bool wasQuitting = SaveSystem.isQuitting;
if (wasQuitting)
{
SaveSystem.isQuitting = false;
CrashLog.Log($"[MP Join] Reset SaveSystem.isQuitting (was {wasQuitting})");
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Could not reset isQuitting: {ex.Message}");
}
// Set SaveSystem.loadSaveName so the game knows which save to load
try
{
SaveSystem.loadSaveName = _pendingSaveName;
CrashLog.Log($"[MP Join] Set SaveSystem.loadSaveName = \"{_pendingSaveName}\"");
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Failed to set loadSaveName: {ex.Message}");
}
// Enumerate available scenes and find the game scene
try
{
int sceneCount = SceneManager.sceneCountInBuildSettings;
CrashLog.Log($"[MP Join] Build has {sceneCount} scenes:");
string? gameSceneName = null;
int gameSceneIndex = -1;
for (int i = 0; i < sceneCount; i++)
{
string path = SceneUtility.GetScenePathByBuildIndex(i);
string name = System.IO.Path.GetFileNameWithoutExtension(path);
CrashLog.Log($"[MP Join] [{i}] \"{name}\" ({path})");
if (!name.Equals("MainMenu", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("Init", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("Splash", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("Loading", StringComparison.OrdinalIgnoreCase))
{
if (gameSceneName == null)
{
gameSceneName = name;
gameSceneIndex = i;
}
}
}
if (gameSceneIndex >= 0)
{
CrashLog.Log($"[MP Join] Loading game scene: [{gameSceneIndex}] \"{gameSceneName}\"");
SetJoinState(JOIN_LOADING_SCENE);
_pendingSaveBytes = null;
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
SceneManager.LoadScene(gameSceneIndex);
return;
}
else
{
CrashLog.Log("[MP Join] Could not identify game scene — trying build index 1");
SetJoinState(JOIN_LOADING_SCENE);
_pendingSaveBytes = null;
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
SceneManager.LoadScene(1);
return;
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Scene enumeration failed: {ex.GetType().Name}: {ex.Message}");
CrashLog.Log("[MP Join] Falling back to SceneManager.LoadScene(1)");
SetJoinState(JOIN_LOADING_SCENE);
_pendingSaveBytes = null;
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
try { SceneManager.LoadScene(1); }
catch (Exception ex2) { CrashLog.Log($"[MP Join] LoadScene(1) failed: {ex2.Message}"); }
}
}
/// <summary>
/// Applies the save via SaveSystem.Load().
/// Then triggers auto-reconnect to the relay.
/// </summary>
private void AttemptSaveLoad()
{
if (_pendingSaveName == null)
{
CrashLog.Log("[MP Join] AttemptSaveLoad: no pending save name — aborting");
ResetJoinState();
return;
}
bool loaded = false;
CrashLog.Log($"[MP Join] AttemptSaveLoad: name=\"{_pendingSaveName}\", scene=\"{_currentSceneName}\"");
// Reset isQuitting flag
try
{
bool wasQuitting = SaveSystem.isQuitting;
SaveSystem.isQuitting = false;
CrashLog.Log($"[MP Join] SaveSystem.isQuitting = false (was {wasQuitting})");
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Could not reset isQuitting: {ex.Message}");
}
// ── Approach A: Load(name, false) — standard load path ──
CrashLog.Log($"[MP Join] Approach A: SaveSystem.Load(\"{_pendingSaveName}\", false)...");
try
{
SaveSystem.Load(_pendingSaveName, false);
CrashLog.Log("[MP Join] Approach A returned OK");
loaded = true;
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Approach A threw: {ex.GetType().Name}: {ex.Message}");
}
// ── Approach B: Load(name, true) — "from pause menu" path ──
if (!loaded)
{
CrashLog.Log($"[MP Join] Approach B: SaveSystem.Load(\"{_pendingSaveName}\", true)...");
try
{
SaveSystem.Load(_pendingSaveName, true);
CrashLog.Log("[MP Join] Approach B returned OK");
loaded = true;
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Approach B threw: {ex.GetType().Name}: {ex.Message}");
}
}
// ── Approach C: Try _mp_sync name directly ──
if (!loaded)
{
CrashLog.Log("[MP Join] Approach C: SaveSystem.Load(\"_mp_sync\", false)...");
try
{
SaveSystem.Load("_mp_sync", false);
CrashLog.Log("[MP Join] Approach C returned OK");
loaded = true;
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Approach C threw: {ex.GetType().Name}: {ex.Message}");
}
}
// ── Approach D: Reflection — LoadGame(string) + LoadGameData() ──
if (!loaded)
{
CrashLog.Log("[MP Join] Approach D: Trying reflection-based load...");
try
{
var ssType = typeof(SaveSystem);
var loadGame = ssType.GetMethod("LoadGame",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static,
null, new Type[] { typeof(string) }, null);
if (loadGame != null)
{
CrashLog.Log($"[MP Join] Found LoadGame(string), invoking with \"{_pendingSaveName}\"...");
loadGame.Invoke(null, new object?[] { _pendingSaveName });
CrashLog.Log("[MP Join] Approach D (LoadGame) returned OK — now calling LoadGameData()...");
try { SaveSystem.LoadGameData(); CrashLog.Log("[MP Join] LoadGameData() OK"); }
catch (Exception ex3) { CrashLog.Log($"[MP Join] LoadGameData() threw: {ex3.Message}"); }
loaded = true;
}
else
{
CrashLog.Log("[MP Join] LoadGame(string) not found via reflection");
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Join] Approach D threw: {ex.GetType().Name}: {ex.Message}");
}
}
CrashLog.Log($"[MP Join] AttemptSaveLoad finished: loaded={loaded}");
if (loaded)
{
SetJoinState(JOIN_LOADED);
_pendingSaveBytes = null; // free memory
_logger.Msg("[MP Join] Save loaded from host!");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Save loaded from host!"); } catch { }
// Only reconnect if the relay actually died during scene transition
if (_reconnectRoomCode != null)
{
bool relayStillAlive = _isRelayActive != null && _isRelayActive() != 0;
bool stillConnected = _isConnected != null && _isConnected() != 0;
if (relayStillAlive && stillConnected)
{
CrashLog.Log($"[MP Join] Relay still alive after save load (alive={relayStillAlive}, connected={stillConnected}) — no reconnect needed");
}
else
{
CrashLog.Log($"[MP Join] Relay died during save load (alive={relayStillAlive}, connected={stillConnected}) — auto-reconnecting to {_reconnectRoomCode}");
AutoReconnect();
}
}
}
else
{
CrashLog.Log("[MP Join] All load approaches failed — giving up");
_logger.Warning("[MP Join] Could not load save — check dc_modloader_debug.log for details.");
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to load host save! Check logs."); } catch { }
ResetJoinState();
}
}
/// <summary>
/// Auto-reconnects to the relay after a scene transition, skipping save re-request.
/// </summary>
private void AutoReconnect()
{
if (_connect == null || string.IsNullOrEmpty(_reconnectRoomCode))
{
CrashLog.Log("[MP Join] AutoReconnect: no connect delegate or room code");
return;
}
CrashLog.Log($"[MP Join] AutoReconnect: joining room {_reconnectRoomCode} (skip save = true)");
_skipSaveOnReconnect = true;
_reconnectCooldown = 5f;
// Tell Rust not to request save on reconnect
if (_skipNextSaveRequest != null)
{
_skipNextSaveRequest();
}
byte[] codeBytes = System.Text.Encoding.UTF8.GetBytes(_reconnectRoomCode);
IntPtr codePtr = Marshal.AllocHGlobal(codeBytes.Length);
try
{
Marshal.Copy(codeBytes, 0, codePtr, codeBytes.Length);
int result = _connect(codePtr, (uint)codeBytes.Length);
CrashLog.Log($"[MP Join] AutoReconnect: mp_connect returned {result}");
if (result == 1)
{
SetJoinState(JOIN_WAITING_FOR_SAVE);
try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Reconnecting..."); } catch { }
}
else
{
CrashLog.Log("[MP Join] AutoReconnect failed");
_skipSaveOnReconnect = false;
}
}
finally
{
Marshal.FreeHGlobal(codePtr);
}
}
/// <summary>
/// Resets join state back to Idle and clears pending data.
/// </summary>
private void ResetJoinState()
{
SetJoinState(JOIN_IDLE);
_pendingSaveBytes = null;
_pendingSaveName = null;
_pendingSaveFullPath = null;
_deferredLoadDelay = 0f;
_skipSaveOnReconnect = false;
_reconnectCooldown = 0f;
_gameHandledSaveLoad = false;
}
private void DumpSaveSystemMethods()
{
try
{
CrashLog.Log("[MP Save] === SaveSystem method dump ===");
var ssType = typeof(SaveSystem);
var methods = ssType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance);
foreach (var m in methods)
{
var parms = m.GetParameters();
var parmStr = string.Join(", ", Array.ConvertAll(parms, p => $"{p.ParameterType.Name} {p.Name}"));
CrashLog.Log($"[MP Save] {(m.IsStatic ? "static " : "")}{m.ReturnType.Name} {m.Name}({parmStr})");
}
CrashLog.Log("[MP Save] === end SaveSystem dump ===");
// Also dump static fields/properties
var fields = ssType.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (fields.Length > 0)
{
CrashLog.Log("[MP Save] === SaveSystem fields ===");
foreach (var f in fields)
{
try
{
var val = f.GetValue(null);
CrashLog.Log($"[MP Save] {(f.IsStatic ? "static " : "")}{f.FieldType.Name} {f.Name} = {val}");
}
catch
{
CrashLog.Log($"[MP Save] {(f.IsStatic ? "static " : "")}{f.FieldType.Name} {f.Name} = <error reading>");
}
}
CrashLog.Log("[MP Save] === end SaveSystem fields ===");
}
var props = ssType.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (props.Length > 0)
{
CrashLog.Log("[MP Save] === SaveSystem properties ===");
foreach (var p in props)
{
try
{
var val = p.GetValue(null);
CrashLog.Log($"[MP Save] {p.PropertyType.Name} {p.Name} = {val}");
}
catch
{
CrashLog.Log($"[MP Save] {p.PropertyType.Name} {p.Name} = <error reading>");
}
}
CrashLog.Log("[MP Save] === end SaveSystem properties ===");
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Save] SaveSystem reflection failed: {ex.GetType().Name}: {ex.Message}");
}
}
private string? DiscoverSaveDirectory()
{
if (_discoveredSavePath != null)
{
string? dir = Path.GetDirectoryName(_discoveredSavePath);
if (dir != null && Directory.Exists(dir)) return dir;
}
string basePath = Application.persistentDataPath;
CrashLog.Log($"[MP Save] persistentDataPath = {basePath}");
// Check common save subdirectories
string[] subDirs = { "Saves", "SaveGames", "Save", "" };
foreach (var sub in subDirs)
{
string candidate = string.IsNullOrEmpty(sub) ? basePath : Path.Combine(basePath, sub);
if (Directory.Exists(candidate))
{
// Check if it has any save-looking files
try
{
var files = Directory.GetFiles(candidate);
if (files.Length > 0)
{
CrashLog.Log($"[MP Save] Found save directory: {candidate} ({files.Length} files)");
return candidate;
}
}
catch { }
}
}
// Fallback: use persistentDataPath directly
CrashLog.Log($"[MP Save] Using persistentDataPath as save directory: {basePath}");
return basePath;
}
private string? DiscoverSaveFile()
{
if (_discoveredSavePath != null && File.Exists(_discoveredSavePath))
return _discoveredSavePath;
string basePath = Application.persistentDataPath;
CrashLog.Log($"[MP Save] Searching for save files in: {basePath}");
// Log directory contents for debugging
try
{
LogDirectoryContents(basePath, 0);
}
catch (Exception ex) { CrashLog.Log($"[MP Save] Error listing dir: {ex.Message}"); }
// Strategy: find the most recently modified save file
string? bestFile = null;
DateTime bestTime = DateTime.MinValue;
string[] searchDirs = { basePath };
try
{
// Also search subdirectories
var subDirs = Directory.GetDirectories(basePath);
var allDirs = new List<string>(subDirs);
allDirs.Insert(0, basePath);
searchDirs = allDirs.ToArray();
}
catch { }
string[] saveExtensions = { ".json", ".sav", ".save", ".dat" };
foreach (var dir in searchDirs)
{
try
{
foreach (var file in Directory.GetFiles(dir))
{
string ext = Path.GetExtension(file).ToLower();
if (Array.IndexOf(saveExtensions, ext) < 0) continue;
var info = new FileInfo(file);
CrashLog.Log($"[MP Save] Candidate: {file} (size={info.Length}, modified={info.LastWriteTime:HH:mm:ss})");
if (info.LastWriteTime > bestTime)
{
bestTime = info.LastWriteTime;
bestFile = file;
}
}
}
catch { }
}
if (bestFile != null)
{
CrashLog.Log($"[MP Save] Selected save file: {bestFile}");
_discoveredSavePath = bestFile;
}
return bestFile;
}
private void LogDirectoryContents(string path, int depth)
{
if (depth > 2) return; // don't recurse too deep
string indent = new string(' ', depth * 2);
try
{
foreach (var file in Directory.GetFiles(path))
{
var info = new FileInfo(file);
CrashLog.Log($"[MP Save] {indent}FILE: {Path.GetFileName(file)} ({info.Length} bytes, {info.LastWriteTime:yyyy-MM-dd HH:mm:ss})");
}
foreach (var dir in Directory.GetDirectories(path))
{
CrashLog.Log($"[MP Save] {indent}DIR: {Path.GetFileName(dir)}/");
LogDirectoryContents(dir, depth + 1);
}
}
catch (Exception ex)
{
CrashLog.Log($"[MP Save] {indent}Error: {ex.Message}");
}
}
// ═══════════════════════════════════════════════════════════════════════
// Main Menu Button Injection
// ═══════════════════════════════════════════════════════════════════════
private void InjectMainMenuButton()
{
try
{
if (!_initialized) return;
if (_menuButton != null) return;
var mpCheck = GetModuleHandle("dc_multiplayer.dll");
if (mpCheck == IntPtr.Zero)
mpCheck = GetModuleHandle("dc_multiplayer");
if (mpCheck == IntPtr.Zero)
{
CrashLog.Log("[MP Bridge] InjectMainMenuButton aborted — dc_multiplayer.dll is not loaded.");
_initialized = false;
return;
}
Transform? templateButton = ModConfigSystem.SettingsButtonTransform;
if (templateButton == null)
{
var allButtons = Resources.FindObjectsOfTypeAll<ButtonExtended>();
if (allButtons != null)
{
foreach (var btn in allButtons)
{
try
{
var onClick = btn.onClick;
if (onClick == null) continue;
int count = onClick.GetPersistentEventCount();
for (int i = 0; i < count; i++)
{
if (onClick.GetPersistentMethodName(i) == "Settings")
{
templateButton = btn.transform;
break;
}
}
if (templateButton != null) break;
}
catch { }
}
}
}
if (templateButton == null)
{
_logger.Warning("[MP Bridge] Could not find Settings button (not cached by ModConfigSystem and no persistent 'Settings' listener found).");
return;
}
var buttonPanel = templateButton.parent;
int siblingIndex = templateButton.GetSiblingIndex();
// Clone the Settings button into the same panel
var clone = UnityEngine.Object.Instantiate(templateButton.gameObject, buttonPanel);
// Place it BEFORE Settings (i.e. after Load Game)
clone.transform.SetSiblingIndex(siblingIndex);
clone.name = "MultiplayerButton";
// ── Step 1: Destroy LocalisedText components ──
var locTexts = clone.GetComponentsInChildren<LocalisedText>(true);
if (locTexts != null)
{
foreach (var lt in locTexts)
{
UnityEngine.Object.Destroy(lt);
}
_logger.Msg($"[MP Bridge] Destroyed {locTexts.Count} LocalisedText component(s) on cloned button.");
}
// ── Step 2: Change the label text to "Multiplayer" ──
var cloneTexts = clone.GetComponentsInChildren<TextMeshProUGUI>(true);
if (cloneTexts != null)
{
foreach (var t in cloneTexts)
{
t.text = "Multiplayer";
try { t.SetText("Multiplayer"); } catch { }
try { t.ForceMeshUpdate(); } catch { }
}
}
_logger.Msg($"[MP Bridge] Found {(cloneTexts != null ? cloneTexts.Count : 0)} TMP component(s) in cloned button.");
// ── Step 3: Rewire onClick ──
var btnExt = clone.GetComponent<ButtonExtended>();
if (btnExt != null)
{
try
{
btnExt.onClick = new ButtonExtended.ButtonClickedEvent();
btnExt.onClick.AddListener((System.Action)(() => ShowMultiplayerPanel()));
_logger.Msg("[MP Bridge] Wired ButtonExtended.onClick to ShowMultiplayerPanel.");
}
catch (Exception ex2)
{
_logger.Warning($"[MP Bridge] Failed to replace ButtonExtended.onClick: {ex2.Message}");
// Fallback: try removing listeners and adding ours
try
{
btnExt.onClick.RemoveAllListeners();
btnExt.onClick.AddListener((System.Action)(() => ShowMultiplayerPanel()));
}
catch { }
}
}
else
{
_logger.Warning("[MP Bridge] ButtonExtended not found on clone, trying Unity Button fallback.");
// Fallback: try standard Unity Button
var btn = clone.GetComponent<Button>();
if (btn != null)
{
try
{
btn.onClick = new Button.ButtonClickedEvent();
btn.onClick.AddListener((System.Action)(() => ShowMultiplayerPanel()));
}
catch { }
}
}
_menuButton = clone;
_logger.Msg("[MP Bridge] Multiplayer button injected into main menu.");
}
catch (Exception ex)
{
CrashLog.LogException("InjectMainMenuButton", ex);
}
}
// ═══════════════════════════════════════════════════════════════════════
// IMGUI Multiplayer Panel
// ═══════════════════════════════════════════════════════════════════════
public void ShowMultiplayerPanel()
{
_showPanel = true;
try
{
var es = UnityEngine.EventSystems.EventSystem.current;
if (es != null)
{
_mpDisabledEventSystem = es;
es.enabled = false;
}
}
catch { }
}
public void HideMultiplayerPanel()
{
_showPanel = false;
if (_mpDisabledEventSystem != null)
_mpReenableCountdown = 2;
}
public void DrawGUI()
{
try
{
if (!_showPanel) return;
if (!_stylesInitialized)
InitStyles();
_panelRect = new Rect((Screen.width - 400) / 2, (Screen.height - 400) / 2, 400, 420);
GUI.DrawTexture(_panelRect, _windowBg);
// Title bar dragging (simplified for brevity)
GUI.Label(new Rect(_panelRect.x + 20, _panelRect.y + 15, 300, 30), "MULTIPLAYER", _titleStyle);
if (GUI.Button(new Rect(_panelRect.x + _panelRect.width - 35, _panelRect.y + 10, 25, 25), "X", _buttonStyle))
HideMultiplayerPanel();
float contentY = _panelRect.y + 60;
float margin = 25;
float innerW = _panelRect.width - (margin * 2);
bool connected = _isConnected() != 0;
if (!connected)
{
// Join / Host screen
GUI.Label(new Rect(_panelRect.x + margin, contentY, innerW, 25), "ROOM CODE (UPPERCASE)", _labelStyle);
contentY += 30;
// Handle room code field
Rect fieldRect = new Rect(_panelRect.x + margin, contentY, innerW, 40);
if (Event.current.type == EventType.MouseDown && fieldRect.Contains(Event.current.mousePosition))
{
_roomCodeFieldFocused = true;
Event.current.Use();
}
GUI.DrawTexture(fieldRect, _fieldBg);
if (_roomCodeFieldFocused)
{
GUI.DrawTexture(fieldRect, _fieldActiveBg);
}
string displayText = _roomCode ?? "";
if (_roomCodeFieldFocused)
{
_cursorBlinkTimer += Time.deltaTime;
if (_cursorBlinkTimer >= 0.5f)
{
_cursorVisible = !_cursorVisible;
_cursorBlinkTimer = 0f;
}
if (_cursorVisible) displayText += "|";
}
var prevAlign = _labelStyle.alignment;
_labelStyle.alignment = TextAnchor.MiddleCenter;
GUI.Label(fieldRect, displayText, _labelStyle);
_labelStyle.alignment = prevAlign;
contentY += 60;
if (GUI.Button(new Rect(_panelRect.x + margin, contentY, innerW, 50), "JOIN GAME", _buttonStyle))
{
_roomCodeFieldFocused = false;
DoConnect();
}
contentY += 70;
GUI.DrawTexture(new Rect(_panelRect.x + margin, contentY, innerW, 1), _fieldBg);
contentY += 20;
if (GUI.Button(new Rect(_panelRect.x + margin, contentY, innerW, 50), "HOST GAME", _buttonStyle))
{
_roomCodeFieldFocused = false;
DoHost();
}
}
else
{
// Session details
string status = _isHosting ? "HOSTING SESSION" : "CONNECTED TO SESSION";
GUI.Label(new Rect(_panelRect.x + margin, contentY, innerW, 25), status, _statusStyle);
contentY += 35;
string codeToDisplay = _isHosting ? _displayRoomCode : _roomCode;
GUI.Label(new Rect(_panelRect.x + margin, contentY, innerW, 35), $"ROOM: {codeToDisplay}", _labelStyle);
contentY += 45;
uint players = _getPlayerCount != null ? _getPlayerCount() : 1;
GUI.Label(new Rect(_panelRect.x + margin, contentY, innerW, 25), $"Players: {players}", _labelStyle);
contentY += 40;
if (_isHosting)
{
if (GUI.Button(new Rect(_panelRect.x + margin, contentY, innerW, 50), "STOP HOSTING", _stopHostButtonStyle))
DoStopHosting();
}
else
{
if (GUI.Button(new Rect(_panelRect.x + margin, contentY, innerW, 50), "DISCONNECT", _stopHostButtonStyle))
DoDisconnect();
}
}
// Close when clicking outside
if (Event.current.type == EventType.MouseDown && !_panelRect.Contains(Event.current.mousePosition))
{
HideMultiplayerPanel();
}
}
catch (Exception ex)
{
CrashLog.LogException("MultiplayerBridge.DrawGUI", ex);
}
}
private void InitStyles()
{
_windowBg = MakeTex(1, 1, new Color(0.12f, 0.12f, 0.15f, 0.95f));
_buttonBg = MakeTex(1, 1, new Color(0.0f, 0.6f, 0.7f, 1f));
_buttonHoverBg = MakeTex(1, 1, new Color(0.0f, 0.8f, 0.9f, 1f));
_fieldBg = MakeTex(1, 1, new Color(0.2f, 0.2f, 0.25f, 1f));
_fieldActiveBg = MakeTex(1, 1, new Color(0.25f, 0.25f, 0.35f, 1f));
_stopBtnBg = MakeTex(1, 1, new Color(0.7f, 0.2f, 0.2f, 1f));
_stopBtnHoverBg = MakeTex(1, 1, new Color(0.9f, 0.3f, 0.3f, 1f));
_titleStyle = new GUIStyle { fontSize = 22, fontStyle = FontStyle.Bold, normal = { textColor = Color.white } };
_labelStyle = new GUIStyle { fontSize = 16, normal = { textColor = new Color(0.8f, 0.8f, 0.8f) } };
_statusStyle = new GUIStyle { fontSize = 18, fontStyle = FontStyle.Bold, normal = { textColor = new Color(0.0f, 0.9f, 0.6f) } };
_buttonStyle = new GUIStyle { fontSize = 18, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, normal = { background = _buttonBg, textColor = Color.white }, hover = { background = _buttonHoverBg, textColor = Color.white } };
_stopHostButtonStyle = new GUIStyle { fontSize = 18, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, normal = { background = _stopBtnBg, textColor = Color.white }, hover = { background = _stopBtnHoverBg, textColor = Color.white } };
_stylesInitialized = true;
}
private Texture2D MakeTex(int w, int h, Color col)
{
var tex = new Texture2D(w, h);
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) tex.SetPixel(x, y, col);
tex.Apply();
return tex;
}
private void CleanupAll()
{
_isHosting = false;
_displayRoomCode = "";
_discoveredSavePath = null;
_cachedSaveData = null;
}
public void Shutdown()
{
DoDisconnect();
CleanupAll();
}
}