diff --git a/CS2WebSocketTelemetryPlugin/CS2WebSocketTelemetryPlugin.cs b/CS2WebSocketTelemetryPlugin/CS2WebSocketTelemetryPlugin.cs
index dc961cc..2361f0b 100644
--- a/CS2WebSocketTelemetryPlugin/CS2WebSocketTelemetryPlugin.cs
+++ b/CS2WebSocketTelemetryPlugin/CS2WebSocketTelemetryPlugin.cs
@@ -7,6 +7,7 @@ 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;
@@ -28,9 +29,9 @@ namespace WsTelemetry;
public class WebSocketTelemetryPlugin : BasePlugin
{
public override string ModuleName => "WS Telemetry";
- public override string ModuleVersion => "1.6.0";
+ public override string ModuleVersion => "1.7.0";
public override string ModuleAuthor => "you + ChatGPT";
- public override string ModuleDescription => "WS(S)-Server: Spielerpositionen + Blickrichtung + Map";
+ public override string ModuleDescription => "WS(S)-Server: Spielerpositionen + Blickrichtung + Map + Nade Trajectories (Prediction + Backfill)";
// --- Konfiguration ---
private volatile bool _enabled = false;
@@ -63,8 +64,14 @@ public class WebSocketTelemetryPlugin : BasePlugin
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
@@ -78,7 +85,9 @@ public class WebSocketTelemetryPlugin : BasePlugin
Url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}",
CertPath = string.IsNullOrWhiteSpace(_certPath) ? "cert.pfx" : _certPath,
CertPassword = _certPassword,
- SendHz = _sendHz
+ SendHz = _sendHz,
+ Predict = _predictEnabled,
+ PredPoints = _predPoints
};
var jsonEx = JsonSerializer.Serialize(example, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(path, jsonEx, Encoding.UTF8);
@@ -131,7 +140,11 @@ public class WebSocketTelemetryPlugin : BasePlugin
// Sendefrequenz
if (cfg.SendHz is int hz && hz >= 1 && hz <= 128) _sendHz = hz;
- Logger.LogInformation($"[WS] Konfiguration geladen ({_bindHost}:{_bindPort}{_bindPath}, tls={_useTls}, hz={_sendHz})");
+ // 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)
{
@@ -163,7 +176,9 @@ public class WebSocketTelemetryPlugin : BasePlugin
Url = url,
CertPath = cp,
CertPassword = _certPassword,
- SendHz = _sendHz
+ SendHz = _sendHz,
+ Predict = _predictEnabled,
+ PredPoints = _predPoints
};
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
@@ -216,7 +231,7 @@ public class WebSocketTelemetryPlugin : BasePlugin
}
// =========================
- // Blickrichtung (Client-Kamera) – EyeAngles bevorzugt
+ // Blickrichtung (Client-Kamera)
// =========================
private static bool IsAlive(dynamic pawn)
@@ -235,13 +250,12 @@ public class WebSocketTelemetryPlugin : BasePlugin
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 { 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;
}
- /// Winkel vorzugsweise vom SceneNode; Fallback: NodeToWorld.Angles, root.AbsRotation.
private static bool TryGetAnglesFromSceneNode(dynamic root, out float pitch, out float yaw, out float roll)
{
pitch = 0f; yaw = 0f; roll = 0f;
@@ -261,18 +275,15 @@ public class WebSocketTelemetryPlugin : BasePlugin
return false;
}
- /// Observer-Target via Pawn (nicht Controller!), robust über verschiedene Felder.
private static dynamic? TryGetObserverTargetFromPawn(dynamic pawn)
{
- // typische Felder am Observer-Pawn / Observer-Komponente
- try { var os = pawn.ObserverServices; var h = os?.m_hObserverTarget; var v = (h != null ? (h.Value ?? h) : null); if (v != null) return v; } catch { }
- try { var h = pawn.m_hObserverTarget; var v = (h != null ? (h.Value ?? h) : null); if (v != null) return v; } catch { }
+ try { var 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 { }
+ try { var v = pawn.ObserverTarget; if (v != null) return (v.Value ?? v); } catch { }
return null;
}
- /// Irgendeine Entität in einen Pawn auflösen (Controller→Pawn, Entity→Pawn, bereits Pawn).
private static dynamic? AsPawn(dynamic entity)
{
if (entity == null) return null;
@@ -283,7 +294,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
return null;
}
- /// Pawn, dessen Kamera wir darstellen: lebend → eigener Pawn; sonst → Observer-Target (vom Pawn aus).
private static bool TryGetClientCameraPawn(CCSPlayerController ctrl, dynamic pawn, out dynamic camPawn)
{
camPawn = null;
@@ -294,7 +304,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
return false;
}
- /// Endgültige View-Winkel (EyeAngles bevorzugt, sonst SceneNode/AbsRotation, dann Fallbacks).
private static bool TryGetViewAngles(CCSPlayerController ctrl, dynamic pawn, out float pitch, out float yaw, out float roll)
{
pitch = 0f; yaw = 0f; roll = 0f;
@@ -302,7 +311,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
dynamic camPawn;
if (TryGetClientCameraPawn(ctrl, pawn, out camPawn))
{
- // 1) Echte Kamera-/Aim-Winkel
try
{
var a = camPawn.EyeAngles;
@@ -311,7 +319,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
}
catch { }
- // 2) Modellorientierung als Fallback
if (TryGetAnglesFromSceneNode(camPawn, out pitch, out yaw, out roll))
return true;
@@ -324,7 +331,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
catch { }
}
- // 3) letzte Fallbacks
if (TryReadAngles(pawn, out pitch, out yaw, out roll)) return true;
return TryReadAngles(ctrl, out pitch, out yaw, out roll);
}
@@ -339,19 +345,18 @@ public class WebSocketTelemetryPlugin : BasePlugin
private static bool IsTiny(float v) => MathF.Abs(v) <= 1e-5f;
private static bool IsTinyPair(float a, float b) => IsTiny(a) && IsTiny(b);
- private (float pitch, float yaw) GetStableAim(CCSPlayerController p, dynamic pawn)
+ private (float pitch, float yaw) GetStableAim(CCSPlayerController p, object pawnObj)
{
+ dynamic pawn = pawnObj; // intern weiterhin dynamisch arbeiten
float vp=0, vy=0, vr=0;
- // 1) Bevorzugt: echte Kamera-/View-Winkel
if (TryGetViewAngles(p, pawn, out vp, out vy, out vr))
{
vp = ClampPitch(vp); vy = NormalizeYaw(vy);
- if (!IsTinyPair(vp, vy)) // 0/0 ist verdächtig → nicht akzeptieren
+ if (!IsTinyPair(vp, vy))
return StoreAim(p, vp, vy);
}
- // 2) Fallback: Pawn EyeAngles (falls vorhanden)
try
{
dynamic a = pawn?.EyeAngles;
@@ -365,7 +370,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
}
catch { }
- // 3) Fallback: Modell-/Feet-Yaw aus AbsRotation
try
{
dynamic r = pawn?.AbsRotation;
@@ -379,7 +383,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
}
catch { }
- // 4) Fallback: Bewegungsrichtung (nur wenn sich der Spieler bewegt)
try
{
var vel = pawn?.AbsVelocity;
@@ -395,7 +398,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
}
catch { }
- // 5) Letzter gültiger Aim oder neutral
{
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
if (_lastAimByPlayer.TryGetValue(sid, out var last)) return last;
@@ -412,7 +414,6 @@ public class WebSocketTelemetryPlugin : BasePlugin
float yaw = (float)ea.Y;
float roll = (float)ea.Z;
- // wie üblich begrenzen/normalisieren
if (IsFinite(pitch) && IsFinite(yaw))
return (ClampPitch(pitch), NormalizeYaw(yaw), roll);
}
@@ -420,16 +421,44 @@ public class WebSocketTelemetryPlugin : BasePlugin
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");
+ Logger.LogInformation("[WS] Plugin geladen. Kommandos: css_ws_enable, css_ws_restart, css_ws_reloadcfg, css_ws_url, css_ws_rate, css_ws_cert, css_ws_certpwd, css_ws_sendmap, css_ws_pred");
RegisterListener(OnTick);
_mapName = Server.MapName ?? "";
RegisterListener(OnMapStart);
+ RegisterListener(OnEntityCreated);
+ RegisterListener(OnEntityDeleted);
+
+ RegisterEventHandler(OnSmokeDetonate);
+ RegisterEventHandler(OnHeDetonate);
+ RegisterEventHandler(OnFlashDetonate);
+ RegisterEventHandler(OnMolotovDetonate);
+ RegisterEventHandler(OnDecoyStart);
+ RegisterEventHandler(OnDecoyDetonate);
+
LoadAndApplyConfig();
_enabled = true;
@@ -478,6 +507,19 @@ public class WebSocketTelemetryPlugin : BasePlugin
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)
{
@@ -503,15 +545,12 @@ public class WebSocketTelemetryPlugin : BasePlugin
{
try
{
- // Config immer neu laden (ohne Beispieldatei zu erzeugen)
LoadAndApplyConfig(generateIfMissing: false);
- // internen Zustand zurücksetzen
_lastTick = DateTime.UtcNow;
_accumulator = 0;
while (_outbox.TryDequeue(out _)) { }
- // Server neu starten (nur wenn derzeit/enabled)
var wasEnabled = _enabled;
StopWebSocket();
if (wasEnabled)
@@ -633,12 +672,12 @@ public class WebSocketTelemetryPlugin : BasePlugin
var pawn = pawnHandle.Value;
if (pawn == null) continue;
- // --- Position (SceneNode.AbsOrigin bevorzugt)
+ // Position
float posX, posY, posZ;
try
{
var node = pawn?.CBodyComponent?.SceneNode;
- var org = node != null ? node.AbsOrigin : pawn.AbsOrigin;
+ var org = node != null ? node.AbsOrigin : pawn.AbsOrigin;
posX = (float)org.X;
posY = (float)org.Y;
posZ = (float)org.Z;
@@ -651,40 +690,63 @@ public class WebSocketTelemetryPlugin : BasePlugin
posZ = (float)org.Z;
}
- // --- Blickrichtung (Yaw aus EyeAngles, bereits normalisiert/geclamped)
- var (_, eyeYaw, _) = ReadEyeAngles(pawn);
- float yawDeg = eyeYaw;
-
- // --- viewAngle wie im Beispiel: AbsRotation (Pitch/Yaw/Roll)
+ // viewAngle exemplarisch aus AbsRotation
float angX = 0f, angY = 0f, angZ = 0f;
try
{
var ang = pawn.AbsRotation;
- angX = (float)ang.X; // pitch
- angY = (float)ang.Y; // yaw
- angZ = (float)ang.Z; // roll
+ angX = (float)ang.X;
+ angY = (float)ang.Y;
+ angZ = (float)ang.Z;
}
catch { }
- // --- Alive-Status
bool isAlive = true;
try { int ls = (int)pawn.LifeState; isAlive = (ls == 0); } catch { }
if (!isAlive) { try { isAlive = ((int)pawn.Health) > 0; } catch { } }
- // --- Minimales Player-Objekt + viewAngle
+ 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 },
+ 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
+ alive = isAlive
});
}
- catch { /* Spieler überspringen bei Fehlern */ }
+ catch { }
}
+ // Nade-Update + Backfill
+ UpdateNadesInTick();
+
if (playersList.Count == 0) return;
var payload = new
@@ -697,7 +759,756 @@ public class WebSocketTelemetryPlugin : BasePlugin
Broadcast(JsonSerializer.Serialize(payload));
}
+ // =========================
+ // Grenade tracking
+ // =========================
+ private sealed class NadeInfo
+ {
+ public required int Id;
+ public required string Kind;
+ public CEntityInstance? Ent; // kann kurzzeitig null sein (nur Meta)
+ public ulong OwnerSteamId;
+ public long CreatedMs;
+
+ public bool Announced; // create schon gesendet?
+ public (float x,float y,float z) LastPos;
+ public long LastT;
+
+ // Backfill-Puffer
+ public bool FirstValidSeen;
+ public (long t, (float x,float y,float z) pos) FirstSample;
+ public bool SecondValidSeen;
+ public (long t, (float x,float y,float z) pos) SecondSample;
+ public bool FixSent;
+
+ // Prediction
+ public bool PredSent;
+ }
+
+ private int _nadeSeq = 0;
+ private readonly ConcurrentDictionary _nades = new(); // Id -> info
+
+ private struct PlayerSnap
+ {
+ public Vector3 Origin;
+ public Vector3 Eye;
+ public Vector3 Vel;
+ public float Pitch;
+ public float Yaw;
+ public long T;
+ }
+ private readonly ConcurrentDictionary _lastPlayerSnap = new();
+
+
+ private static bool IsValidPos((float x, float y, float z) p)
+ {
+ if (!float.IsFinite(p.x) || !float.IsFinite(p.y) || !float.IsFinite(p.z))
+ return false;
+ if (Math.Abs(p.x) < 1f && Math.Abs(p.y) < 1f && Math.Abs(p.z) < 1f)
+ return false;
+ if (Math.Abs(p.x) > 100000f || Math.Abs(p.y) > 100000f || Math.Abs(p.z) > 100000f)
+ return false;
+ return true;
+ }
+
+ // =========================
+ // Helpers
+ // =========================
+
+ private static (float x, float y, float z) ReadInitialNadePos(dynamic ent)
+ {
+ try { var v = ent.m_vInitialPosition; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {}
+ try { var v = ent.InitialPosition; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {}
+ return (0f, 0f, 0f);
+ }
+
+ private static (float x, float y, float z) ReadInitialNadeVel(dynamic ent)
+ {
+ try { var v = ent.m_vInitialVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {}
+ try { var v = ent.InitialVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {}
+ return (0f, 0f, 0f);
+ }
+
+ private static bool IsGrenadeDesignerName(string? name)
+ {
+ if (string.IsNullOrEmpty(name)) return false;
+ name = name.ToLowerInvariant();
+ return name.Contains("grenade_projectile") ||
+ name.Contains("flashbang_projectile") ||
+ name.Contains("smokegrenade_projectile") ||
+ name.Contains("molotov_projectile") ||
+ name.Contains("decoy_projectile");
+ }
+
+ private static string KindFromDesignerName(string name)
+ {
+ name = name.ToLowerInvariant();
+ if (name.Contains("hegrenade")) return "he";
+ if (name.Contains("flashbang")) return "flash";
+ if (name.Contains("smokegrenade")) return "smoke";
+ if (name.Contains("molotov")) return "molotov";
+ if (name.Contains("decoy")) return "decoy";
+ return "other";
+ }
+
+ private static (float pitch, float yaw, float roll) ReadAbsAngles(dynamic ent)
+ {
+ try {
+ var node = ent?.GameSceneNode;
+ if (node != null) {
+ try { var r = node.AbsRotation; return ((float)r.X, (float)r.Y, (float)r.Z); } catch {}
+ try { var tf = node.NodeToWorld; var a = tf.Angles; return ((float)a.X, (float)a.Y, (float)a.Z); } catch {}
+ }
+ } catch {}
+
+ try { var r = ent?.AbsRotation; return ((float)r.X, (float)r.Y, (float)r.Z); } catch {}
+
+ return (0f, 0f, 0f);
+ }
+
+ private static (float x, float y, float z) ReadAbsOrigin(dynamic ent)
+ {
+ try {
+ var node = ent?.GameSceneNode;
+ if (node != null) {
+ try { var o = node.AbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+ try { var o = node.m_vecAbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+ try { var tf = node.NodeToWorld; var a = tf.Origin; return ((float)a.X, (float)a.Y, (float)a.Z); } catch {}
+ }
+ } catch {}
+
+ try {
+ var bodyComp = ent?.CBodyComponent;
+ if (bodyComp != null) {
+ var nd = bodyComp.SceneNode;
+ if (nd != null) {
+ try { var o = nd.AbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+ try { var o = nd.m_vecAbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+ }
+ }
+ } catch {}
+
+ try { var o = ent?.AbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+ try { var o = ent?.m_vecAbsOrigin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+
+ try {
+ var transform = ent?.m_pGameSceneNode?.m_nodeToWorld;
+ if (transform != null) {
+ return ((float)transform.m_vOrigin.X, (float)transform.m_vOrigin.Y, (float)transform.m_vOrigin.Z);
+ }
+ } catch {}
+
+ try {
+ var physics = ent?.Physics;
+ if (physics != null) {
+ try { var o = physics.Origin; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+ try { var o = physics.Position; return ((float)o.X, (float)o.Y, (float)o.Z); } catch {}
+ }
+ } catch {}
+
+ return ReadInitialNadePos(ent);
+ }
+
+ private static (float x, float y, float z) ReadAbsVelocity(dynamic ent)
+ {
+ try { var v = ent?.AbsVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {}
+ try { var v = ent?.m_vecAbsVelocity; return ((float)v.X, (float)v.Y, (float)v.Z); } catch {}
+ try { var v = ent?.Physics?.Velocity;return ((float)v.X, (float)v.Y, (float)v.Z); } catch {}
+
+ return ReadInitialNadeVel(ent);
+ }
+
+ private static ulong TryGetThrowerSteamId(dynamic projectileBase)
+ {
+ try
+ {
+ dynamic p = projectileBase; // dynamisch binden
+
+ // 1) Thrower (Pawn) -> Controller -> SteamID
+ try
+ {
+ dynamic th = p.m_hThrower; // kann ein Handle sein
+ dynamic thVal = th is null ? null : (th.Value ?? th);
+ dynamic throwerPawn = thVal ?? p.Thrower;
+
+ if (throwerPawn != null)
+ {
+ try
+ {
+ dynamic ctrl = throwerPawn.Controller;
+ if (ctrl != null)
+ return (ulong)(ctrl.AuthorizedSteamID?.SteamId64 ?? 0UL);
+ }
+ catch { /* weiter probieren */ }
+ }
+ }
+ catch { /* weiter */ }
+
+ // 2) OwnerEntity (Pawn/Entity) -> Controller -> SteamID
+ try
+ {
+ dynamic oh = p.m_hOwnerEntity;
+ dynamic ohVal = oh is null ? null : (oh.Value ?? oh);
+ dynamic ownerEnt = ohVal ?? p.OwnerEntity;
+
+ if (ownerEnt != null)
+ {
+ try
+ {
+ dynamic ctrl = ownerEnt.Controller;
+ if (ctrl != null)
+ return (ulong)(ctrl.AuthorizedSteamID?.SteamId64 ?? 0UL);
+ }
+ catch { }
+ }
+ }
+ catch { }
+ }
+ catch { }
+
+ return 0UL;
+ }
+
+
+ // =========================
+ // Prediction (vereinfachte Ballistik)
+ // =========================
+
+ private static Vector3 AnglesToForward(float pitchDeg, float yawDeg)
+ {
+ // Pitch: Up ist negativ in Source-Notation, deshalb Minus
+ float pitch = -pitchDeg * (float)Math.PI / 180f;
+ float yaw = yawDeg * (float)Math.PI / 180f;
+ float cp = MathF.Cos(pitch);
+ return new Vector3(
+ cp * MathF.Cos(yaw),
+ cp * MathF.Sin(yaw),
+ MathF.Sin(pitch)
+ );
+ }
+
+ // =========================
+ // Hooks
+ // =========================
+
+ private void OnEntityCreated(CEntityInstance ent)
+ {
+ try
+ {
+ var name = ent.DesignerName ?? "";
+ if (!IsGrenadeDesignerName(name)) return;
+
+ var kind = KindFromDesignerName(name);
+ var id = Interlocked.Increment(ref _nadeSeq);
+ var owner = TryGetThrowerSteamId(ent);
+
+ var info = new NadeInfo {
+ Id = id, Kind = kind, Ent = ent, OwnerSteamId = owner,
+ CreatedMs = NowMs(),
+ Announced = false,
+ LastPos = (0,0,0),
+ LastT = 0,
+ FirstValidSeen = false,
+ SecondValidSeen = false,
+ FixSent = false,
+ PredSent = false
+ };
+ _nades[id] = info;
+
+ // Prediction mit Retry; fällt auf Spieler-Snapshot zurück, wenn Projectile-Netvars leer sind
+ if (_predictEnabled)
+ {
+ const int maxTries = 10; // bis zu ~10 Versuche
+ const float tryInterval = 0.016f; // alle ~1 Frame (bei ~64 tick)
+ int attempt = 0;
+
+ void TryPredict()
+ {
+ static string V3(Vector3 v) => $"({v.X:F2},{v.Y:F2},{v.Z:F2})";
+ static string T3((float x,float y,float z) t) => $"({t.x:F2},{t.y:F2},{t.z:F2})";
+
+ try
+ {
+ attempt++;
+ Logger.LogInformation($"[WS-PRED] fire id={id} kind={kind}: attempt {attempt}/{maxTries}");
+
+ if (ent == null || !ent.IsValid || !_nades.ContainsKey(id))
+ {
+ Logger.LogWarning($"[WS-PRED] id={id} kind={kind}: entity invalid or gone -> abort");
+ return;
+ }
+
+ if (attempt == 1)
+ {
+ try
+ {
+ var dn = ent.DesignerName ?? "(null)";
+ var typeName = ent.GetType().Name;
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: DesignerName='{dn}', CLR='{typeName}' owner={owner}");
+ }
+ catch { }
+ }
+
+ // 1) Pos/Vel aus Netvars (oder initial) lesen
+ var pAbs = ReadAbsOrigin(ent);
+ bool pAbsValid = IsValidPos(pAbs);
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: AbsOrigin={T3(pAbs)} valid={pAbsValid}");
+
+ (float x,float y,float z) p0 = pAbs;
+ if (!pAbsValid)
+ {
+ var pInit = ReadInitialNadePos(ent);
+ bool pInitValid = IsValidPos(pInit);
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: InitialPos={T3(pInit)} valid={pInitValid}");
+ if (pInitValid) p0 = pInit;
+ }
+
+ var vAbs = ReadAbsVelocity(ent);
+ var vInit = ReadInitialNadeVel(ent);
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: AbsVel=({vAbs.x:F2},{vAbs.y:F2},{vAbs.z:F2}) | InitVel=({vInit.x:F2},{vInit.y:F2},{vInit.z:F2})");
+
+ bool posOk = IsValidPos(p0);
+ bool velOk = !(vAbs.x==0 && vAbs.y==0 && vAbs.z==0) || !(vInit.x==0 && vInit.y==0 && vInit.z==0);
+
+ Vector3 start3 = posOk ? new Vector3(p0.x, p0.y, p0.z) : Vector3.Zero;
+ Vector3 v0 = Vector3.Zero;
+ if (!(vAbs.x==0 && vAbs.y==0 && vAbs.z==0)) v0 = new Vector3(vAbs.x, vAbs.y, vAbs.z);
+ else if (!(vInit.x==0 && vInit.y==0 && vInit.z==0)) v0 = new Vector3(vInit.x, vInit.y, vInit.z);
+
+ // 2) Fallback: Spieler-Snapshot (Owner → jüngster Spieler)
+ if (start3 == Vector3.Zero || v0 == Vector3.Zero)
+ {
+ bool haveSnap = false;
+ PlayerSnap snap = default;
+
+ if (owner != 0 && _lastPlayerSnap.TryGetValue(owner, out snap))
+ {
+ haveSnap = true;
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: using owner snap steamid={owner}");
+ }
+ else
+ {
+ // jüngster Snapshot eines Spielers
+ ulong picked = 0; long bestT = 0;
+ foreach (var kv in _lastPlayerSnap)
+ {
+ if (kv.Value.T > bestT)
+ {
+ picked = kv.Key; bestT = kv.Value.T; snap = kv.Value;
+ }
+ }
+ if (picked != 0) { haveSnap = true; Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: using most recent player snap steamid={picked}"); }
+ }
+
+ if (haveSnap)
+ {
+ var snapStart = snap.Eye == default ? snap.Origin + new Vector3(0,0,64f) : snap.Eye;
+ var dir = AnglesToForward(snap.Pitch, snap.Yaw);
+
+ if (start3 == Vector3.Zero) start3 = snapStart;
+ if (v0 == Vector3.Zero) v0 = dir * 750f + snap.Vel;
+
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: snap start={V3(start3)} aim=({snap.Pitch:F1},{snap.Yaw:F1}) v0={V3(v0)}");
+ }
+ else
+ {
+ // kein Snap — ggf. nochmal warten
+ if (!posOk && attempt < maxTries)
+ {
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: no pos & no snap -> retry in {tryInterval*1000:F0}ms");
+ AddTimer(tryInterval, TryPredict);
+ return;
+ }
+ }
+ }
+
+ // Wenn immer noch kein Start: letzter Versuch (nochmal warten)
+ if (start3 == Vector3.Zero)
+ {
+ if (attempt < maxTries)
+ {
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: start still unknown -> retry in {tryInterval*1000:F0}ms");
+ AddTimer(tryInterval, TryPredict);
+ return;
+ }
+ Logger.LogWarning($"[WS-PRED] id={id} kind={kind}: giving up (no start after {attempt} tries)");
+ return;
+ }
+
+ // Velocity notfalls aus Richtung schätzen (falls bis hier noch 0)
+ if (v0 == Vector3.Zero)
+ {
+ // Schätze Richtung aus Start → (falls möglich) nächsten Tick – sonst nimm Eye-Aim aus jüngstem Snap
+ Vector3 estDir = Vector3.UnitX; // dummy
+ bool gotDir = false;
+ // quickest: nimm jüngsten Snap
+ ulong bestSid=0; long bestTs=0; PlayerSnap bestSnap=default;
+ foreach (var kv in _lastPlayerSnap) if (kv.Value.T > bestTs) { bestSid=kv.Key; bestTs=kv.Value.T; bestSnap=kv.Value; }
+ if (bestSid!=0)
+ {
+ estDir = AnglesToForward(bestSnap.Pitch, bestSnap.Yaw);
+ gotDir = true;
+ }
+ if (!gotDir) estDir = Vector3.UnitX;
+
+ v0 = estDir * 750f;
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: v0 missing -> estimated v0={V3(v0)}");
+ }
+
+ float dt = GetTickInterval();
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: start={V3(start3)} v0={V3(v0)} dt={dt:F4} points={_predPoints}");
+
+ var pts = PredictPathFromState(start3, v0, _predPoints, dt, 800f, includeStart: true);
+ if (pts.Count == 0)
+ {
+ if (attempt < maxTries)
+ {
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: 0 points -> retry in {tryInterval*1000:F0}ms");
+ AddTimer(tryInterval, TryPredict);
+ return;
+ }
+ Logger.LogWarning($"[WS-PRED] id={id} kind={kind}: 0 points after {attempt} tries -> give up");
+ return;
+ }
+
+ Broadcast(JsonSerializer.Serialize(new
+ {
+ type = "nade_pred",
+ t = NowMs(),
+ id = id,
+ kind = kind,
+ owner = owner,
+ points = pts
+ }));
+ Logger.LogInformation($"[WS-PRED] id={id} kind={kind}: nade_pred broadcast sent ({pts.Count} pts)");
+
+ if (_nades.TryGetValue(id, out var n)) n.PredSent = true;
+ }
+ catch (Exception exOuter)
+ {
+ Logger.LogError($"[WS-PRED] id={id} kind={kind}: outer exception: {exOuter.Message}");
+ }
+ }
+
+ AddTimer(tryInterval, TryPredict);
+ }
+
+ // Kleiner Delay, bis Netvars sicher da sind → create senden
+ AddTimer(0.05f, () =>
+ {
+ try
+ {
+ if (ent == null || !ent.IsValid || !_nades.ContainsKey(id)) return;
+
+ var pos = ReadAbsOrigin(ent);
+ var vel = ReadAbsVelocity(ent);
+
+ if (!IsValidPos(pos))
+ pos = ReadInitialNadePos(ent);
+
+ if (!IsValidPos(pos))
+ return;
+
+ var ang = ReadAbsAngles(ent);
+
+ if (_nades.TryGetValue(id, out var n))
+ {
+ n.LastPos = pos;
+ n.LastT = NowMs();
+ n.Announced = true;
+ n.FirstValidSeen = true;
+ n.FirstSample = (n.LastT, pos);
+ }
+
+ Broadcast(JsonSerializer.Serialize(new {
+ type = "nade_create",
+ t = NowMs(),
+ id = id,
+ kind = kind,
+ owner= owner,
+ pos = new { x = pos.x, y = pos.y, z = pos.z },
+ vel = new { x = vel.x, y = vel.y, z = vel.z },
+ ang = new { pitch = ang.pitch, yaw = ang.yaw, roll = ang.roll }
+ }));
+ }
+ catch { /* ignore */ }
+ });
+ }
+ catch { /* ignore */ }
+ }
+
+
+ private static List