2018 lines
73 KiB
C#
2018 lines
73 KiB
C#
// 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;
|
||
}
|
||
}
|
||
}
|