Beta Release

This commit is contained in:
2026-09-26 19:37:28 +02:00
parent adca4d9167
commit e77ccf2f33
29 changed files with 6819 additions and 293 deletions
+254
View File
@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Discord Rich Presence über Discords lokale IPC-Schnittstelle (Named Pipe "discord-ipc-N"),
/// ohne zusätzliche DLL. Liest/schreibt direkt mit ReadFile/WriteFile (Unitys altes Mono kommt
/// mit Pipes in FileStream nicht zurecht). Läuft in einem Hintergrund-Thread; ist Discord nicht
/// offen, wird es alle 15 s erneut versucht. Statusmeldungen landen über TakeLog() in der Konsole.
/// </summary>
public class DiscordPresence
{
/// <summary>
/// Application ID aus dem Discord Developer Portal (https://discord.com/developers/applications).
/// Unter "Rich Presence → Art Assets" ein Bild mit dem Namen "logo" hochladen (branding/logo-512.png).
/// </summary>
public const string ApplicationId = "1553384871303450664";
const string ServersUrl = "https://msco.mbm.wtf/servers";
const int RetrySeconds = 15;
readonly object gate = new object();
readonly Queue<string> log = new Queue<string>();
string pendingActivity; // JSON der Aktivität, die noch gesendet werden muss
string lastActivity;
string lastStatus;
Thread worker;
volatile bool running;
int nonce;
int pid;
public static bool Configured
{
get { return ApplicationId.Trim('0').Length > 0; }
}
public void Start()
{
if (running) return;
if (!Configured)
{
ModConsole.Print("[MSCO] Discord Rich Presence: no application id set (DiscordPresence.ApplicationId)");
return;
}
if (Application.platform != RuntimePlatform.WindowsPlayer && Application.platform != RuntimePlatform.WindowsEditor) return;
try { pid = System.Diagnostics.Process.GetCurrentProcess().Id; } catch { pid = 0; }
running = true;
worker = new Thread(Run) { IsBackground = true, Name = "MSCO Discord" };
worker.Start();
}
public void Stop()
{
running = false;
}
/// <summary>Statusmeldungen des Hintergrund-Threads (im Hauptthread in die Konsole schreiben).</summary>
public string TakeLog()
{
lock (gate) return log.Count > 0 ? log.Dequeue() : null;
}
void Status(string text)
{
lock (gate)
{
if (text == lastStatus) return; // gleiche Meldung nicht alle 15 s wiederholen
lastStatus = text;
log.Enqueue(text);
}
}
/// <summary>Neue Anzeige setzen (wird nur gesendet, wenn sie sich geändert hat).</summary>
public void SetActivity(string details, string state, int partySize, int partyMax, string partyId, long startUnix)
{
var sb = new StringBuilder();
sb.Append("{\"details\":").Append(Json(details));
if (!string.IsNullOrEmpty(state)) sb.Append(",\"state\":").Append(Json(state));
if (startUnix > 0) sb.Append(",\"timestamps\":{\"start\":").Append(startUnix).Append('}');
sb.Append(",\"assets\":{\"large_image\":\"logo\",\"large_text\":\"My Summer Car Online\"}");
if (partySize > 0 && partyMax >= partySize)
sb.Append(",\"party\":{\"id\":").Append(Json(partyId ?? "msco")).Append(",\"size\":[").Append(partySize).Append(',').Append(partyMax).Append("]}");
sb.Append(",\"buttons\":[{\"label\":\"MSCO Servers\",\"url\":").Append(Json(ServersUrl)).Append("}]}");
string activity = sb.ToString();
lock (gate)
{
if (activity == lastActivity) return;
lastActivity = activity;
pendingActivity = activity;
}
}
// ---------------------------------------------------------------- Hintergrund-Thread
void Run()
{
while (running)
{
IntPtr pipe = InvalidHandle;
try
{
pipe = Connect();
if (pipe == InvalidHandle)
{
Status("Discord not running (will retry)");
Sleep(RetrySeconds);
continue;
}
Write(pipe, 0, "{\"v\":1,\"client_id\":" + Json(ApplicationId) + "}");
string ready = Read(pipe);
if (ready.IndexOf("READY", StringComparison.Ordinal) < 0) throw new Exception("Unexpected handshake reply: " + Short(ready));
Status("Discord connected");
lock (gate) pendingActivity = lastActivity; // nach (Wieder-)Verbindung erneut senden
while (running)
{
string activity;
lock (gate)
{
activity = pendingActivity;
pendingActivity = null;
}
if (activity != null)
{
Write(pipe, 1, "{\"cmd\":\"SET_ACTIVITY\",\"args\":{\"pid\":" + pid + ",\"activity\":" + activity +
"},\"nonce\":\"" + (++nonce) + "\"}");
string reply = Read(pipe);
if (reply.IndexOf("\"evt\":\"ERROR\"", StringComparison.Ordinal) >= 0)
Status("Discord rejected the activity: " + Short(reply));
}
Thread.Sleep(1000);
}
}
catch (Exception e)
{
Status("Discord connection lost: " + e.Message);
}
finally
{
if (pipe != InvalidHandle) CloseHandle(pipe);
}
if (running) Sleep(RetrySeconds);
}
}
void Sleep(int seconds)
{
for (int i = 0; i < seconds * 10 && running; i++) Thread.Sleep(100);
}
static IntPtr Connect()
{
for (int i = 0; i < 10; i++)
{
IntPtr handle = CreateFile(@"\\.\pipe\discord-ipc-" + i, GenericRead | GenericWrite, 0, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero);
if (handle != InvalidHandle) return handle;
}
return InvalidHandle;
}
/// <summary>Discord-IPC-Frame: int32 Opcode, int32 Länge, JSON (UTF-8).</summary>
static void Write(IntPtr pipe, int opcode, string json)
{
byte[] payload = Encoding.UTF8.GetBytes(json);
var frame = new byte[8 + payload.Length];
BitConverter.GetBytes(opcode).CopyTo(frame, 0);
BitConverter.GetBytes(payload.Length).CopyTo(frame, 4);
Buffer.BlockCopy(payload, 0, frame, 8, payload.Length);
int written;
if (!WriteFile(pipe, frame, frame.Length, out written, IntPtr.Zero) || written != frame.Length)
throw new Exception("WriteFile failed (" + Marshal.GetLastWin32Error() + ")");
}
static string Read(IntPtr pipe)
{
byte[] header = ReadExactly(pipe, 8);
int opcode = BitConverter.ToInt32(header, 0);
int length = BitConverter.ToInt32(header, 4);
if (length < 0 || length > 64 * 1024) throw new Exception("Bad Discord frame");
string json = Encoding.UTF8.GetString(ReadExactly(pipe, length));
if (opcode == 2) throw new Exception("Discord closed the connection: " + Short(json));
return json;
}
static byte[] ReadExactly(IntPtr pipe, int count)
{
var data = new byte[count];
int offset = 0;
var chunk = new byte[count];
while (offset < count)
{
int read;
if (!ReadFile(pipe, chunk, count - offset, out read, IntPtr.Zero) || read <= 0)
throw new Exception("ReadFile failed (" + Marshal.GetLastWin32Error() + ")");
Buffer.BlockCopy(chunk, 0, data, offset, read);
offset += read;
}
return data;
}
static string Short(string s)
{
return s == null ? "" : s.Length > 160 ? s.Substring(0, 160) + "..." : s;
}
static string Json(string value)
{
var sb = new StringBuilder("\"");
foreach (char c in value ?? "")
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': break;
case '\t': sb.Append(' '); break;
default:
if (c < 0x20) break;
sb.Append(c);
break;
}
}
return sb.Append('"').ToString();
}
static readonly IntPtr InvalidHandle = new IntPtr(-1);
const uint GenericRead = 0x80000000;
const uint GenericWrite = 0x40000000;
const uint OpenExisting = 3;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern IntPtr CreateFile(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadFile(IntPtr handle, byte[] buffer, int toRead, out int read, IntPtr overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteFile(IntPtr handle, byte[] buffer, int toWrite, out int written, IntPtr overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr handle);
}
}