2025-08-19 15:33:02 +02:00

1783 lines
62 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;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Net.Security;
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 CounterStrikeSharp.API.Modules.Events;
using Microsoft.Extensions.Logging;
namespace WsTelemetry;
[MinimumApiVersion(175)]
public class WebSocketTelemetryPlugin : BasePlugin
{
public override string ModuleName => "WS Telemetry";
public override string ModuleVersion => "1.5.2";
public override string ModuleAuthor => "you + ChatGPT";
public override string ModuleDescription => "WS(S)-Server: Spielerpos/aim + Granaten (Tick-Trace/Bounce/Detonate/Path) + Map + Smoke/Fire-Status";
// --- 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;
// Durations bleiben hart verdrahtet
_smokeDurationSec = SMOKE_DURATION_SEC;
_fireDurationSec = FIRE_DURATION_SEC;
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);
// URL aus aktuellen Feldern zusammensetzen
var url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}";
// CertPath möglichst relativ speichern
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;
// --- Nade Runtime-Status ---
private int _seq = 0; // globale Sequenz (für Nade/Trace IDs)
// Fest verdrahtete Zeiten (nicht änderbar)
private const double SMOKE_DURATION_SEC = 20.0;
private const double FIRE_DURATION_SEC = 7.0;
// interner Zugriff bleibt über Felder, die auf die Konstanten gesetzt werden
private double _smokeDurationSec = SMOKE_DURATION_SEC;
private double _fireDurationSec = FIRE_DURATION_SEC;
private class AoeState
{
public required DateTimeOffset EndAt;
public required float X;
public required float Y;
public required float Z;
}
private readonly ConcurrentDictionary<int, AoeState> _activeSmokes = new();
private readonly ConcurrentDictionary<int, AoeState> _activeFires = new();
// --- Trajektorien-Tracking ---
private class NadeTrack
{
public int Id;
public ulong SteamId;
public string Nade = "";
public DateTimeOffset Start;
public DateTimeOffset Last;
public bool Finalized;
public object? BoundEntity;
public string? BoundName;
public float LastX, LastY, LastZ;
public int Sent = 0; // bereits gesendete Punkte
public int CurrentSeg = 0; // aktuelle Segment-ID (0-basiert)
public readonly List<Point> Points = new();
public class Point { public float X; public float Y; public float Z; public long T; public int S; }
}
// Alle Tracks by id
private readonly ConcurrentDictionary<int, NadeTrack> _tracks = new();
// Letzter aktiver Track je Spieler (für grenade_bounce ohne Typ)
private readonly ConcurrentDictionary<ulong, int> _lastTrackByPlayer = new();
// Aktiver Track je Spieler+Nade (für eindeutiges Finalisieren)
private readonly ConcurrentDictionary<string, int> _activeTrackByPlayerNade = new();
private static string PNKey(ulong steamId, string nade) => $"{steamId}:{nade}";
// --- Entity-Suche via Reflection ---
private static MethodInfo? _miFindByDesigner;
private static MethodInfo? _miFindByClass;
private static IEnumerable<object> FindEntitiesByName(string name)
{
_miFindByDesigner ??= typeof(Utilities).GetMethod("FindAllEntitiesByDesignerName", BindingFlags.Public | BindingFlags.Static);
_miFindByClass ??= typeof(Utilities).GetMethod("FindAllEntitiesByClassname", BindingFlags.Public | BindingFlags.Static);
IEnumerable? ie = null;
try
{
if (_miFindByDesigner != null)
ie = _miFindByDesigner.Invoke(null, new object[] { name }) as IEnumerable;
if (ie == null && _miFindByClass != null)
ie = _miFindByClass.Invoke(null, new object[] { name }) as IEnumerable;
}
catch { }
if (ie == null) yield break;
foreach (var e in ie) if (e != null) yield return e;
}
private static bool TryGetPos(object ent, out float x, out float y, out float z)
{
try
{
dynamic d = ent;
var pos = d.AbsOrigin;
x = (float)pos.X; y = (float)pos.Y; z = (float)pos.Z;
return true;
}
catch
{
x = y = z = 0;
return false;
}
}
private static bool IsEntValid(object ent)
{
try { dynamic d = ent; return d != null && d.IsValid; }
catch { return ent != null; }
}
// --- 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;
}
// -- Camera-gesteuerte Blickrichtung --
private static bool TryReadAngles(dynamic src, out float pitch, out float yaw, out float roll)
{
// Fallback-Init, damit out immer gesetzt ist
pitch = 0f; yaw = 0f; roll = 0f;
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 { }
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 { }
// bleibt false; out sind bereits gesetzt
return false;
}
private static bool TryGetCameraAnglesFromRoot(dynamic root, out float pitch, out float yaw, out float roll)
{
// out-Parameter VOR jeglicher Logik initialisieren (fix für CS0177)
pitch = 0f; yaw = 0f; roll = 0f;
// 1) Aktive Kamera-Entity über CameraServices
try
{
dynamic cs = root.CameraServices;
dynamic h = (cs != null) ? cs.m_hActiveCamera : null;
dynamic cam = (h != null ? h.Value : null) ?? cs?.ActiveCamera ?? root.Camera;
if (cam != null && TryReadAngles(cam, out pitch, out yaw, out roll))
return true;
}
catch { }
// 2) Winkel direkt aus CameraServices
try
{
dynamic cs = root.CameraServices;
if (cs != null && TryReadAngles(cs, out pitch, out yaw, out roll))
return true;
}
catch { }
// 3) Letzter Versuch: direkt am Root
return TryReadAngles(root, out pitch, out yaw, out roll);
}
private static bool TryGetCameraAngles(CCSPlayerController p, dynamic pawn, out float pitch, out float yaw, out float roll)
{
// out-Defaults
pitch = 0f; yaw = 0f; roll = 0f;
if (pawn != null && TryGetCameraAnglesFromRoot(pawn, out pitch, out yaw, out roll))
return true;
if (TryGetCameraAnglesFromRoot(p, out pitch, out yaw, out roll))
return true;
return false;
}
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 (float pitch, float yaw) GetStableAim(CCSPlayerController p, dynamic pawn)
{
// 0) Kamera-Winkel bevorzugen
float cp, cy, cr;
if (TryGetCameraAngles(p, pawn, out cp, out cy, out cr))
return StoreAim(p, ClampPitch(cp), NormalizeYaw(cy));
// 1) Fallback: Pawn EyeAngles
try
{
dynamic a = pawn?.EyeAngles;
float px = (float)a.X, py = (float)a.Y;
if (IsFinite(px) && IsFinite(py))
return StoreAim(p, ClampPitch(px), NormalizeYaw(py));
}
catch { }
// 2) Fallback: AbsRotation (Yaw), Pitch aus letztem Wert
try
{
dynamic r = pawn?.AbsRotation;
float yaw = (float)r.Y;
if (IsFinite(yaw))
{
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
float pitch = _lastAimByPlayer.TryGetValue(sid, out var last) ? last.pitch : 0f;
return StoreAim(p, pitch, NormalizeYaw(yaw));
}
}
catch { }
// 3) Letzter gültiger Aim oder neutral
{
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
if (_lastAimByPlayer.TryGetValue(sid, out var last)) return last;
return (0f, 0f);
}
}
public override void Load(bool hotReload)
{
Logger.LogInformation("[WS] Plugin geladen. Kommandos: css_ws_enable, 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);
// Konfiguration einlesen (ohne enabled) …
LoadAndApplyConfig();
// … und automatisch starten (abschaltbar per css_ws_enable 0)
_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_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;
// verwaiste Tracks aufräumen (Failsafe, 15s)
foreach (var kv in _tracks)
{
var tr = kv.Value;
if (!tr.Finalized && (DateTimeOffset.UtcNow - tr.Last).TotalSeconds > 15)
{
TryFinalizeTrackById(tr.Id, sendEvenIfEmpty: true, nadeOverride: tr.Nade);
}
}
// --- Spieler sammeln ---
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;
var pos = pawn.AbsOrigin;
// stabile Blickrichtung
var (aimPitch, aimYaw) = GetStableAim(p, pawn);
// Bewegungsrichtung aus Velocity (2D)
float dirYaw = 0f, speed2D = 0f;
float vx = 0f, vy = 0f, vz = 0f;
try
{
var vel = pawn.AbsVelocity;
vx = vel.X; vy = vel.Y; vz = vel.Z;
speed2D = MathF.Sqrt(vx * vx + vy * vy);
if (speed2D > 1f)
dirYaw = NormalizeYaw((float)(Math.Atan2(vy, vx) * 180.0 / Math.PI));
}
catch { }
// rohe Pawn-Angles (Debug/Kompat)
float rawPitch = 0f, rawYaw = 0f, rawRoll = 0f;
try
{
var ar = pawn.EyeAngles;
rawPitch = ar.X; rawYaw = ar.Y; rawRoll = ar.Z;
}
catch { }
playersList.Add(new
{
steamId = p.AuthorizedSteamID?.SteamId64 ?? 0UL,
name = p.PlayerName,
team = p.TeamNum,
pos = new { x = pos.X, y = pos.Y, z = pos.Z },
// stabilisierte Blickrichtung (nutzen!)
view = new { pitch = aimPitch, yaw = aimYaw, roll = 0f },
aim = new { pitch = aimPitch, yaw = aimYaw },
// optional: rohe Werte
viewRaw = new { pitch = rawPitch, yaw = rawYaw, roll = rawRoll },
move = new
{
dirYaw = speed2D > 1f ? dirYaw : (float?)null,
speed = speed2D,
vel = new { x = vx, y = vy, z = vz }
}
});
}
catch { }
}
// --- Nade-Trace pro Tick sammeln ---
var traceList = new List<object>();
// Sammeln/Updaten der aktiven Projektile (füllt nur Track-Punkte)
SampleActiveNadeTracks();
// --- Nade-Path Realtime-Streaming pro Tick ---
foreach (var tr in _tracks.Values)
{
if (tr.Finalized) continue;
int from, to;
List<object> delta = null!;
lock (tr.Points)
{
from = tr.Sent;
to = tr.Points.Count;
if (to > from)
{
delta = new List<object>(to - from);
for (int i = from; i < to; i++)
{
var p = tr.Points[i];
delta.Add(new { x = p.X, y = p.Y, z = p.Z, t = p.T, s = p.S });
}
tr.Sent = to;
}
}
if (delta != null && delta.Count > 0)
{
Enqueue(new
{
type = "nade",
sub = "path",
id = tr.Id,
nade = tr.Nade,
steamId = tr.SteamId,
t = NowMs(),
points = delta,
final = false
});
}
}
// aktive Nades (letzte bekannte Position)
var activeList = new List<object>();
foreach (var tr in _tracks.Values)
{
if (tr.Finalized) continue;
activeList.Add(new
{
id = tr.Id,
nade = tr.Nade,
steamId = tr.SteamId,
pos = new { x = tr.LastX, y = tr.LastY, z = tr.LastZ }
});
}
if (playersList.Count == 0 && traceList.Count == 0 && activeList.Count == 0) return;
var payload = new
{
type = "tick",
t = NowMs(),
players = playersList,
nades = (activeList.Count > 0 || traceList.Count > 0)
? new { trace = traceList, active = activeList }
: null
};
Broadcast(JsonSerializer.Serialize(payload));
}
// =========================
// Sampling der aktiven Projektile
// =========================
private static readonly Dictionary<string, string[]> _kindToNames = new()
{
["he"] = new[] { "hegrenade_projectile" },
["flash"] = new[] { "flashbang_projectile" },
["smoke"] = new[] { "smokegrenade_projectile" },
["decoy"] = new[] { "decoy_projectile" },
["molotov"] = new[] { "molotov_projectile", "incgrenade_projectile" },
["other"] = new[] { "grenade_projectile" } // Fallback
};
private IEnumerable<(object ent, string name, float x, float y, float z)> EnumerateProjectilesFor(string kind)
{
if (!_kindToNames.TryGetValue(kind, out var names)) names = _kindToNames["other"];
foreach (var nm in names)
{
foreach (var e in FindEntitiesByName(nm))
{
if (e == null || !IsEntValid(e)) continue;
if (TryGetPos(e, out var x, out var y, out var z))
yield return (e, nm, x, y, z);
}
}
}
private void SampleActiveNadeTracks()
{
var neededKinds = _tracks.Values.Where(t => !t.Finalized).Select(t => t.Nade).Distinct().ToList();
var poolByKind = new Dictionary<string, List<(object ent, string name, float x, float y, float z)>>();
foreach (var k in neededKinds)
poolByKind[k] = EnumerateProjectilesFor(k).ToList();
var usedEnts = new HashSet<object>(ReferenceEqualityComparer.Instance);
foreach (var kv in _tracks)
{
var tr = kv.Value;
if (tr.Finalized) continue;
if (tr.BoundEntity == null)
{
if (!poolByKind.TryGetValue(tr.Nade, out var list) || list.Count == 0) continue;
var best = list
.Where(tpl => !usedEnts.Contains(tpl.ent))
.Select(tpl => new { tpl.ent, tpl.name, tpl.x, tpl.y, tpl.z, dist2 = Dist2(tr.LastX, tr.LastY, tr.LastZ, tpl.x, tpl.y, tpl.z) })
.OrderBy(a => a.dist2)
.FirstOrDefault();
// NEU: altersabhängiger Fangradius + sanfter Fallback
var ageSec = (float)(DateTimeOffset.UtcNow - tr.Start).TotalSeconds;
// wächst schnell an, deckelt bei 4500u (ca. halbe Mapbreite)
var maxDist = MathF.Min(4500f, 300f + ageSec * 2500f);
var maxDist2 = maxDist * maxDist;
if (best != null && best.dist2 <= maxDist2)
{
tr.BoundEntity = best.ent;
tr.BoundName = best.name;
tr.LastX = best.x; tr.LastY = best.y; tr.LastZ = best.z;
tr.Last = DateTimeOffset.UtcNow;
var tms = NowMs();
lock (tr.Points)
tr.Points.Add(new NadeTrack.Point { X = best.x, Y = best.y, Z = best.z, T = tms, S = tr.CurrentSeg });
usedEnts.Add(best.ent);
}
else if (best != null && tr.Points.Count <= 1)
{
// Fallback: ganz am Anfang binden wir notfalls trotzdem den nächsten Kandidaten,
// damit sofort Bewegungsdaten fließen (vermeidet "nur Wurf+Detonate").
tr.BoundEntity = best.ent;
tr.BoundName = best.name;
tr.LastX = best.x; tr.LastY = best.y; tr.LastZ = best.z;
tr.Last = DateTimeOffset.UtcNow;
var tms = NowMs();
lock (tr.Points)
tr.Points.Add(new NadeTrack.Point { X = best.x, Y = best.y, Z = best.z, T = tms, S = tr.CurrentSeg });
usedEnts.Add(best.ent);
}
}
else
{
var ent = tr.BoundEntity;
if (ent == null || !IsEntValid(ent)) continue;
if (TryGetPos(ent, out var ex, out var ey, out var ez))
{
if (Dist2(tr.LastX, tr.LastY, tr.LastZ, ex, ey, ez) > 0.1f * 0.1f)
{
var tms = NowMs();
lock (tr.Points)
tr.Points.Add(new NadeTrack.Point { X = ex, Y = ey, Z = ez, T = tms, S = tr.CurrentSeg });
tr.LastX = ex; tr.LastY = ey; tr.LastZ = ez;
tr.Last = DateTimeOffset.UtcNow;
}
}
}
}
}
private static float Dist2(float ax, float ay, float az, float bx, float by, float bz)
{
float dx = ax - bx, dy = ay - by, dz = az - bz;
return dx * dx + dy * dy + dz * dz;
}
private static bool LooksAirburst(NadeTrack tr)
{
if (tr.Points.Count < 2) return false;
var a = tr.Points[^1];
var b = tr.Points[^2];
var dtMs = Math.Max(1, a.T - b.T);
var dist = MathF.Sqrt(Dist2(a.X, a.Y, a.Z, b.X, b.Y, b.Z));
var speed3D = 1000f * dist / dtMs; // units/s
var vz = 1000f * Math.Abs(a.Z - b.Z) / dtMs; // vertikale speed
return speed3D > 250f || vz > 120f;
}
// =========================
// Game-Events: Granaten
// =========================
private static string WeaponToKind(string weapon)
{
var w = (weapon ?? "").ToLowerInvariant();
return w switch
{
"hegrenade" or "weapon_hegrenade" => "he",
"flashbang" or "weapon_flashbang" => "flash",
"smokegrenade" or "weapon_smokegrenade" => "smoke",
"decoy" or "weapon_decoy" => "decoy",
"molotov" or "weapon_molotov" or "incgrenade" or "weapon_incgrenade" => "molotov",
_ => "other"
};
}
private int StartTrack(ulong steamId, string nade, float x, float y, float z)
{
var id = Interlocked.Increment(ref _seq);
var tr = new NadeTrack
{
Id = id,
SteamId = steamId,
Nade = nade,
Start = DateTimeOffset.UtcNow,
Last = DateTimeOffset.UtcNow,
LastX = x, LastY = y, LastZ = z,
Sent = 0,
CurrentSeg = 0
};
tr.Points.Add(new NadeTrack.Point { X = x, Y = y, Z = z, T = NowMs(), S = tr.CurrentSeg });
_tracks[id] = tr;
_lastTrackByPlayer[steamId] = id;
_activeTrackByPlayerNade[PNKey(steamId, nade)] = id;
return id;
}
private void AppendPointByPlayer(ulong steamId, float x, float y, float z)
{
if (_lastTrackByPlayer.TryGetValue(steamId, out var id) &&
_tracks.TryGetValue(id, out var tr) && !tr.Finalized)
{
lock (tr.Points)
{
var tms = NowMs();
tr.Points.Add(new NadeTrack.Point { X = x, Y = y, Z = z, T = tms, S = tr.CurrentSeg });
tr.LastX = x; tr.LastY = y; tr.LastZ = z;
tr.Last = DateTimeOffset.UtcNow;
}
}
}
private void AppendPointByPlayerBounce(ulong steamId, float x, float y, float z)
{
if (_lastTrackByPlayer.TryGetValue(steamId, out var id) &&
_tracks.TryGetValue(id, out var tr) && !tr.Finalized)
{
lock (tr.Points)
{
tr.CurrentSeg++; // neues Liniensegment beginnen
var tms = NowMs();
tr.Points.Add(new NadeTrack.Point { X = x, Y = y, Z = z, T = tms, S = tr.CurrentSeg });
tr.LastX = x; tr.LastY = y; tr.LastZ = z;
tr.Last = DateTimeOffset.UtcNow;
}
}
}
private void TryFinalizeTrack(ulong steamId, string nade, float? x, float? y, float? z)
{
var key = PNKey(steamId, nade);
if (!_activeTrackByPlayerNade.TryRemove(key, out var id)) return;
TryFinalizeTrackById(id, true, nade, x, y, z);
_lastTrackByPlayer.TryGetValue(steamId, out var lastId);
if (lastId == id) _lastTrackByPlayer.TryRemove(steamId, out _);
}
private void TryFinalizeTrackById(int id, bool sendEvenIfEmpty, string? nadeOverride = null, float? x = null, float? y = null, float? z = null)
{
if (!_tracks.TryGetValue(id, out var tr) || tr.Finalized) return;
if (x.HasValue && y.HasValue && z.HasValue)
{
lock (tr.Points)
{
tr.Points.Add(new NadeTrack.Point { X = x.Value, Y = y.Value, Z = z.Value, T = NowMs(), S = tr.CurrentSeg });
}
}
// Rest-Delta senden (falls noch nicht raus)
List<object>? delta = null;
lock (tr.Points)
{
if (tr.Points.Count > tr.Sent)
{
delta = new List<object>(tr.Points.Count - tr.Sent);
for (int i = tr.Sent; i < tr.Points.Count; i++)
{
var p = tr.Points[i];
delta.Add(new { x = p.X, y = p.Y, z = p.Z, t = p.T, s = p.S });
}
tr.Sent = tr.Points.Count;
}
}
tr.Finalized = true;
// Finale Nachricht
Enqueue(new
{
type = "nade",
sub = "path",
id = tr.Id,
nade = nadeOverride ?? tr.Nade,
steamId = tr.SteamId,
t = NowMs(),
points = (delta != null && delta.Count > 0) ? delta : null,
final = true
});
_tracks.TryRemove(id, out _);
}
[GameEventHandler]
public HookResult OnGrenadeThrown(EventGrenadeThrown ev, GameEventInfo info)
{
try
{
var p = ev.Userid;
var pawn = p?.PlayerPawn?.Value;
var pos = pawn?.AbsOrigin;
var (gaPitch, gaYaw) = GetStableAim(p, pawn);
string weapon = ev.Weapon ?? "";
string kind = WeaponToKind(weapon);
float tx = pos?.X ?? 0f, ty = pos?.Y ?? 0f, tz = pos?.Z ?? 0f;
var id = StartTrack(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, kind, tx, ty, tz);
try { SampleActiveNadeTracks(); } catch { }
Enqueue(new
{
type = "nade",
sub = "thrown",
id,
nade = kind,
t = NowMs(),
steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL,
name = p?.PlayerName ?? "",
weapon = weapon,
throwPos = new { x = tx, y = ty, z = tz },
throwAim = new { pitch = gaPitch, yaw = NormalizeYaw(gaYaw) }
});
}
catch { }
return HookResult.Continue;
}
// Bounces -> Trajektorie weiter aufzeichnen (Punkt erscheint im nächsten Tick)
[GameEventHandler]
public HookResult OnGrenadeBounce(EventGrenadeBounce ev, GameEventInfo info)
{
try
{
dynamic d = ev;
var p = d.Userid as CCSPlayerController;
AppendPointByPlayerBounce(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, (float)d.X, (float)d.Y, (float)d.Z);
}
catch { }
return HookResult.Continue;
}
[GameEventHandler] public HookResult OnHeDetonate (EventHegrenadeDetonate ev, GameEventInfo info)
{ TryFinalizeDet(ev, "he"); return EnqueueNadeDet(ev, "he"); }
[GameEventHandler] public HookResult OnFlashDetonate (EventFlashbangDetonate ev, GameEventInfo info)
{ TryFinalizeDet(ev, "flash"); return EnqueueNadeDet(ev, "flash"); }
[GameEventHandler] public HookResult OnDecoyDetonate (EventDecoyDetonate ev, GameEventInfo info)
{ TryFinalizeDet(ev, "decoy"); return EnqueueNadeDet(ev, "decoy"); }
private void TryFinalizeDet(GameEvent ev, string kind)
{
try
{
dynamic d = ev;
var p = d.Userid as CCSPlayerController;
TryFinalizeTrack(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, kind, (float)d.X, (float)d.Y, (float)d.Z);
}
catch { }
}
// Smoke: Detonate + start/end mit ETA (+ Pfadabschluss)
[GameEventHandler]
public HookResult OnSmokeDetonate(EventSmokegrenadeDetonate ev, GameEventInfo info)
{
try
{
dynamic d = ev;
var p = d.Userid as CCSPlayerController;
TryFinalizeTrack(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, "smoke", (float)d.X, (float)d.Y, (float)d.Z);
Enqueue(new
{
type = "nade",
sub = "detonate",
nade = "smoke",
t = NowMs(),
steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL,
name = p?.PlayerName ?? "",
pos = new { x = (float)d.X, y = (float)d.Y, z = (float)d.Z }
});
var id = Interlocked.Increment(ref _seq);
var endAt = DateTimeOffset.UtcNow.AddSeconds(_smokeDurationSec);
var state = new AoeState { EndAt = endAt, X = (float)d.X, Y = (float)d.Y, Z = (float)d.Z };
_activeSmokes[id] = state;
Enqueue(new
{
type = "smoke",
state = "start",
id,
t = NowMs(),
endAt = endAt.ToUnixTimeMilliseconds(),
pos = new { x = state.X, y = state.Y, z = state.Z }
});
var token = _serverCts?.Token ?? CancellationToken.None;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(_smokeDurationSec), token);
if (_activeSmokes.TryRemove(id, out _))
{
Enqueue(new { type = "smoke", state = "end", id, t = NowMs() });
}
}
catch { }
}, token);
}
catch { }
return HookResult.Continue;
}
// Molotov: Detonate + start/end mit ETA (+ Pfadabschluss) nur start, wenn kein Airburst
[GameEventHandler]
public HookResult OnMolotovDetonate(EventMolotovDetonate ev, GameEventInfo info)
{
try
{
dynamic d = ev;
var p = d.Userid as CCSPlayerController;
var sid = p?.AuthorizedSteamID?.SteamId64 ?? 0UL;
bool airburst = false;
if (_activeTrackByPlayerNade.TryGetValue(PNKey(sid, "molotov"), out var tid) &&
_tracks.TryGetValue(tid, out var trProbe))
{
airburst = LooksAirburst(trProbe);
}
TryFinalizeTrack(sid, "molotov", (float)d.X, (float)d.Y, (float)d.Z);
Enqueue(new
{
type = "nade",
sub = "detonate",
nade = "molotov",
t = NowMs(),
steamId = sid,
name = p?.PlayerName ?? "",
pos = new { x = (float)d.X, y = (float)d.Y, z = (float)d.Z }
});
if (!airburst)
{
var id = Interlocked.Increment(ref _seq);
var endAt = DateTimeOffset.UtcNow.AddSeconds(_fireDurationSec);
var state = new AoeState { EndAt = endAt, X = (float)d.X, Y = (float)d.Y, Z = (float)d.Z };
_activeFires[id] = state;
Enqueue(new
{
type = "fire",
state = "start",
id,
t = NowMs(),
endAt = endAt.ToUnixTimeMilliseconds(),
pos = new { x = state.X, y = state.Y, z = state.Z }
});
var token = _serverCts?.Token ?? CancellationToken.None;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(_fireDurationSec), token);
if (_activeFires.TryRemove(id, out _))
{
Enqueue(new { type = "fire", state = "end", id, t = NowMs() });
}
}
catch { }
}, token);
}
}
catch { }
return HookResult.Continue;
}
// Generischer Detonate-Helper für HE/Flash/Decoy (Einzel-Event bleibt)
private HookResult EnqueueNadeDet(GameEvent ev, string kind)
{
try
{
dynamic d = ev;
var p = d.Userid as CCSPlayerController;
Enqueue(new
{
type = "nade",
sub = "detonate",
nade = kind,
t = NowMs(),
steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL,
name = p?.PlayerName ?? "",
pos = new { x = (float)d.X, y = (float)d.Y, z = (float)d.Z }
});
}
catch { }
return HookResult.Continue;
}
private HookResult Enqueue(object obj)
{
if (!_enabled || !_serverRunning) return HookResult.Continue;
_outbox.Enqueue(JsonSerializer.Serialize(obj));
_sendSignal.Set();
return HookResult.Continue;
}
private static bool LooksWrongPfxPassword(Exception ex)
{
try
{
var s = (ex?.Message ?? "").ToLowerInvariant();
return s.Contains("password") || s.Contains("kennwort");
}
catch { return false; }
}
private bool TryLoadCertificate(out string usedPath)
{
usedPath = _certPath;
try
{
string pluginDir = ModuleDirectory;
// Pfad ermitteln
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 (LooksWrongPfxPassword(cex))
{
var pwdState = string.IsNullOrEmpty(_certPassword) ? "leer" : "gesetzt";
Logger.LogError($"[WS] TLS-PFX konnte nicht geöffnet werden: vermutlich falsches Passwort. " +
$"Pfad: {usedPath}. CertPassword (aus config.json) ist {pwdState}. " +
$"Setze es mit 'css_ws_certpwd <pass>' oder trage es in {ConfigFileName} unter 'CertPassword' ein.");
_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;
}
}
// =========================
// 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 }));
foreach (var kv in _activeSmokes)
{
var aoe = kv.Value;
if (aoe.EndAt > DateTimeOffset.UtcNow)
{
SendTextFrame(state, JsonSerializer.Serialize(new
{
type = "smoke",
state = "start",
id = kv.Key,
t = nowMs,
endAt = aoe.EndAt.ToUnixTimeMilliseconds(),
pos = new { x = aoe.X, y = aoe.Y, z = aoe.Z }
}));
}
}
foreach (var kv in _activeFires)
{
var aoe = kv.Value;
if (aoe.EndAt > DateTimeOffset.UtcNow)
{
SendTextFrame(state, JsonSerializer.Serialize(new
{
type = "fire",
state = "start",
id = kv.Key,
t = nowMs,
endAt = aoe.EndAt.ToUnixTimeMilliseconds(),
pos = new { x = aoe.X, y = aoe.Y, z = aoe.Z }
}));
}
}
}
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;
}
// --- Hilfsklasse für Referenz-HashSet ---
private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
public static readonly ReferenceEqualityComparer Instance = new();
public new bool Equals(object x, object y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
}
}