2025-08-19 22:41:31 +02:00

1207 lines
42 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// CS2WebSocketTelemetryPlugin.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using Microsoft.Extensions.Logging;
namespace WsTelemetry;
[MinimumApiVersion(175)]
public class WebSocketTelemetryPlugin : BasePlugin
{
public override string ModuleName => "WS Telemetry";
public override string ModuleVersion => "1.6.0";
public override string ModuleAuthor => "you + ChatGPT";
public override string ModuleDescription => "WS(S)-Server: Spielerpositionen + Blickrichtung + Map";
// --- Konfiguration ---
private volatile bool _enabled = false;
private volatile int _sendHz = 10;
private volatile string _mapName = "";
// WS Bind-Info
private volatile string _bindHost = "0.0.0.0";
private volatile int _bindPort = 8081;
private volatile string _bindPath = "/telemetry";
private volatile bool _useTls = false;
// TLS Zertifikat (PFX)
private volatile string _certPath = "";
private volatile string _certPassword = "";
private X509Certificate2? _serverCert;
// --- Server / Clients ---
private TcpListener? _listener;
private CancellationTokenSource? _serverCts;
private Task? _acceptTask;
private volatile bool _serverRunning = false;
// --- Konfigurations-Laden ---
private const string ConfigFileName = "config.json";
private sealed class Cfg
{
public string? Url { get; set; }
public string? CertPath { get; set; }
public string? CertPassword { get; set; }
public int? SendHz { get; set; }
}
private void LoadAndApplyConfig(bool generateIfMissing = true)
{
try
{
var path = Path.Combine(ModuleDirectory, ConfigFileName);
if (!File.Exists(path) && generateIfMissing)
{
var example = new Cfg
{
Url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}",
CertPath = string.IsNullOrWhiteSpace(_certPath) ? "cert.pfx" : _certPath,
CertPassword = _certPassword,
SendHz = _sendHz
};
var jsonEx = JsonSerializer.Serialize(example, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(path, jsonEx, Encoding.UTF8);
Logger.LogInformation($"[WS] Beispiel-Konfiguration erzeugt: {path}");
}
if (!File.Exists(path))
{
Logger.LogWarning($"[WS] Keine {ConfigFileName} gefunden. Verwende Defaults.");
return;
}
var json = File.ReadAllText(path, Encoding.UTF8);
var cfg = JsonSerializer.Deserialize<Cfg>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
) ?? new Cfg();
// URL anwenden
if (!string.IsNullOrWhiteSpace(cfg.Url))
{
if (Uri.TryCreate(cfg.Url, UriKind.Absolute, out var uri) && (uri.Scheme == "ws" || uri.Scheme == "wss"))
{
_useTls = uri.Scheme == "wss";
_bindHost = string.IsNullOrWhiteSpace(uri.Host) ? "0.0.0.0" : uri.Host;
_bindPort = uri.IsDefaultPort ? (_useTls ? 443 : 80) : uri.Port;
_bindPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
if (_bindHost == "127.0.0.1") _bindHost = "0.0.0.0";
}
else
{
Logger.LogWarning($"[WS] Ungültige URL in {ConfigFileName}: '{cfg.Url}'");
}
}
// Zertifikat anwenden
if (!string.IsNullOrWhiteSpace(cfg.CertPath))
{
_certPath = Path.IsPathRooted(cfg.CertPath)
? cfg.CertPath
: Path.Combine(ModuleDirectory, cfg.CertPath);
_serverCert = null; // beim nächsten Start neu laden
}
if (cfg.CertPassword != null)
{
_certPassword = cfg.CertPassword;
_serverCert = null;
}
// Sendefrequenz
if (cfg.SendHz is int hz && hz >= 1 && hz <= 128) _sendHz = hz;
Logger.LogInformation($"[WS] Konfiguration geladen ({_bindHost}:{_bindPort}{_bindPath}, tls={_useTls}, hz={_sendHz})");
}
catch (Exception ex)
{
Logger.LogError($"[WS] Konnte {ConfigFileName} nicht laden/anwenden: {ex.Message}");
}
}
private void SaveConfig()
{
try
{
var path = Path.Combine(ModuleDirectory, ConfigFileName);
var url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}";
var cp = _certPath;
try
{
if (!string.IsNullOrWhiteSpace(cp))
{
if (Path.IsPathRooted(cp) && cp.StartsWith(ModuleDirectory, StringComparison.OrdinalIgnoreCase))
cp = Path.GetRelativePath(ModuleDirectory, cp);
}
}
catch { /* not fatal */ }
var cfg = new Cfg
{
Url = url,
CertPath = cp,
CertPassword = _certPassword,
SendHz = _sendHz
};
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(path, json, Encoding.UTF8);
Logger.LogInformation($"[WS] Konfiguration gespeichert: {path}");
}
catch (Exception ex)
{
Logger.LogError($"[WS] Konnte Konfiguration nicht speichern: {ex.Message}");
}
}
private class ClientState
{
public required TcpClient Tcp;
public required Stream Stream; // NetworkStream oder SslStream
public readonly object SendLock = new();
public readonly CancellationTokenSource Cts = new();
}
private readonly ConcurrentDictionary<int, ClientState> _clients = new();
private int _clientSeq = 0;
// --- Outgoing Queue ---
private readonly ConcurrentQueue<string> _outbox = new();
private readonly AutoResetEvent _sendSignal = new(false);
// --- Tick / Sampling ---
private double _accumulator = 0.0;
private const double MaxFrameDt = 0.25;
private DateTime _lastTick = DateTime.UtcNow;
// --- Stabiler Aim ---
private readonly ConcurrentDictionary<ulong, (float pitch, float yaw)> _lastAimByPlayer = new();
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private static bool IsFinite(float v) => !(float.IsNaN(v) || float.IsInfinity(v)) && Math.Abs(v) < 1e6;
private static float NormalizeYaw(float yaw)
{
yaw %= 360f;
if (yaw < 0) yaw += 360f;
return yaw;
}
private static float ClampPitch(float pitch)
{
if (pitch < -89f) pitch = -89f;
if (pitch > 89f) pitch = 89f;
return pitch;
}
// =========================
// Blickrichtung (Client-Kamera) EyeAngles bevorzugt
// =========================
private static bool IsAlive(dynamic pawn)
{
try { int ls = (int)pawn.LifeState; return ls == 0; } catch { }
try { return (bool)pawn.IsAlive; } catch { }
try { int hp = (int)pawn.Health; return hp > 0; } catch { }
return true;
}
private static bool TryReadAngles(dynamic src, out float pitch, out float yaw, out float roll)
{
pitch = 0f; yaw = 0f; roll = 0f;
// Kamera-/Aim-Winkel
try { var a = src.EyeAngles; pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
// Modell-/Knotenorientierung
try { var a = src.AbsRotation; pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
try { var a = src.ViewAngles; pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
try { pitch = (float)src.Pitch; yaw = (float)src.Yaw; roll = 0f; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
return false;
}
/// <summary> Winkel vorzugsweise vom SceneNode; Fallback: NodeToWorld.Angles, root.AbsRotation. </summary>
private static bool TryGetAnglesFromSceneNode(dynamic root, out float pitch, out float yaw, out float roll)
{
pitch = 0f; yaw = 0f; roll = 0f;
try
{
dynamic node = root?.GameSceneNode;
if (node != null)
{
try { var r = node.AbsRotation; pitch = (float)r.X; yaw = (float)r.Y; roll = (float)r.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
try { var tf = node.NodeToWorld; var a = tf.Angles; pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
}
try { var r = root.AbsRotation; pitch = (float)r.X; yaw = (float)r.Y; roll = (float)r.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
}
catch { }
return false;
}
/// <summary> Observer-Target via Pawn (nicht Controller!), robust über verschiedene Felder. </summary>
private static dynamic? TryGetObserverTargetFromPawn(dynamic pawn)
{
// typische Felder am Observer-Pawn / Observer-Komponente
try { var os = pawn.ObserverServices; var h = os?.m_hObserverTarget; var v = (h != null ? (h.Value ?? h) : null); if (v != null) return v; } catch { }
try { var h = pawn.m_hObserverTarget; var v = (h != null ? (h.Value ?? h) : null); if (v != null) return v; } catch { }
try { var h = pawn.m_hLastObserverTarget; var v = (h != null ? (h.Value ?? h) : null); if (v != null) return v; } catch { }
try { var v = pawn.ObserverTarget; if (v != null) return (v.Value ?? v); } catch { }
return null;
}
/// <summary> Irgendeine Entität in einen Pawn auflösen (Controller→Pawn, Entity→Pawn, bereits Pawn). </summary>
private static dynamic? AsPawn(dynamic entity)
{
if (entity == null) return null;
try { var p = entity.PlayerPawn; if (p != null && p.IsValid) return p.Value ?? p; } catch { }
try { var p = entity.Pawn; if (p != null && p.IsValid) return p.Value ?? p; } catch { }
try { var _ = entity.EyeAngles; return entity; } catch { }
try { var _ = entity.AbsOrigin; return entity; } catch { }
return null;
}
/// <summary> Pawn, dessen Kamera wir darstellen: lebend → eigener Pawn; sonst → Observer-Target (vom Pawn aus). </summary>
private static bool TryGetClientCameraPawn(CCSPlayerController ctrl, dynamic pawn, out dynamic camPawn)
{
camPawn = null;
try { if (pawn != null && IsAlive(pawn)) { camPawn = pawn; return true; } } catch { }
var tgtEnt = TryGetObserverTargetFromPawn(pawn);
var tgtPawn = AsPawn(tgtEnt);
if (tgtPawn != null) { camPawn = tgtPawn; return true; }
return false;
}
/// <summary> Endgültige View-Winkel (EyeAngles bevorzugt, sonst SceneNode/AbsRotation, dann Fallbacks). </summary>
private static bool TryGetViewAngles(CCSPlayerController ctrl, dynamic pawn, out float pitch, out float yaw, out float roll)
{
pitch = 0f; yaw = 0f; roll = 0f;
dynamic camPawn;
if (TryGetClientCameraPawn(ctrl, pawn, out camPawn))
{
// 1) Echte Kamera-/Aim-Winkel
try
{
var a = camPawn.EyeAngles;
pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z;
if (IsFinite(pitch) && IsFinite(yaw)) return true;
}
catch { }
// 2) Modellorientierung als Fallback
if (TryGetAnglesFromSceneNode(camPawn, out pitch, out yaw, out roll))
return true;
try
{
var r = camPawn.AbsRotation;
pitch = (float)r.X; yaw = (float)r.Y; roll = (float)r.Z;
if (IsFinite(pitch) && IsFinite(yaw)) return true;
}
catch { }
}
// 3) letzte Fallbacks
if (TryReadAngles(pawn, out pitch, out yaw, out roll)) return true;
return TryReadAngles(ctrl, out pitch, out yaw, out roll);
}
private (float pitch, float yaw) StoreAim(CCSPlayerController p, float pitch, float yaw)
{
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
_lastAimByPlayer[sid] = (pitch, yaw);
return (pitch, yaw);
}
private static bool IsTiny(float v) => MathF.Abs(v) <= 1e-5f;
private static bool IsTinyPair(float a, float b) => IsTiny(a) && IsTiny(b);
private (float pitch, float yaw) GetStableAim(CCSPlayerController p, dynamic pawn)
{
float vp=0, vy=0, vr=0;
// 1) Bevorzugt: echte Kamera-/View-Winkel
if (TryGetViewAngles(p, pawn, out vp, out vy, out vr))
{
vp = ClampPitch(vp); vy = NormalizeYaw(vy);
if (!IsTinyPair(vp, vy)) // 0/0 ist verdächtig → nicht akzeptieren
return StoreAim(p, vp, vy);
}
// 2) Fallback: Pawn EyeAngles (falls vorhanden)
try
{
dynamic a = pawn?.EyeAngles;
float px = (float)a.X, py = (float)a.Y;
if (IsFinite(px) && IsFinite(py))
{
px = ClampPitch(px); py = NormalizeYaw(py);
if (!IsTinyPair(px, py))
return StoreAim(p, px, py);
}
}
catch { }
// 3) Fallback: Modell-/Feet-Yaw aus AbsRotation
try
{
dynamic r = pawn?.AbsRotation;
float ryaw = (float)r.Y;
if (IsFinite(ryaw) && !IsTiny(ryaw))
{
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
float pitch = _lastAimByPlayer.TryGetValue(sid, out var last) ? last.pitch : 0f;
return StoreAim(p, pitch, NormalizeYaw(ryaw));
}
}
catch { }
// 4) Fallback: Bewegungsrichtung (nur wenn sich der Spieler bewegt)
try
{
var vel = pawn?.AbsVelocity;
float vx = (float)vel.X, vy2 = (float)vel.Y;
float sp = MathF.Sqrt(vx * vx + vy2 * vy2);
if (sp > 1f)
{
float vyaw = NormalizeYaw(MathF.Atan2(vy2, vx) * 180f / MathF.PI);
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
float pitch = _lastAimByPlayer.TryGetValue(sid, out var last) ? last.pitch : 0f;
return StoreAim(p, pitch, vyaw);
}
}
catch { }
// 5) Letzter gültiger Aim oder neutral
{
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
if (_lastAimByPlayer.TryGetValue(sid, out var last)) return last;
return (0f, 0f);
}
}
private static (float pitch, float yaw, float roll) ReadEyeAngles(dynamic pawn)
{
try
{
var ea = pawn?.EyeAngles;
float pitch = (float)ea.X;
float yaw = (float)ea.Y;
float roll = (float)ea.Z;
// wie üblich begrenzen/normalisieren
if (IsFinite(pitch) && IsFinite(yaw))
return (ClampPitch(pitch), NormalizeYaw(yaw), roll);
}
catch { }
return (0f, 0f, 0f);
}
public override void Load(bool hotReload)
{
Logger.LogInformation("[WS] Plugin geladen. Kommandos: css_ws_enable, css_ws_restart, css_ws_reloadcfg, css_ws_url, css_ws_rate, css_ws_cert, css_ws_certpwd, css_ws_sendmap");
RegisterListener<Listeners.OnTick>(OnTick);
_mapName = Server.MapName ?? "";
RegisterListener<Listeners.OnMapStart>(OnMapStart);
LoadAndApplyConfig();
_enabled = true;
StartWebSocket();
}
public override void Unload(bool hotReload)
{
StopWebSocket();
}
private void OnMapStart(string newMap)
{
_mapName = newMap ?? Server.MapName ?? "";
var payload = JsonSerializer.Serialize(new
{
type = "map",
name = _mapName,
t = NowMs()
});
Broadcast(payload);
Logger.LogInformation($"[WS] Map gewechselt: '{_mapName}' an Clients gesendet.");
}
// =========================
// Konsolen-Kommandos
// =========================
[ConsoleCommand("css_ws_enable", "Aktiviert/Deaktiviert den integrierten WS(S)-Server: css_ws_enable 1|0")]
[CommandHelper(minArgs: 1, usage: "<1|0>")]
public void CmdEnable(CCSPlayerController? caller, CommandInfo cmd)
{
var val = cmd.GetArg(1);
bool enable = val == "1" || val.Equals("true", StringComparison.OrdinalIgnoreCase);
if (enable == _enabled)
{
cmd.ReplyToCommand($"[WS] Bereits {_enabled}.");
return;
}
_enabled = enable;
cmd.ReplyToCommand($"[WS] Enabled = {_enabled}");
if (_enabled) StartWebSocket();
else StopWebSocket();
}
[ConsoleCommand("css_ws_reloadcfg", "Lädt die config.json neu und startet den WS(S)-Server ggf. neu")]
public void CmdReloadCfg(CCSPlayerController? caller, CommandInfo cmd)
{
var wasEnabled = _enabled;
LoadAndApplyConfig(generateIfMissing: false);
if (wasEnabled)
{
StopWebSocket();
StartWebSocket();
cmd.ReplyToCommand("[WS] Konfiguration neu geladen und Server neu gestartet.");
}
else
{
cmd.ReplyToCommand("[WS] Konfiguration neu geladen. Server ist deaktiviert (css_ws_enable 1 zum Starten).");
}
}
[ConsoleCommand("css_ws_restart", "Lädt config.json neu und startet den WS(S)-Server neu.")]
[CommandHelper(minArgs: 0, usage: "")]
public void CmdRestart(CCSPlayerController? caller, CommandInfo cmd)
{
try
{
// Config immer neu laden (ohne Beispieldatei zu erzeugen)
LoadAndApplyConfig(generateIfMissing: false);
// internen Zustand zurücksetzen
_lastTick = DateTime.UtcNow;
_accumulator = 0;
while (_outbox.TryDequeue(out _)) { }
// Server neu starten (nur wenn derzeit/enabled)
var wasEnabled = _enabled;
StopWebSocket();
if (wasEnabled)
StartWebSocket();
cmd.ReplyToCommand("[WS] Config neu geladen und neu gestartet.");
if (!wasEnabled)
cmd.ReplyToCommand("[WS] Hinweis: Server ist deaktiviert (css_ws_enable 1), Neustart war nur intern.");
}
catch (Exception ex)
{
cmd.ReplyToCommand($"[WS] Restart-Fehler: {ex.Message}");
}
}
[ConsoleCommand("css_ws_sendmap", "Sendet die aktuelle Karte an alle verbundenen Clients")]
public void CmdSendMap(CCSPlayerController? caller, CommandInfo cmd)
{
var payload = JsonSerializer.Serialize(new
{
type = "map",
name = _mapName,
t = NowMs()
});
Broadcast(payload);
cmd.ReplyToCommand($"[WS] Map '{_mapName}' an Clients gesendet.");
}
[ConsoleCommand("css_ws_url", "Setzt Bind-Host/Port/Pfad als ws[s]://host:port/path")]
[CommandHelper(minArgs: 1, usage: "<ws[s]://host:port/path>")]
public void CmdUrl(CCSPlayerController? caller, CommandInfo cmd)
{
var url = cmd.GetArg(1);
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != "ws" && uri.Scheme != "wss"))
{
cmd.ReplyToCommand($"[WS] Ungültige URL: {url}");
return;
}
_useTls = uri.Scheme == "wss";
_bindHost = string.IsNullOrWhiteSpace(uri.Host) ? "0.0.0.0" : uri.Host;
_bindPort = uri.IsDefaultPort ? (_useTls ? 443 : 80) : uri.Port;
_bindPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
if (_bindHost == "127.0.0.1") _bindHost = "0.0.0.0";
var scheme = _useTls ? "wss" : "ws";
cmd.ReplyToCommand($"[WS] Bind = {scheme}://{_bindHost}:{_bindPort}{_bindPath}");
SaveConfig();
if (_enabled) { StopWebSocket(); StartWebSocket(); }
}
[ConsoleCommand("css_ws_rate", "Sendefrequenz in Hz (Standard 10)")]
[CommandHelper(minArgs: 1, usage: "<hz>")]
public void CmdRate(CCSPlayerController? caller, CommandInfo cmd)
{
if (int.TryParse(cmd.GetArg(1), out var hz) && hz > 0 && hz <= 128)
{
_sendHz = hz;
SaveConfig();
cmd.ReplyToCommand($"[WS] Sendefrequenz = {_sendHz} Hz");
}
else cmd.ReplyToCommand("[WS] Ungültig. Bereich: 1..128");
}
[ConsoleCommand("css_ws_cert", "Setzt das TLS-Zertifikat (PFX-Datei)")]
[CommandHelper(minArgs: 1, usage: "<pfad-zum.pfx>")]
public void CmdCert(CCSPlayerController? caller, CommandInfo cmd)
{
var input = cmd.GetArg(1);
_certPath = Path.IsPathRooted(input) ? input : Path.Combine(ModuleDirectory, input);
_serverCert = null; // neu laden beim Start
cmd.ReplyToCommand($"[WS] Zertifikatspfad gesetzt: '{_certPath}'");
SaveConfig();
if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); }
}
[ConsoleCommand("css_ws_certpwd", "Setzt das Passwort für das PFX-Zertifikat (oder '-' zum Leeren)")]
[CommandHelper(minArgs: 1, usage: "<passwort|->")]
public void CmdCertPwd(CCSPlayerController? caller, CommandInfo cmd)
{
var pwd = cmd.GetArg(1);
_certPassword = pwd == "-" ? "" : pwd;
_serverCert = null; // neu laden beim Start
cmd.ReplyToCommand($"[WS] Zertifikatspasswort {(string.IsNullOrEmpty(_certPassword) ? "gelöscht" : "gesetzt")}.");
SaveConfig();
if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); }
}
// =========================
// Tick / Spieler-Snapshot
// =========================
private void OnTick()
{
if (!_enabled || !_serverRunning) return;
var now = DateTime.UtcNow;
var dt = (now - _lastTick).TotalSeconds;
_lastTick = now;
if (dt > MaxFrameDt) dt = MaxFrameDt;
_accumulator += dt;
var targetDt = 1.0 / Math.Max(1, _sendHz);
if (_accumulator < targetDt) return;
_accumulator = 0;
var playersList = new List<object>();
foreach (var p in Utilities.GetPlayers())
{
try
{
if (p == null || !p.IsValid || p.IsBot || p.IsHLTV) continue;
var pawnHandle = p.PlayerPawn;
if (pawnHandle == null || !pawnHandle.IsValid) continue;
var pawn = pawnHandle.Value;
if (pawn == null) continue;
// --- Position (SceneNode.AbsOrigin bevorzugt)
float posX, posY, posZ;
try
{
var node = pawn?.CBodyComponent?.SceneNode;
var org = node != null ? node.AbsOrigin : pawn.AbsOrigin;
posX = (float)org.X;
posY = (float)org.Y;
posZ = (float)org.Z;
}
catch
{
var org = pawn.AbsOrigin;
posX = (float)org.X;
posY = (float)org.Y;
posZ = (float)org.Z;
}
// --- Blickrichtung (Yaw aus EyeAngles, bereits normalisiert/geclamped)
var (_, eyeYaw, _) = ReadEyeAngles(pawn);
float yawDeg = eyeYaw;
// --- viewAngle wie im Beispiel: AbsRotation (Pitch/Yaw/Roll)
float angX = 0f, angY = 0f, angZ = 0f;
try
{
var ang = pawn.AbsRotation;
angX = (float)ang.X; // pitch
angY = (float)ang.Y; // yaw
angZ = (float)ang.Z; // roll
}
catch { }
// --- Alive-Status
bool isAlive = true;
try { int ls = (int)pawn.LifeState; isAlive = (ls == 0); } catch { }
if (!isAlive) { try { isAlive = ((int)pawn.Health) > 0; } catch { } }
// --- Minimales Player-Objekt + viewAngle
playersList.Add(new
{
steamId = p.AuthorizedSteamID?.SteamId64 ?? 0UL,
name = p.PlayerName,
team = p.TeamNum,
pos = new { x = posX, y = posY, z = posZ },
viewAngle = new { pitch = angX, yaw = angY, roll = angZ },
alive = isAlive
});
}
catch { /* Spieler überspringen bei Fehlern */ }
}
if (playersList.Count == 0) return;
var payload = new
{
type = "tick",
t = NowMs(),
players = playersList
};
Broadcast(JsonSerializer.Serialize(payload));
}
// =========================
// WS(S)-Server / Broadcast
// =========================
private void StartWebSocket()
{
StopWebSocket();
try
{
if (_useTls)
{
if (!TryLoadCertificate(out var _))
throw new Exception("TLS aktiv, aber kein funktionsfähiges PFX gefunden.");
}
IPAddress ip;
if (!IPAddress.TryParse(_bindHost, out ip))
ip = IPAddress.Any;
_listener = new TcpListener(ip, _bindPort);
_listener.Start();
_serverCts = new CancellationTokenSource();
_serverRunning = true;
var scheme = _useTls ? "wss" : "ws";
Logger.LogInformation($"[WS] Server lauscht auf {scheme}://{_bindHost}:{_bindPort}{_bindPath}");
_mapName = string.IsNullOrEmpty(Server.MapName) ? _mapName : Server.MapName!;
Broadcast(JsonSerializer.Serialize(new { type = "map", name = _mapName, t = NowMs() }));
_acceptTask = Task.Run(async () =>
{
var ct = _serverCts!.Token;
while (!ct.IsCancellationRequested)
{
TcpClient? tcp = null;
try
{
tcp = await _listener!.AcceptTcpClientAsync(ct);
_ = HandleClientAsync(tcp, ct);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
Logger.LogWarning($"[WS] Accept-Fehler: {ex.Message}");
tcp?.Close();
await Task.Delay(250, ct);
}
}
});
_ = Task.Run(async () =>
{
var ct = _serverCts!.Token;
while (!ct.IsCancellationRequested)
{
if (_outbox.IsEmpty) _sendSignal.WaitOne(200);
while (_outbox.TryDequeue(out var msg))
Broadcast(msg);
await Task.Delay(1, ct);
}
});
}
catch (Exception ex)
{
Logger.LogError($"[WS] Start fehlgeschlagen: {ex.Message}");
StopWebSocket();
}
}
private void StopWebSocket()
{
_serverRunning = false;
try { _serverCts?.Cancel(); } catch { }
try { _listener?.Stop(); } catch { }
_listener = null;
foreach (var kv in _clients)
{
try { kv.Value.Cts.Cancel(); } catch { }
try { kv.Value.Stream.Close(); } catch { }
try { kv.Value.Tcp.Close(); } catch { }
}
_clients.Clear();
_serverCts = null;
_acceptTask = null;
}
private void Broadcast(string text)
{
foreach (var kv in _clients)
{
var id = kv.Key;
var c = kv.Value;
try
{
SendTextFrame(c, text);
}
catch
{
_clients.TryRemove(id, out _);
try { c.Cts.Cancel(); } catch { }
try { c.Stream.Close(); } catch { }
try { c.Tcp.Close(); } catch { }
}
}
}
private async Task HandleClientAsync(TcpClient tcp, CancellationToken serverCt)
{
var id = Interlocked.Increment(ref _clientSeq);
tcp.NoDelay = true;
var baseStream = tcp.GetStream();
baseStream.ReadTimeout = 10000;
baseStream.WriteTimeout = 10000;
Stream stream = baseStream;
SslStream? ssl = null;
try
{
if (_useTls)
{
ssl = new SslStream(baseStream, leaveInnerStreamOpen: false);
try
{
await ssl.AuthenticateAsServerAsync(
_serverCert!,
clientCertificateRequired: false,
enabledSslProtocols: SslProtocols.Tls13 | SslProtocols.Tls12,
checkCertificateRevocation: false
);
}
catch (AuthenticationException aex)
{
Logger.LogError($"[WS] TLS-Handshake fehlgeschlagen: {aex.Message}. " +
$"Tipp: Stimmt das PFX und das CertPassword aus {ConfigFileName}?");
throw;
}
stream = ssl;
}
if (!await DoHandshakeAsync(stream, serverCt))
{
tcp.Close();
return;
}
var state = new ClientState { Tcp = tcp, Stream = stream };
_clients[id] = state;
Logger.LogInformation($"[WS] Client #{id} verbunden. Aktive: {_clients.Count}");
try
{
var nowMs = NowMs();
SendTextFrame(state, JsonSerializer.Serialize(new { type = "map", name = _mapName, t = nowMs }));
}
catch { }
await ReceiveLoop(state, serverCt);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Logger.LogWarning($"[WS] Client #{id} Fehler: {ex.Message}");
}
finally
{
_clients.TryRemove(id, out _);
try { stream.Close(); } catch { }
try { ssl?.Dispose(); } catch { }
try { baseStream.Close(); } catch { }
try { tcp.Close(); } catch { }
Logger.LogInformation($"[WS] Client #{id} getrennt. Aktive: {_clients.Count}");
}
}
// --- Minimaler WebSocket-Server: Handshake + Frames ---
private static async Task<string> ReadHeadersAsync(Stream s, CancellationToken ct)
{
var buf = new byte[8192];
using var ms = new MemoryStream();
while (true)
{
int n = await s.ReadAsync(buf.AsMemory(0, buf.Length), ct);
if (n <= 0) break;
ms.Write(buf, 0, n);
if (ms.Length >= 4)
{
var b = ms.GetBuffer();
for (int i = 3; i < ms.Length; i++)
{
if (b[i - 3] == '\r' && b[i - 2] == '\n' && b[i - 1] == '\r' && b[i] == '\n')
{
return Encoding.ASCII.GetString(b, 0, i + 1);
}
}
}
if (ms.Length > 65536) throw new Exception("Header zu groß");
}
return Encoding.ASCII.GetString(ms.ToArray());
}
private async Task<bool> DoHandshakeAsync(Stream stream, CancellationToken ct)
{
var header = await ReadHeadersAsync(stream, ct);
if (!header.StartsWith("GET ", StringComparison.OrdinalIgnoreCase))
return false;
var firstLineEnd = header.IndexOf("\r\n", StringComparison.Ordinal);
var firstLine = firstLineEnd > 0 ? header[..firstLineEnd] : header;
var parts = firstLine.Split(' ');
if (parts.Length < 2) return false;
var path = parts[1];
if (!path.StartsWith(_bindPath, StringComparison.Ordinal))
{
var notFound = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
var bytes = Encoding.ASCII.GetBytes(notFound);
await stream.WriteAsync(bytes, ct);
await stream.FlushAsync(ct);
return false;
}
string? wsKey = null;
foreach (var line in header.Split("\r\n"))
{
var idx = line.IndexOf(':');
if (idx <= 0) continue;
var name = line[..idx].Trim();
var value = line[(idx + 1)..].Trim();
if (name.Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase))
wsKey = value;
}
if (string.IsNullOrEmpty(wsKey))
return false;
var accept = ComputeWebSocketAccept(wsKey);
var resp =
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
$"Sec-WebSocket-Accept: {accept}\r\n" +
"\r\n";
var respBytes = Encoding.ASCII.GetBytes(resp);
await stream.WriteAsync(respBytes, ct);
await stream.FlushAsync(ct);
return true;
}
private static string ComputeWebSocketAccept(string key)
{
const string guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
var bytes = Encoding.ASCII.GetBytes(key + guid);
var hash = SHA1.HashData(bytes);
return Convert.ToBase64String(hash);
}
private async Task ReceiveLoop(ClientState state, CancellationToken serverCt)
{
var s = state.Stream;
var buf2 = new byte[2];
while (!serverCt.IsCancellationRequested && !state.Cts.IsCancellationRequested)
{
int r = await ReadExactAsync(s, buf2, 0, 2, serverCt);
if (r == 0) break;
byte b0 = buf2[0]; // FIN + opcode
byte b1 = buf2[1]; // MASK + payload len
byte opcode = (byte)(b0 & 0x0F);
bool masked = (b1 & 0x80) != 0;
ulong len = (ulong)(b1 & 0x7F);
if (len == 126)
{
r = await ReadExactAsync(s, buf2, 0, 2, serverCt);
if (r == 0) break;
len = (ulong)IPAddress.NetworkToHostOrder(BitConverter.ToInt16(buf2, 0));
}
else if (len == 127)
{
var b8 = new byte[8];
r = await ReadExactAsync(s, b8, 0, 8, serverCt);
if (r == 0) break;
if (BitConverter.IsLittleEndian) Array.Reverse(b8);
len = BitConverter.ToUInt64(b8, 0);
}
byte[] mask = Array.Empty<byte>();
if (masked)
{
mask = new byte[4];
r = await ReadExactAsync(s, mask, 0, 4, serverCt);
if (r == 0) break;
}
byte[] payload = Array.Empty<byte>();
if (len > 0)
{
payload = new byte[len];
r = await ReadExactAsync(s, payload, 0, (int)len, serverCt);
if (r == 0) break;
if (masked)
for (int i = 0; i < payload.Length; i++)
payload[i] = (byte)(payload[i] ^ mask[i % 4]);
}
if (opcode == 0x8) // Close
{
await SendCloseFrame(state);
break;
}
else if (opcode == 0x9) // Ping -> Pong
{
await SendPongFrame(state, payload);
}
// Textframes (0x1) werden ignoriert
}
}
private static async Task<int> ReadExactAsync(Stream s, byte[] buf, int off, int len, CancellationToken ct)
{
int got = 0;
while (got < len)
{
int n = await s.ReadAsync(buf.AsMemory(off + got, len - got), ct);
if (n <= 0) return got;
got += n;
}
return got;
}
private void SendTextFrame(ClientState c, string text)
{
var payload = Encoding.UTF8.GetBytes(text);
using var ms = new MemoryStream(capacity: 2 + payload.Length + 10);
ms.WriteByte(0x81); // FIN + Text
if (payload.Length <= 125)
{
ms.WriteByte((byte)payload.Length);
}
else if (payload.Length <= ushort.MaxValue)
{
ms.WriteByte(126);
var lenBytes = BitConverter.GetBytes((ushort)payload.Length);
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
ms.Write(lenBytes, 0, 2);
}
else
{
ms.WriteByte(127);
var lenBytes = BitConverter.GetBytes((ulong)payload.Length);
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
ms.Write(lenBytes, 0, 8);
}
ms.Write(payload, 0, payload.Length);
lock (c.SendLock)
{
var buf = ms.GetBuffer();
c.Stream.Write(buf, 0, (int)ms.Length);
c.Stream.Flush();
}
}
private static Task SendPongFrame(ClientState c, byte[] pingPayload)
{
var header = new MemoryStream(2 + pingPayload.Length);
header.WriteByte(0x8A); // FIN + Pong
if (pingPayload.Length <= 125)
{
header.WriteByte((byte)pingPayload.Length);
}
else if (pingPayload.Length <= ushort.MaxValue)
{
header.WriteByte(126);
var lenBytes = BitConverter.GetBytes((ushort)pingPayload.Length);
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
header.Write(lenBytes, 0, 2);
}
else
{
header.WriteByte(127);
var lenBytes = BitConverter.GetBytes((ulong)pingPayload.Length);
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
header.Write(lenBytes, 0, 8);
}
var buf = header.ToArray();
lock (c.SendLock)
{
c.Stream.Write(buf, 0, buf.Length);
if (pingPayload.Length > 0) c.Stream.Write(pingPayload, 0, pingPayload.Length);
c.Stream.Flush();
}
return Task.CompletedTask;
}
private static Task SendCloseFrame(ClientState c)
{
var frame = new byte[] { 0x88, 0x00 }; // Close, no payload
lock (c.SendLock)
{
c.Stream.Write(frame, 0, frame.Length);
c.Stream.Flush();
}
return Task.CompletedTask;
}
// --- TLS ---
private bool TryLoadCertificate(out string usedPath)
{
usedPath = _certPath;
try
{
string pluginDir = ModuleDirectory;
if (string.IsNullOrWhiteSpace(usedPath))
{
var def = Path.Combine(pluginDir, "cert.pfx");
if (File.Exists(def))
{
usedPath = def;
}
else
{
var files = Directory.GetFiles(pluginDir, "*.pfx", SearchOption.TopDirectoryOnly);
if (files.Length > 0) usedPath = files[0];
}
}
else if (!Path.IsPathRooted(usedPath))
{
usedPath = Path.Combine(pluginDir, usedPath);
}
if (string.IsNullOrWhiteSpace(usedPath) || !File.Exists(usedPath))
{
Logger.LogWarning($"[WS] Kein PFX gefunden im Plugin-Ordner ({pluginDir}). Lege z.B. 'cert.pfx' dort ab oder setze mit css_ws_cert <pfad>.");
return false;
}
try
{
_serverCert = new X509Certificate2(
usedPath,
string.IsNullOrEmpty(_certPassword) ? null : _certPassword,
X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable
);
}
catch (CryptographicException cex) when ((cex.Message ?? "").IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 ||
(cex.Message ?? "").IndexOf("kennwort", StringComparison.OrdinalIgnoreCase) >= 0)
{
var pwdState = string.IsNullOrEmpty(_certPassword) ? "leer" : "gesetzt";
Logger.LogError($"[WS] TLS-PFX konnte nicht geöffnet werden: vermutlich falsches Passwort. Pfad: {usedPath}. CertPassword ist {pwdState}.");
_serverCert = null;
return false;
}
catch (CryptographicException cex)
{
Logger.LogError($"[WS] TLS-PFX Fehler ({usedPath}): {cex.Message}");
_serverCert = null;
return false;
}
if (_serverCert == null)
return false;
if (!_serverCert.HasPrivateKey)
{
Logger.LogError($"[WS] Zertifikat geladen, aber ohne Private Key: {Path.GetFileName(usedPath)}. Bitte PFX mit privatem Schlüssel verwenden.");
_serverCert = null;
return false;
}
try
{
Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)} | Subject: {_serverCert.Subject} | Gültig bis: {_serverCert.NotAfter:u}");
}
catch
{
Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)}");
}
return true;
}
catch (Exception ex)
{
Logger.LogError($"[WS] Zertifikat konnte nicht geladen werden: {ex.Message}");
_serverCert = null;
return false;
}
}
}