MSCO 2.10.0: mod support, ESC no longer quits from the main menu

- MscoMod API (ModApi.cs) and ModLoader: own mods from MSCO\Mods, hooks for menu/game/update/GUI,
  session and player events, Send/OnMessage between players (MsgType.ModMessage)
- Server mods: ModList before Welcome, consent prompt (INSTALL & JOIN), download with SHA-256 check
  into MSCO\Mods\Servers, active only on that server; game start waits for the download
- Mods page in the MSCO menu, console command "mods"
- Server browser: MODS pill, VANILLA/MODDED filters, mod list in the server profile
- PlayMaker ApplicationQuit is ignored in the main menu (ESC used to close the game)
- MODDING.md and examples/HelloMod

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 01:00:16 +02:00
co-authored by Claude Opus 5.5
parent 027296d27c
commit 4c1366b5d9
16 changed files with 815 additions and 15 deletions
+181 -9
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using UnityEngine;
@@ -192,6 +192,7 @@ namespace MSCO
objects.LocalId = localId;
AddPlayer(localId, playerName, true);
SetStatus("Hosting on port " + port + " - waiting for players...");
ModLoader.SessionStart();
}
catch (System.Exception e)
{
@@ -883,6 +884,7 @@ namespace MSCO
objects.LocalId = localId;
AddPlayer(localId, OwnName(), true);
SetStatus("Steam lobby ready - invite your friends!");
ModLoader.SessionStart();
}
void OnSteamLobbyEntered(Steamworks.CSteamID lobby, Steamworks.CSteamID owner)
@@ -1339,6 +1341,10 @@ namespace MSCO
connectingToServer = false;
readySent = false;
pendingSnapshot = null;
PendingMods = null;
downloadingMods.Clear();
deferredStart = null;
ModLoader.SessionEnd();
SteamLobby.CancelAuthTicket();
passenger.Exit(localPlayer, playerCamera); // Beifahrer: neben das Auto stellen
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
@@ -1379,7 +1385,7 @@ namespace MSCO
|| t == MsgType.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem
|| t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage
|| t == MsgType.TrafficStates || t == MsgType.WorldVars || t == MsgType.HingeStates
|| t == MsgType.VehicleControls;
|| t == MsgType.VehicleControls || t == MsgType.ModMessage;
}
void OnMessage(int peerId, Message m)
@@ -1499,9 +1505,39 @@ namespace MSCO
reconnectMessage = null; // (wieder) verbunden
reconnectAttempts = 0;
SetStatus(dedicated ? "Connected to server - loading..." : "Connected - waiting for host to start...");
if (PendingMods != null) SetStatus("This server uses mods - confirm to download them");
ModLoader.SessionStart();
break;
}
case MsgType.ModList:
{
var list = new List<ServerModInfo>();
int n = r.ReadInt32();
for (int i = 0; i < n && i < 64; i++)
list.Add(new ServerModInfo { Id = r.ReadString(), Name = r.ReadString(), Version = r.ReadString(), Sha256 = r.ReadString(), Size = r.ReadInt32() });
ResolveServerMods(list);
break;
}
case MsgType.ModFilePart:
{
string id = r.ReadString();
int total = r.ReadInt32(), offset = r.ReadInt32(), len = r.ReadInt32();
byte[] part = r.ReadBytes(len);
ServerModInfo info = downloadingMods.Find(x => x.Id == id);
if (info == null || total != info.Size || total > MaxModBytes || offset < 0 || offset + part.Length > total) break;
if (info.Buffer == null) info.Buffer = new byte[total];
System.Buffer.BlockCopy(part, 0, info.Buffer, offset, part.Length);
info.Received += part.Length;
SetStatus("Downloading mods... " + ModDownloadPercent() + "%");
break;
}
case MsgType.ModFileDone:
FinishModDownload(r.ReadString());
break;
case MsgType.Reject:
{
string why = r.ReadString();
@@ -1630,28 +1666,50 @@ namespace MSCO
}
case MsgType.StartGame:
{
if (!receivingSave) return;
receivingSave = false;
// Später beigetreten: nach dem Laden neben den Host setzen
teleportToHost = r.BaseStream.Length > 0 && r.ReadBoolean();
// Server mit "neues Spiel" (Panel): kein Spielstand - hier ein neues Spiel starten (Server ab 2.9.3)
bool serverNewGame = r.BaseStream.Length - r.BaseStream.Position >= 1 && r.ReadBoolean();
if (serverNewGame)
// Fehlen noch Mods des Servers: erst nach Zustimmung + Download ins Spiel
if (PendingMods != null || ModsDownloading)
{
SetStatus("The server starts a new game - creating a fresh world...");
MscoLog.Info("[MSCO] Server has a new game - starting a fresh world");
MenuHelper.StartNewGame();
deferredStart = serverNewGame;
break;
}
SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
MenuHelper.StartGame();
BeginServerGame(serverNewGame);
break;
}
}
}
/// <summary>Spielstand ist da: ins Spiel (bzw. bei einem neuen Server-Spiel ein neues Spiel anlegen).</summary>
void BeginServerGame(bool serverNewGame)
{
if (serverNewGame)
{
SetStatus("The server starts a new game - creating a fresh world...");
MscoLog.Info("[MSCO] Server has a new game - starting a fresh world");
MenuHelper.StartNewGame();
return;
}
SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
MenuHelper.StartGame();
}
void HandleGameMessage(int from, MsgType type, BinaryReader r)
{
if (from == localId) return;
if (type == MsgType.ModMessage)
{
string modId = r.ReadString();
int len = r.ReadInt32();
if (len < 0 || len > MaxModMessageBytes) return;
ModLoader.Message(modId, from, r.ReadBytes(len));
return;
}
if (type == MsgType.Chat)
{
string text = r.ReadString();
@@ -1724,6 +1782,117 @@ namespace MSCO
}
/// <summary>Spiel-Nachricht an alle anderen (Client: über den Host).</summary>
// ---------------------------------------------------------------- MSCO-Mods
public sealed class ServerModInfo
{
public string Id, Name, Version, Sha256;
public int Size, Received;
public byte[] Buffer;
}
const int MaxModBytes = 20 * 1024 * 1024, MaxModMessageBytes = 60000;
/// <summary>Mods, die der Server verlangt und die hier fehlen - warten auf Zustimmung des Spielers (null = keine).</summary>
public List<ServerModInfo> PendingMods { get; private set; }
readonly List<ServerModInfo> downloadingMods = new List<ServerModInfo>();
readonly List<string> serverModShas = new List<string>();
bool? deferredStart;
public bool ModsDownloading { get { return downloadingMods.Count > 0; } }
int ModDownloadPercent()
{
long total = 0, done = 0;
foreach (ServerModInfo m in downloadingMods) { total += m.Size; done += m.Received; }
return total > 0 ? (int)(done * 100 / total) : 100;
}
/// <summary>Liste vom Server: vorhandene Mods (gleiche Prüfsumme) einschalten, fehlende zum Bestätigen merken.</summary>
void ResolveServerMods(List<ServerModInfo> list)
{
serverModShas.Clear();
var missing = new List<ServerModInfo>();
foreach (ServerModInfo m in list)
{
if (string.IsNullOrEmpty(m.Id) || m.Id.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || m.Id.Contains("..")) continue;
serverModShas.Add(m.Sha256);
if (ModLoader.FindBySha(m.Sha256) != null) continue;
// Schon einmal geladen (gleiche Datei auf der Platte)?
string file = ModLoader.ServerFile(m.Id);
if (File.Exists(file) && ModLoader.Sha256Of(File.ReadAllBytes(file)) == m.Sha256 && ModLoader.Load(file, true) != null) continue;
missing.Add(m);
}
ModLoader.ActivateServerMods(serverModShas);
PendingMods = missing.Count > 0 ? missing : null;
if (PendingMods != null) MscoLog.Info("[MSCO] The server uses " + list.Count + " mod(s), " + missing.Count + " missing");
}
/// <summary>Spieler stimmt zu: fehlende Mods vom Server laden.</summary>
public void AcceptServerMods()
{
if (PendingMods == null || session == null) return;
downloadingMods.Clear();
downloadingMods.AddRange(PendingMods);
PendingMods = null;
foreach (ServerModInfo m in downloadingMods)
{
string id = m.Id;
session.SendTo(NetSession.HostId, MsgType.ModFileRequest, NetSession.Build(w => w.Write(id)));
}
SetStatus("Downloading mods... 0%");
}
/// <summary>Spieler lehnt ab: ohne die Mods geht es auf diesem Server nicht.</summary>
public void DeclineServerMods()
{
PendingMods = null;
serverReason = "This server needs its mods - you declined to install them.";
reconnectMessage = null;
if (session != null) session.Close("Declined the server's mods");
OnSessionEnded("Declined the server's mods");
}
void FinishModDownload(string id)
{
ServerModInfo m = downloadingMods.Find(x => x.Id == id);
if (m == null) return;
downloadingMods.Remove(m);
if (m.Buffer == null || m.Received < m.Size || ModLoader.Sha256Of(m.Buffer) != m.Sha256)
{
MscoLog.Warning("[MSCO] Mod " + m.Name + " arrived damaged (checksum mismatch) - not loaded");
serverReason = "The mod " + m.Name + " could not be downloaded correctly - try again.";
if (session != null) session.Close("Mod download failed");
OnSessionEnded("Mod download failed");
return;
}
try
{
Directory.CreateDirectory(ModLoader.ServerFolder);
File.WriteAllBytes(ModLoader.ServerFile(m.Id), m.Buffer);
}
catch (System.Exception e) { MscoLog.Warning("[MSCO] Could not save mod " + m.Name + ": " + e.Message); }
if (ModLoader.Load(ModLoader.ServerFile(m.Id), true) == null)
MscoLog.Warning("[MSCO] Mod " + m.Name + " could not be started - see the log");
ModLoader.ActivateServerMods(serverModShas);
if (MenuHelper.InMenu) ModLoader.MenuLoad();
if (downloadingMods.Count > 0) return;
SetStatus("Mods installed - loading the world...");
if (deferredStart != null)
{
bool newGame = deferredStart.Value;
deferredStart = null;
BeginServerGame(newGame);
}
}
/// <summary>Für MscoMod.Send: Daten einer Mod an dieselbe Mod bei allen anderen Spielern.</summary>
public bool SendModMessage(string modId, byte[] data)
{
if (session == null || !handshakeDone || string.IsNullOrEmpty(modId) || data == null || data.Length > MaxModMessageBytes) return false;
SendGame(MsgType.ModMessage, NetSession.Build(w => { w.Write(modId); w.Write(data.Length); w.Write(data); }));
return true;
}
void SendGame(MsgType type, byte[] payload)
{
if (session == null) return;
@@ -1736,9 +1905,11 @@ namespace MSCO
PlayerInfo AddPlayer(int id, string name, bool isLocal)
{
PlayerInfo p;
if (!players.TryGetValue(id, out p)) players[id] = p = new PlayerInfo { Id = id };
bool isNew = !players.TryGetValue(id, out p);
if (isNew) players[id] = p = new PlayerInfo { Id = id };
p.Name = name;
p.IsLocal = isLocal;
if (isNew && !isLocal) ModLoader.PlayerJoined(id, name);
return p;
}
@@ -1746,6 +1917,7 @@ namespace MSCO
{
PlayerInfo p;
if (!players.TryGetValue(id, out p)) return;
if (!p.IsLocal) ModLoader.PlayerLeft(id, p.Name);
DestroyAvatar(p);
objects.ReleaseAllOf(id);
players.Remove(id);