2025-08-20 22:56:36 +02:00

2018 lines
73 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// CS2WebSocketTelemetryPlugin.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.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<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;
// 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<int, ClientState> _clients = new();
private int _clientSeq = 0;
// --- Outgoing Queue ---
private readonly ConcurrentQueue<string> _outbox = new();
private readonly AutoResetEvent _sendSignal = new(false);
// --- Tick / Sampling ---
private double _accumulator = 0.0;
private const double MaxFrameDt = 0.25;
private DateTime _lastTick = DateTime.UtcNow;
// --- Stabiler Aim ---
private readonly ConcurrentDictionary<ulong, (float pitch, float yaw)> _lastAimByPlayer = new();
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private static bool IsFinite(float v) => !(float.IsNaN(v) || float.IsInfinity(v)) && Math.Abs(v) < 1e6;
private static float NormalizeYaw(float yaw)
{
yaw %= 360f;
if (yaw < 0) yaw += 360f;
return yaw;
}
private static float ClampPitch(float pitch)
{
if (pitch < -89f) pitch = -89f;
if (pitch > 89f) pitch = 89f;
return pitch;
}
// =========================
// Blickrichtung (Client-Kamera)
// =========================
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<Listeners.OnTick>(OnTick);
_mapName = Server.MapName ?? "";
RegisterListener<Listeners.OnMapStart>(OnMapStart);
RegisterListener<Listeners.OnEntityCreated>(OnEntityCreated);
RegisterListener<Listeners.OnEntityDeleted>(OnEntityDeleted);
RegisterEventHandler<EventSmokegrenadeDetonate>(OnSmokeDetonate);
RegisterEventHandler<EventHegrenadeDetonate>(OnHeDetonate);
RegisterEventHandler<EventFlashbangDetonate>(OnFlashDetonate);
RegisterEventHandler<EventMolotovDetonate>(OnMolotovDetonate);
RegisterEventHandler<EventDecoyStarted>(OnDecoyStart);
RegisterEventHandler<EventDecoyDetonate>(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: "<ws[s]://host:port/path>")]
public void CmdUrl(CCSPlayerController? caller, CommandInfo cmd)
{
var url = cmd.GetArg(1);
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != "ws" && uri.Scheme != "wss"))
{
cmd.ReplyToCommand($"[WS] Ungültige URL: {url}");
return;
}
_useTls = uri.Scheme == "wss";
_bindHost = string.IsNullOrWhiteSpace(uri.Host) ? "0.0.0.0" : uri.Host;
_bindPort = uri.IsDefaultPort ? (_useTls ? 443 : 80) : uri.Port;
_bindPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
if (_bindHost == "127.0.0.1") _bindHost = "0.0.0.0";
var scheme = _useTls ? "wss" : "ws";
cmd.ReplyToCommand($"[WS] Bind = {scheme}://{_bindHost}:{_bindPort}{_bindPath}");
SaveConfig();
if (_enabled) { StopWebSocket(); StartWebSocket(); }
}
[ConsoleCommand("css_ws_rate", "Sendefrequenz in Hz (Standard 10)")]
[CommandHelper(minArgs: 1, usage: "<hz>")]
public void CmdRate(CCSPlayerController? caller, CommandInfo cmd)
{
if (int.TryParse(cmd.GetArg(1), out var hz) && hz > 0 && hz <= 128)
{
_sendHz = hz;
SaveConfig();
cmd.ReplyToCommand($"[WS] Sendefrequenz = {_sendHz} Hz");
}
else cmd.ReplyToCommand("[WS] Ungültig. Bereich: 1..128");
}
[ConsoleCommand("css_ws_cert", "Setzt das TLS-Zertifikat (PFX-Datei)")]
[CommandHelper(minArgs: 1, usage: "<pfad-zum.pfx>")]
public void CmdCert(CCSPlayerController? caller, CommandInfo cmd)
{
var input = cmd.GetArg(1);
_certPath = Path.IsPathRooted(input) ? input : Path.Combine(ModuleDirectory, input);
_serverCert = null; // neu laden beim Start
cmd.ReplyToCommand($"[WS] Zertifikatspfad gesetzt: '{_certPath}'");
SaveConfig();
if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); }
}
[ConsoleCommand("css_ws_certpwd", "Setzt das Passwort für das PFX-Zertifikat (oder '-' zum Leeren)")]
[CommandHelper(minArgs: 1, usage: "<passwort|->")]
public void CmdCertPwd(CCSPlayerController? caller, CommandInfo cmd)
{
var pwd = cmd.GetArg(1);
_certPassword = pwd == "-" ? "" : pwd;
_serverCert = null; // neu laden beim Start
cmd.ReplyToCommand($"[WS] Zertifikatspasswort {(string.IsNullOrEmpty(_certPassword) ? "gelöscht" : "gesetzt")}.");
SaveConfig();
if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); }
}
// =========================
// Tick / Spieler-Snapshot
// =========================
private void OnTick()
{
if (!_enabled || !_serverRunning) return;
var now = DateTime.UtcNow;
var dt = (now - _lastTick).TotalSeconds;
_lastTick = now;
if (dt > MaxFrameDt) dt = MaxFrameDt;
_accumulator += dt;
var targetDt = 1.0 / Math.Max(1, _sendHz);
if (_accumulator < targetDt) return;
_accumulator = 0;
var playersList = new List<object>();
foreach (var p in Utilities.GetPlayers())
{
try
{
if (p == null || !p.IsValid || p.IsBot || p.IsHLTV) continue;
var pawnHandle = p.PlayerPawn;
if (pawnHandle == null || !pawnHandle.IsValid) continue;
var pawn = pawnHandle.Value;
if (pawn == null) continue;
// Position
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<int, NadeInfo> _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<ulong, PlayerSnap> _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<object> PredictPathFromState(
Vector3 start, Vector3 v0, int points, float dt, float gravityAbs = 800f, bool includeStart = true)
{
var list = new List<object>(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<object>();
var toRemove = new List<int>();
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<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.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 <pfad>.");
return false;
}
try
{
_serverCert = new X509Certificate2(
usedPath,
string.IsNullOrEmpty(_certPassword) ? null : _certPassword,
X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable
);
}
catch (CryptographicException cex) when ((cex.Message ?? "").IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 ||
(cex.Message ?? "").IndexOf("kennwort", StringComparison.OrdinalIgnoreCase) >= 0)
{
var pwdState = string.IsNullOrEmpty(_certPassword) ? "leer" : "gesetzt";
Logger.LogError($"[WS] TLS-PFX konnte nicht geöffnet werden: vermutlich falsches Passwort. Pfad: {usedPath}. CertPassword ist {pwdState}.");
_serverCert = null;
return false;
}
catch (CryptographicException cex)
{
Logger.LogError($"[WS] TLS-PFX Fehler ({usedPath}): {cex.Message}");
_serverCert = null;
return false;
}
if (_serverCert == null)
return false;
if (!_serverCert.HasPrivateKey)
{
Logger.LogError($"[WS] Zertifikat geladen, aber ohne Private Key: {Path.GetFileName(usedPath)}. Bitte PFX mit privatem Schlüssel verwenden.");
_serverCert = null;
return false;
}
try
{
Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)} | Subject: {_serverCert.Subject} | Gültig bis: {_serverCert.NotAfter:u}");
}
catch
{
Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)}");
}
return true;
}
catch (Exception ex)
{
Logger.LogError($"[WS] Zertifikat konnte nicht geladen werden: {ex.Message}");
_serverCert = null;
return false;
}
}
}