// 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.Numerics; 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.7.0"; public override string ModuleAuthor => "you + ChatGPT"; public override string ModuleDescription => "WS(S)-Server: Spielerpositionen + Blickrichtung + Map + Nade Trajectories (Prediction + Backfill)"; // --- 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; } public bool? Predict { get; set; } public int? PredPoints { get; set; } } // Prediction-Optionen private volatile bool _predictEnabled = true; private volatile int _predPoints = 24; 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, Predict = _predictEnabled, PredPoints = _predPoints }; 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( 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; // Prediction if (cfg.Predict.HasValue) _predictEnabled = cfg.Predict.Value; if (cfg.PredPoints is int pp && pp >= 8 && pp <= 64) _predPoints = pp; Logger.LogInformation($"[WS] Konfiguration geladen ({_bindHost}:{_bindPort}{_bindPath}, tls={_useTls}, hz={_sendHz}, predict={_predictEnabled}, predPoints={_predPoints})"); } 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, Predict = _predictEnabled, PredPoints = _predPoints }; 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 _clients = new(); private int _clientSeq = 0; // --- Outgoing Queue --- private readonly ConcurrentQueue _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 _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) // ========================= 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; } 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; } private static dynamic? TryGetObserverTargetFromPawn(dynamic pawn) { 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; } 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; } 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; } 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)) { 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 { } 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 { } } 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, object pawnObj) { dynamic pawn = pawnObj; // intern weiterhin dynamisch arbeiten float vp=0, vy=0, vr=0; if (TryGetViewAngles(p, pawn, out vp, out vy, out vr)) { vp = ClampPitch(vp); vy = NormalizeYaw(vy); if (!IsTinyPair(vp, vy)) return StoreAim(p, vp, vy); } 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 { } 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 { } 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 { } { 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; if (IsFinite(pitch) && IsFinite(yaw)) return (ClampPitch(pitch), NormalizeYaw(yaw), roll); } catch { } return (0f, 0f, 0f); } private static bool TryGetEyePosition(dynamic pawn, out Vector3 eye) { // Beste Schätzungen try { var v = pawn?.EyePosition; eye = new Vector3((float)v.X, (float)v.Y, (float)v.Z); return true; } catch {} try { var v = pawn?.AbsOrigin; eye = new Vector3((float)v.X, (float)v.Y, (float)v.Z) + new Vector3(0,0,64f); return true; } catch {} eye = default; return false; } private static float GetTickInterval() { // Kompatibler Fallback: 64 Tick (falls deine API keine globale Tickrate liefert) // Wenn du eine verlässliche Quelle hast (z. B. ConVar oder API), trage sie hier ein. return 1.0f / 64.0f; } // ========================= // Lifecycle // ========================= 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, css_ws_pred"); RegisterListener(OnTick); _mapName = Server.MapName ?? ""; RegisterListener(OnMapStart); RegisterListener(OnEntityCreated); RegisterListener(OnEntityDeleted); RegisterEventHandler(OnSmokeDetonate); RegisterEventHandler(OnHeDetonate); RegisterEventHandler(OnFlashDetonate); RegisterEventHandler(OnMolotovDetonate); RegisterEventHandler(OnDecoyStart); RegisterEventHandler(OnDecoyDetonate); 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_pred", "Prediction der Nade-Trajektorie aktivieren/deaktivieren (1|0), Punkte optional")] [CommandHelper(minArgs: 1, usage: "<1|0> [points 8..64]")] public void CmdPred(CCSPlayerController? caller, CommandInfo cmd) { var on = cmd.GetArg(1); _predictEnabled = on == "1" || on.Equals("true", StringComparison.OrdinalIgnoreCase); if (cmd.ArgCount >= 3 && int.TryParse(cmd.GetArg(2), out var pts) && pts >= 8 && pts <= 64) _predPoints = pts; cmd.ReplyToCommand($"[WS] Prediction: {(_predictEnabled ? "an" : "aus")} (points={_predPoints})"); SaveConfig(); } [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 { LoadAndApplyConfig(generateIfMissing: false); _lastTick = DateTime.UtcNow; _accumulator = 0; while (_outbox.TryDequeue(out _)) { } 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: "")] 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: "")] 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: "")] 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: "")] 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(); 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 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; } // viewAngle exemplarisch aus AbsRotation float angX = 0f, angY = 0f, angZ = 0f; try { var ang = pawn.AbsRotation; angX = (float)ang.X; angY = (float)ang.Y; angZ = (float)ang.Z; } catch { } bool isAlive = true; try { int ls = (int)pawn.LifeState; isAlive = (ls == 0); } catch { } if (!isAlive) { try { isAlive = ((int)pawn.Health) > 0; } catch { } } Vector3 eyePos; TryGetEyePosition(pawn, out eyePos); // nutzt deine vorhandene TryGetEyePosition Vector3 pVel = Vector3.Zero; try { var v = pawn.AbsVelocity; pVel = new Vector3((float)v.X, (float)v.Y, (float)v.Z); } catch { /* ok */ } // Aim sinnvoll clampen/normalisieren float spPitch = ClampPitch(angX); float spYaw = NormalizeYaw(angY); var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL; _lastPlayerSnap[sid] = new PlayerSnap { Origin = new Vector3(posX, posY, posZ), Eye = eyePos == default ? new Vector3(posX, posY, posZ + 64f) : eyePos, Vel = pVel, Pitch = spPitch, Yaw = spYaw, T = NowMs() }; 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 { } } // Nade-Update + Backfill UpdateNadesInTick(); if (playersList.Count == 0) return; var payload = new { type = "tick", t = NowMs(), players = playersList }; Broadcast(JsonSerializer.Serialize(payload)); } // ========================= // Grenade tracking // ========================= private sealed class NadeInfo { public required int Id; public required string Kind; public CEntityInstance? Ent; // kann kurzzeitig null sein (nur Meta) public ulong OwnerSteamId; public long CreatedMs; public bool Announced; // create schon gesendet? public (float x,float y,float z) LastPos; public long LastT; // Backfill-Puffer public bool FirstValidSeen; public (long t, (float x,float y,float z) pos) FirstSample; public bool SecondValidSeen; public (long t, (float x,float y,float z) pos) SecondSample; public bool FixSent; // Prediction public bool PredSent; } private int _nadeSeq = 0; private readonly ConcurrentDictionary _nades = new(); // Id -> info private struct PlayerSnap { public Vector3 Origin; public Vector3 Eye; public Vector3 Vel; public float Pitch; public float Yaw; public long T; } private readonly ConcurrentDictionary _lastPlayerSnap = new(); private static bool IsValidPos((float x, float y, float z) p) { if (!float.IsFinite(p.x) || !float.IsFinite(p.y) || !float.IsFinite(p.z)) return false; if (Math.Abs(p.x) < 1f && Math.Abs(p.y) < 1f && Math.Abs(p.z) < 1f) return false; if (Math.Abs(p.x) > 100000f || Math.Abs(p.y) > 100000f || Math.Abs(p.z) > 100000f) return false; return true; } // ========================= // Helpers // ========================= private static (float x, float y, float z) ReadInitialNadePos(dynamic ent) { try { var v = ent.m_vInitialPosition; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {} try { var v = ent.InitialPosition; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {} return (0f, 0f, 0f); } private static (float x, float y, float z) ReadInitialNadeVel(dynamic ent) { try { var v = ent.m_vInitialVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {} try { var v = ent.InitialVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {} return (0f, 0f, 0f); } private static bool IsGrenadeDesignerName(string? name) { if (string.IsNullOrEmpty(name)) return false; name = name.ToLowerInvariant(); return name.Contains("grenade_projectile") || name.Contains("flashbang_projectile") || name.Contains("smokegrenade_projectile") || name.Contains("molotov_projectile") || name.Contains("decoy_projectile"); } private static string KindFromDesignerName(string name) { name = name.ToLowerInvariant(); if (name.Contains("hegrenade")) return "he"; if (name.Contains("flashbang")) return "flash"; if (name.Contains("smokegrenade")) return "smoke"; if (name.Contains("molotov")) return "molotov"; if (name.Contains("decoy")) return "decoy"; return "other"; } private static (float pitch, float yaw, float roll) ReadAbsAngles(dynamic ent) { try { var node = ent?.GameSceneNode; if (node != null) { try { var r = node.AbsRotation; return ((float)r.X, (float)r.Y, (float)r.Z); } catch {} try { var tf = node.NodeToWorld; var a = tf.Angles; return ((float)a.X, (float)a.Y, (float)a.Z); } catch {} } } catch {} try { var r = ent?.AbsRotation; return ((float)r.X, (float)r.Y, (float)r.Z); } catch {} return (0f, 0f, 0f); } private static (float x, float y, float z) ReadAbsOrigin(dynamic ent) { try { var node = ent?.GameSceneNode; if (node != null) { try { var o = node.AbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} try { var o = node.m_vecAbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} try { var tf = node.NodeToWorld; var a = tf.Origin; return ((float)a.X, (float)a.Y, (float)a.Z); } catch {} } } catch {} try { var bodyComp = ent?.CBodyComponent; if (bodyComp != null) { var nd = bodyComp.SceneNode; if (nd != null) { try { var o = nd.AbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} try { var o = nd.m_vecAbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} } } } catch {} try { var o = ent?.AbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} try { var o = ent?.m_vecAbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} try { var transform = ent?.m_pGameSceneNode?.m_nodeToWorld; if (transform != null) { return ((float)transform.m_vOrigin.X, (float)transform.m_vOrigin.Y, (float)transform.m_vOrigin.Z); } } catch {} try { var physics = ent?.Physics; if (physics != null) { try { var o = physics.Origin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} try { var o = physics.Position; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {} } } catch {} return ReadInitialNadePos(ent); } private static (float x, float y, float z) ReadAbsVelocity(dynamic ent) { try { var v = ent?.AbsVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {} try { var v = ent?.m_vecAbsVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {} try { var v = ent?.Physics?.Velocity;return ((float)v.X, (float)v.Y, (float)v.Z); } catch {} return ReadInitialNadeVel(ent); } private static ulong TryGetThrowerSteamId(dynamic projectileBase) { try { dynamic p = projectileBase; // dynamisch binden // 1) Thrower (Pawn) -> Controller -> SteamID try { dynamic th = p.m_hThrower; // kann ein Handle sein dynamic thVal = th is null ? null : (th.Value ?? th); dynamic throwerPawn = thVal ?? p.Thrower; if (throwerPawn != null) { try { dynamic ctrl = throwerPawn.Controller; if (ctrl != null) return (ulong)(ctrl.AuthorizedSteamID?.SteamId64 ?? 0UL); } catch { /* weiter probieren */ } } } catch { /* weiter */ } // 2) OwnerEntity (Pawn/Entity) -> Controller -> SteamID try { dynamic oh = p.m_hOwnerEntity; dynamic ohVal = oh is null ? null : (oh.Value ?? oh); dynamic ownerEnt = ohVal ?? p.OwnerEntity; if (ownerEnt != null) { try { dynamic ctrl = ownerEnt.Controller; if (ctrl != null) return (ulong)(ctrl.AuthorizedSteamID?.SteamId64 ?? 0UL); } catch { } } } catch { } } catch { } return 0UL; } // ========================= // Prediction (vereinfachte Ballistik) // ========================= private static Vector3 AnglesToForward(float pitchDeg, float yawDeg) { // Pitch: Up ist negativ in Source-Notation, deshalb Minus float pitch = -pitchDeg * (float)Math.PI / 180f; float yaw = yawDeg * (float)Math.PI / 180f; float cp = MathF.Cos(pitch); return new Vector3( cp * MathF.Cos(yaw), cp * MathF.Sin(yaw), MathF.Sin(pitch) ); } // ========================= // Hooks // ========================= private void OnEntityCreated(CEntityInstance ent) { try { var name = ent.DesignerName ?? ""; if (!IsGrenadeDesignerName(name)) return; var kind = KindFromDesignerName(name); var id = Interlocked.Increment(ref _nadeSeq); var owner = TryGetThrowerSteamId(ent); var info = new NadeInfo { Id = id, Kind = kind, Ent = ent, OwnerSteamId = owner, CreatedMs = NowMs(), Announced = false, LastPos = (0,0,0), LastT = 0, FirstValidSeen = false, SecondValidSeen = false, FixSent = false, PredSent = false }; _nades[id] = info; // Prediction mit Retry; fällt auf Spieler-Snapshot zurück, wenn Projectile-Netvars leer sind if (_predictEnabled) { const int maxTries = 10; // bis zu ~10 Versuche const float tryInterval = 0.016f; // alle ~1 Frame (bei ~64 tick) int attempt = 0; void TryPredict() { static string V3(Vector3 v) => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"; static string T3((float x,float y,float z) t) => $"({t.x:F2},{t.y:F2},{t.z:F2})"; try { attempt++; Logger.LogInformation($"[WS-PRED] fire id={id} kind={kind}: attempt {attempt}/{maxTries}"); if (ent == null || !ent.IsValid || !_nades.ContainsKey(id)) { Logger.LogWarning($"[WS-PRED] id={id} kind={kind}: entity invalid or gone -> abort"); return; } if (attempt == 1) { try { var dn = ent.DesignerName ?? "(null)"; var typeName = ent.GetType().Name; Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: DesignerName='{dn}', CLR='{typeName}' owner={owner}"); } catch { } } // 1) Pos/Vel aus Netvars (oder initial) lesen var pAbs = ReadAbsOrigin(ent); bool pAbsValid = IsValidPos(pAbs); Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: AbsOrigin={T3(pAbs)} valid={pAbsValid}"); (float x,float y,float z) p0 = pAbs; if (!pAbsValid) { var pInit = ReadInitialNadePos(ent); bool pInitValid = IsValidPos(pInit); Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: InitialPos={T3(pInit)} valid={pInitValid}"); if (pInitValid) p0 = pInit; } var vAbs = ReadAbsVelocity(ent); var vInit = ReadInitialNadeVel(ent); Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: AbsVel=({vAbs.x:F2},{vAbs.y:F2},{vAbs.z:F2}) | InitVel=({vInit.x:F2},{vInit.y:F2},{vInit.z:F2})"); bool posOk = IsValidPos(p0); bool velOk = !(vAbs.x==0 && vAbs.y==0 && vAbs.z==0) || !(vInit.x==0 && vInit.y==0 && vInit.z==0); Vector3 start3 = posOk ? new Vector3(p0.x, p0.y, p0.z) : Vector3.Zero; Vector3 v0 = Vector3.Zero; if (!(vAbs.x==0 && vAbs.y==0 && vAbs.z==0)) v0 = new Vector3(vAbs.x, vAbs.y, vAbs.z); else if (!(vInit.x==0 && vInit.y==0 && vInit.z==0)) v0 = new Vector3(vInit.x, vInit.y, vInit.z); // 2) Fallback: Spieler-Snapshot (Owner → jüngster Spieler) if (start3 == Vector3.Zero || v0 == Vector3.Zero) { bool haveSnap = false; PlayerSnap snap = default; if (owner != 0 && _lastPlayerSnap.TryGetValue(owner, out snap)) { haveSnap = true; Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: using owner snap steamid={owner}"); } else { // jüngster Snapshot eines Spielers ulong picked = 0; long bestT = 0; foreach (var kv in _lastPlayerSnap) { if (kv.Value.T > bestT) { picked = kv.Key; bestT = kv.Value.T; snap = kv.Value; } } if (picked != 0) { haveSnap = true; Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: using most recent player snap steamid={picked}"); } } if (haveSnap) { var snapStart = snap.Eye == default ? snap.Origin + new Vector3(0,0,64f) : snap.Eye; var dir = AnglesToForward(snap.Pitch, snap.Yaw); if (start3 == Vector3.Zero) start3 = snapStart; if (v0 == Vector3.Zero) v0 = dir * 750f + snap.Vel; Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: snap start={V3(start3)} aim=({snap.Pitch:F1},{snap.Yaw:F1}) v0={V3(v0)}"); } else { // kein Snap — ggf. nochmal warten if (!posOk && attempt < maxTries) { Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: no pos & no snap -> retry in {tryInterval*1000:F0}ms"); AddTimer(tryInterval, TryPredict); return; } } } // Wenn immer noch kein Start: letzter Versuch (nochmal warten) if (start3 == Vector3.Zero) { if (attempt < maxTries) { Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: start still unknown -> retry in {tryInterval*1000:F0}ms"); AddTimer(tryInterval, TryPredict); return; } Logger.LogWarning($"[WS-PRED] id={id} kind={kind}: giving up (no start after {attempt} tries)"); return; } // Velocity notfalls aus Richtung schätzen (falls bis hier noch 0) if (v0 == Vector3.Zero) { // Schätze Richtung aus Start → (falls möglich) nächsten Tick – sonst nimm Eye-Aim aus jüngstem Snap Vector3 estDir = Vector3.UnitX; // dummy bool gotDir = false; // quickest: nimm jüngsten Snap ulong bestSid=0; long bestTs=0; PlayerSnap bestSnap=default; foreach (var kv in _lastPlayerSnap) if (kv.Value.T > bestTs) { bestSid=kv.Key; bestTs=kv.Value.T; bestSnap=kv.Value; } if (bestSid!=0) { estDir = AnglesToForward(bestSnap.Pitch, bestSnap.Yaw); gotDir = true; } if (!gotDir) estDir = Vector3.UnitX; v0 = estDir * 750f; Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: v0 missing -> estimated v0={V3(v0)}"); } float dt = GetTickInterval(); Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: start={V3(start3)} v0={V3(v0)} dt={dt:F4} points={_predPoints}"); var pts = PredictPathFromState(start3, v0, _predPoints, dt, 800f, includeStart: true); if (pts.Count == 0) { if (attempt < maxTries) { Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: 0 points -> retry in {tryInterval*1000:F0}ms"); AddTimer(tryInterval, TryPredict); return; } Logger.LogWarning($"[WS-PRED] id={id} kind={kind}: 0 points after {attempt} tries -> give up"); return; } Broadcast(JsonSerializer.Serialize(new { type = "nade_pred", t = NowMs(), id = id, kind = kind, owner = owner, points = pts })); Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: nade_pred broadcast sent ({pts.Count} pts)"); if (_nades.TryGetValue(id, out var n)) n.PredSent = true; } catch (Exception exOuter) { Logger.LogError($"[WS-PRED] id={id} kind={kind}: outer exception: {exOuter.Message}"); } } AddTimer(tryInterval, TryPredict); } // Kleiner Delay, bis Netvars sicher da sind → create senden AddTimer(0.05f, () => { try { if (ent == null || !ent.IsValid || !_nades.ContainsKey(id)) return; var pos = ReadAbsOrigin(ent); var vel = ReadAbsVelocity(ent); if (!IsValidPos(pos)) pos = ReadInitialNadePos(ent); if (!IsValidPos(pos)) return; var ang = ReadAbsAngles(ent); if (_nades.TryGetValue(id, out var n)) { n.LastPos = pos; n.LastT = NowMs(); n.Announced = true; n.FirstValidSeen = true; n.FirstSample = (n.LastT, pos); } Broadcast(JsonSerializer.Serialize(new { type = "nade_create", t = NowMs(), id = id, kind = kind, owner= owner, pos = new { x = pos.x, y = pos.y, z = pos.z }, vel = new { x = vel.x, y = vel.y, z = vel.z }, ang = new { pitch = ang.pitch, yaw = ang.yaw, roll = ang.roll } })); } catch { /* ignore */ } }); } catch { /* ignore */ } } private static List PredictPathFromState( Vector3 start, Vector3 v0, int points, float dt, float gravityAbs = 800f, bool includeStart = true) { var list = new List(points); Vector3 p = start; Vector3 v = v0; Vector3 a = new Vector3(0, 0, -gravityAbs); if (includeStart) list.Add(new { x = p.X, y = p.Y, z = p.Z }); // Punkt 0 = exakter Start for (int i = includeStart ? 1 : 0; i < points; i++) { p += v * dt + 0.5f * a * dt * dt; v += a * dt; list.Add(new { x = p.X, y = p.Y, z = p.Z }); if (MathF.Abs(p.X) > 200000f || MathF.Abs(p.Y) > 200000f || MathF.Abs(p.Z) > 200000f) break; } return list; } private void UpdateNadesInTick() { if (_nades.IsEmpty) return; var nades = new List(); var toRemove = new List(); var now = NowMs(); foreach (var kv in _nades) { var n = kv.Value; try { if (n.Ent == null || !n.Ent.IsValid) { // bis Detonate/Event continue; } var pos = ReadAbsOrigin(n.Ent); var vel = ReadAbsVelocity(n.Ent); if (!IsValidPos(pos)) continue; // --- NEU: Prediction hier nachreichen, sobald wir zum ersten Mal echte Daten sehen --- if (_predictEnabled && !n.PredSent) { try { float dt = GetTickInterval(); var start3 = new Vector3(pos.x, pos.y, pos.z); var v0 = new Vector3(vel.x, vel.y, vel.z); Logger.LogInformation( $"[WS-PRED] late id={n.Id} kind={n.Kind}: start=({start3.X:F2},{start3.Y:F2},{start3.Z:F2}) " + $"v0=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) dt={dt:F4} points={_predPoints}"); var pts = PredictPathFromState(start3, v0, _predPoints, dt, 800f, includeStart: true); if (pts.Count > 0) { Broadcast(JsonSerializer.Serialize(new { type = "nade_pred", t = NowMs(), id = n.Id, kind = n.Kind, owner = n.OwnerSteamId, points = pts })); n.PredSent = true; Logger.LogInformation($"[WS-PRED] late id={n.Id} kind={n.Kind}: nade_pred broadcast sent ({pts.Count} pts)"); } else { Logger.LogWarning($"[WS-PRED] late id={n.Id} kind={n.Kind}: 0 points (skip)"); } } catch (Exception ex) { Logger.LogError($"[WS-PRED] late id={n.Id} kind={n.Kind}: exception: {ex.Message}"); } } // --- ENDE NEU --- // First/Second-Sample Logik für Backfill (deins, unverändert) … if (IsValidPos(pos)) { if (!n.FirstValidSeen) { n.FirstValidSeen = true; n.FirstSample = (now, pos); } else if (!n.SecondValidSeen) { n.SecondValidSeen = true; n.SecondSample = (now, pos); var initVel = ReadInitialNadeVel(n.Ent); float dtf = Math.Max(GetTickInterval(), ((n.SecondSample.t - n.FirstSample.t) / 1000f)); Vector3 p1 = new Vector3(n.FirstSample.pos.x, n.FirstSample.pos.y, n.FirstSample.pos.z); Vector3 v1; if (initVel.x != 0 || initVel.y != 0 || initVel.z != 0) v1 = new Vector3(initVel.x, initVel.y, initVel.z); else { Vector3 p2 = new Vector3(n.SecondSample.pos.x, n.SecondSample.pos.y, n.SecondSample.pos.z); float dtt = Math.Max(0.001f, (n.SecondSample.t - n.FirstSample.t) / 1000f); v1 = (p2 - p1) / dtt; } Vector3 a = new Vector3(0, 0, -800f); Vector3 p0 = p1 - v1 * dtf - 0.5f * a * dtf * dtf; if (!n.FixSent && IsFinite(p0.X) && IsFinite(p0.Y) && IsFinite(p0.Z)) { n.FixSent = true; Broadcast(JsonSerializer.Serialize(new { type = "nade_fix_start", t = NowMs(), id = n.Id, pos = new { x = p0.X, y = p0.Y, z = p0.Z } })); } } } // Falls create noch nicht raus ist, jetzt nachholen (deins, unverändert) … if (!n.Announced && IsValidPos(pos)) { var ang = ReadAbsAngles(n.Ent); n.Announced = true; n.LastPos = pos; n.LastT = now; Broadcast(JsonSerializer.Serialize(new { type = "nade_create", t = now, id = n.Id, kind = n.Kind, owner = n.OwnerSteamId, pos = new { x = pos.x, y = pos.y, z = pos.z }, vel = new { x = vel.x, y = vel.y, z = vel.z }, ang = new { pitch = ang.pitch, yaw = ang.yaw, roll = ang.roll } })); } nades.Add(new { id = n.Id, kind = n.Kind, owner = n.OwnerSteamId, pos = new { x = pos.x, y = pos.y, z = pos.z }, vel = new { x = vel.x, y = vel.y, z = vel.z } }); n.LastPos = pos; n.LastT = now; } catch (Exception) { toRemove.Add(kv.Key); } } if (nades.Count > 0) { var nadePayload = new { type = "nades", t = NowMs(), nades }; Broadcast(JsonSerializer.Serialize(nadePayload)); } foreach (var id in toRemove) _nades.TryRemove(id, out _); } private void OnEntityDeleted(CEntityInstance ent) { try { foreach (var kv in _nades) { if (ReferenceEquals(kv.Value.Ent, ent)) { _nades.TryRemove(kv.Key, out _); break; } } } catch { } } // ========================= // Detonation Handler / Cleanup // ========================= private HookResult OnSmokeDetonate(EventSmokegrenadeDetonate ev, GameEventInfo info) { return HandleExplodeGeneric("smoke", ev.X, ev.Y, ev.Z); } private HookResult OnHeDetonate(EventHegrenadeDetonate ev, GameEventInfo info) { return HandleExplodeGeneric("he", ev.X, ev.Y, ev.Z); } private HookResult OnFlashDetonate(EventFlashbangDetonate ev, GameEventInfo info) { return HandleExplodeGeneric("flash", ev.X, ev.Y, ev.Z); } private HookResult OnMolotovDetonate(EventMolotovDetonate ev, GameEventInfo info) { return HandleExplodeGeneric("molotov", ev.X, ev.Y, ev.Z); } private HookResult OnDecoyStart(EventDecoyStarted ev, GameEventInfo info) { Broadcast(JsonSerializer.Serialize(new { type = "nade_decoy_start", t = NowMs(), pos = new { x = ev.X, y = ev.Y, z = ev.Z } })); return HookResult.Continue; } private HookResult OnDecoyDetonate(EventDecoyDetonate ev, GameEventInfo info) { return HandleExplodeGeneric("decoy", ev.X, ev.Y, ev.Z); } private HookResult HandleExplodeGeneric(string kind, float x, float y, float z) { int removeId = -1; foreach (var kv in _nades) { if (kv.Value.Kind == kind) { removeId = kv.Key; break; } } if (removeId != -1 && _nades.TryRemove(removeId, out var info)) { Broadcast(JsonSerializer.Serialize(new { type = "nade_explode", t = NowMs(), id = info.Id, kind = info.Kind, owner = info.OwnerSteamId, pos = new { x, y, z } })); } else { Broadcast(JsonSerializer.Serialize(new { type = "nade_explode", t = NowMs(), id = (int?)null, kind, pos = new { x, y, z } })); } return HookResult.Continue; } // ========================= // 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 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 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(); if (masked) { mask = new byte[4]; r = await ReadExactAsync(s, mask, 0, 4, serverCt); if (r == 0) break; } byte[] payload = Array.Empty(); 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 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.ToArray(); c.Stream.Write(buf, 0, buf.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 ."); 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; } } }