8634792e2b
gregCore CI / build (push) Has been cancelled
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
2151 lines
85 KiB
C#
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();
|
|
}
|
|
}
|