initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

View file

@ -0,0 +1,15 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// Controller-side schema writes (the pawn-side equivalents live in PawnWriter). Currently just the scoreboard clan tag —
// kept here so the field name + the SetStateChanged notify contract live together, like every other engine write.
internal static class ControllerWriter
{
public static void SetClan(CCSPlayerController p, string tag)
{
p.Clan = tag;
Utilities.SetStateChanged(p, EngineNames.CCSPlayerController, EngineNames.Clan);
}
}

View file

@ -0,0 +1,99 @@
using System.Runtime.InteropServices;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
namespace Outnumbered.Engine;
// Deals real, attributed damage as if `source` dealt it to `target`, returning the actual damage dealt. By default the hit
// re-enters the offense hook so it scales with the source's build + handicap (Explode-on-Kill relies on this) and fires
// EventPlayerDeath on a kill (proper credit / GG-rung / killstreak). A TakeDamageOld invoke does NOT raise player_hurt, so
// EventPlayerHurt effects (lifesteal, per-hit XP) don't trigger — callers apply those on the returned amount. Mirrors the
// CSSharp reference: a zeroed CTakeDamageInfo with AttackerInfo_t at +0x88, plus a CTakeDamageResult, fed to TakeDamageOld.
// raw = a FLAT, armor-skipping BURN hit (DoT): DFLAG_IGNORE_ARMOR + DMG_BURN.
// flat = a normal bullet that respects armor but is NOT scaled (thorns: the bot eats exactly what was computed).
// Both set Unscaled so the offense hook applies `damage` verbatim; attribution is still kept so a kill credits the source.
internal static class DamageDealer
{
// TRUE around an unscaled invoke (raw burn DoT or flat thorns reflect): the offense hook (OnEntityTakeDamagePre) returns
// immediately when set, so `damage` lands verbatim — no build/handicap scaling, no crit roll. Toggled inside Deal only.
internal static bool Unscaled;
// Byte offset of AttackerInfo_t inside CTakeDamageInfo (the CSSharp Tests.Native layout). A build-volatile fixup point
// — if the struct layout shifts on a CS2 update, this is the one-line change (kept here with the marshalling it serves).
private const int AttackerInfoOffset = 0x88;
// Cached all-zero source for bulk-clearing the two unmanaged buffers (one memcpy each vs N per-byte WriteByte interop
// crossings). Grows once to the largest class size; never written so it stays zero. Game-thread only, Deal not re-entrant.
private static byte[] _zero = [];
// The two unmanaged buffers, REUSED across calls instead of AllocHGlobal/FreeHGlobal per Deal (hot in burn waves: per
// burn-tick-per-bot, per thorns hit, per explode). Safe because Deal is game-thread + NOT re-entrant — the synchronous
// player_death its Invoke can fire only REMOVES burns (ClearBurnsForBot), and explode-on-kill is deferred a frame; no
// path re-enters Deal before it returns. Grow-only to the class size; never freed (process-lifetime); re-zeroed each call.
private static IntPtr _infoBuf, _resBuf;
private static int _infoCap, _resCap;
internal static float Deal(CCSPlayerController target, CCSPlayerController source, float damage, bool raw = false, bool flat = false)
{
if (damage <= 0 || !target.IsValid || !source.IsValid) return 0f;
var targetPawn = target.PlayerPawn.Value;
if (targetPawn is null || targetPawn.Health <= 0) return 0f;
int infoSize = Schema.GetClassSize(EngineNames.CTakeDamageInfo);
int resSize = Schema.GetClassSize(EngineNames.CTakeDamageResult);
if (_zero.Length < infoSize || _zero.Length < resSize) _zero = new byte[Math.Max(infoSize, resSize)];
// Grow the reused buffers if needed (class sizes are fixed per build, so this fires only on the first call), then
// re-zero them — no per-call AllocHGlobal/FreeHGlobal.
if (_infoCap < infoSize) { _infoBuf = _infoBuf == IntPtr.Zero ? Marshal.AllocHGlobal(infoSize) : Marshal.ReAllocHGlobal(_infoBuf, (IntPtr)infoSize); _infoCap = infoSize; }
if (_resCap < resSize) { _resBuf = _resBuf == IntPtr.Zero ? Marshal.AllocHGlobal(resSize) : Marshal.ReAllocHGlobal(_resBuf, (IntPtr)resSize); _resCap = resSize; }
IntPtr infoPtr = _infoBuf, resPtr = _resBuf;
Marshal.Copy(_zero, 0, infoPtr, infoSize); // bulk-zero the reused buffers (memcpy) — restore the clean-slate contract
Marshal.Copy(_zero, 0, resPtr, resSize);
var info = new CTakeDamageInfo(infoPtr);
var ai = new AttackerInfo_t
{
NeedInit = true,
IsPawn = true,
AttackerPawn = source.Pawn.Raw,
AttackerPlayerSlot = source.Slot,
};
Marshal.StructureToPtr(ai, new IntPtr(infoPtr.ToInt64() + AttackerInfoOffset), false);
uint inflictor = source.PawnIsAlive ? source.Pawn.Raw : source.PlayerPawn.Raw;
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Inflictor, inflictor);
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Attacker, source.Pawn.Raw);
info.Damage = damage;
info.BitsDamageType = raw ? DamageTypes_t.DMG_BURN : DamageTypes_t.DMG_BULLET;
if (raw) info.DamageFlags |= TakeDamageFlags_t.DFLAG_IGNORE_ARMOR;
var result = new CTakeDamageResult(resPtr);
Schema.SetSchemaValue(result.Handle, EngineNames.CTakeDamageResult, EngineNames.OriginatingInfo, info.Handle);
result.HealthBefore = targetPawn.Health;
result.HealthLost = (int)damage;
result.DamageDealt = damage;
result.PreModifiedDamage = damage;
bool unscaled = raw || flat;
#pragma warning disable CS0618
if (unscaled) Unscaled = true;
try { VirtualFunctions.CBaseEntity_TakeDamageOldFunc.Invoke(targetPawn, info, result); }
finally { if (unscaled) Unscaled = false; }
#pragma warning restore CS0618
return info.Damage;
}
}
// Attribution payload the engine reads out of CTakeDamageInfo at +0x88 (kill credit / assists). Byte-laid-out to match
// the CSSharp reference (Tests.Native); written via Marshal in Deal.
[StructLayout(LayoutKind.Sequential)]
internal struct AttackerInfo_t
{
public bool NeedInit;
public bool IsPawn;
public bool IsWorld;
public uint AttackerPawn;
public int AttackerPlayerSlot;
public int TeamChecked;
public int Team;
}

View file

@ -0,0 +1,47 @@
using System.Collections.Frozen;
namespace Outnumbered.Engine;
// Every engine string/identifier in one place: schema classes + fields, designer names, entity classnames, item-def
// indices. A CS2/CSSharp rename becomes a one-line fix here instead of a hunt across files (engine churn is when, not if).
internal static class EngineNames
{
// schema classes
public const string CBaseEntity = "CBaseEntity";
public const string CCSPlayerPawn = "CCSPlayerPawn";
public const string CCSPlayerController = "CCSPlayerController";
public const string CBasePlayerWeapon = "CBasePlayerWeapon";
public const string CTakeDamageInfo = "CTakeDamageInfo";
public const string CTakeDamageResult = "CTakeDamageResult";
// schema fields
public const string Health = "m_iHealth";
public const string MaxHealth = "m_iMaxHealth";
public const string ArmorValue = "m_ArmorValue";
public const string MoveType = "m_MoveType";
public const string Clan = "m_szClan";
public const string Inflictor = "m_hInflictor";
public const string Attacker = "m_hAttacker";
public const string OriginatingInfo = "m_pOriginatingInfo";
public const string NextPrimaryAttackTick = "m_nNextPrimaryAttackTick";
// pawn designer-name varies by build — FrozenSet for the per-pellet membership check in the damage hook
public static readonly FrozenSet<string> PlayerPawnDesigners =
new[] { "cs_player_pawn", "player" }.ToFrozenSet(StringComparer.Ordinal);
// entity classnames / designer names
public const string PointWorldText = "point_worldtext";
public const string GameRulesDesigner = "cs_gamerules";
// (the HE projectile is spawned via the native create func in GrenadeSpawner, not by classname)
// common item designer-names given/compared in loadouts (the knife rung, the spawn armor, the default-pool fallback)
public const string WeaponKnife = "weapon_knife";
public const string ItemAssaultSuit = "item_assaultsuit";
public const string WeaponAk47 = "weapon_ak47";
// the two shop "carrier" items every human spawns with: the healthshot (X toggles the menu) + the zeus (shop key 3)
public const string ShopCarrier = "weapon_healthshot";
public const string ShopMelee = "weapon_taser";
// item-def index for the HE grenade (native projectile create)
public const int HeGrenadeItemDef = 44;
}

View file

@ -0,0 +1,93 @@
using System.Runtime.InteropServices;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
using CounterStrikeSharp.API.Modules.Utils;
namespace Outnumbered.Engine;
// A real HE blast at a point, attributed to a player. Spawns via the game's NATIVE projectile-create (the same call used
// when a player throws) because Valve removed the think-arming from the InitializeSpawnFromWorld input, so a
// CreateEntityByName grenade is inert on current CS2. Signature from MatchZy / cs2-executes. Lazy-resolved; if it doesn't
// match this build, falls back to a manual radius blast (no engine VFX). Config-agnostic: damage/radius/fuse are passed in;
// `log` receives one-time path notes. Args: (position, angle, velocity, velocity, IntPtr.Zero, itemDefIndex).
internal static class GrenadeSpawner
{
private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
private static MemoryFunctionWithReturn<nint, nint, nint, nint, nint, int, CHEGrenadeProjectile>? _heCreate;
private static bool _init, _ok, _nativeLogged, _manualLogged;
public static void Explode(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
{
if (attacker is not { IsValid: true } || attacker.PlayerPawn.Value is null) return;
if (!TrySpawnHe(attacker, pos, baseDamage, radius, fuseSeconds, log))
ManualBlast(attacker, pos, baseDamage, radius, log);
}
private static bool TrySpawnHe(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
{
if (!_init)
{
_init = true;
try
{
_heCreate = new(IsLinux
? "55 4C 89 C1 48 89 E5 41 57 49 89 D7"
: "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 48 83 EC 50 48 8B AC 24 80 00 00 00 49 8B F8");
_ok = true;
}
catch (Exception ex)
{
log("explode-on-kill: HE native-create signature not found — manual blast fallback", ex);
_ok = false;
}
}
if (!_ok || _heCreate is null) return false;
try
{
var apawn = attacker.PlayerPawn.Value!;
var spawn = new Vector(pos.X, pos.Y, pos.Z + 12f);
var ang = new QAngle();
var vel = new Vector(0, 0, -10f);
var nade = _heCreate.Invoke(spawn.Handle, ang.Handle, vel.Handle, vel.Handle, IntPtr.Zero, EngineNames.HeGrenadeItemDef);
if (nade is null || !nade.IsValid) return false;
nade.Teleport(spawn, ang, vel);
nade.Globalname = "custom";
nade.TeamNum = apawn.TeamNum; // CT -> only damages bots; survivors safe (ff off)
nade.Thrower.Raw = attacker.PlayerPawn.Raw; // kill attribution + killfeed
nade.OriginalThrower.Raw = attacker.PlayerPawn.Raw;
nade.OwnerEntity.Raw = attacker.PlayerPawn.Raw;
nade.Damage = baseDamage; // scaled UP by the offense hook on detonation
nade.DmgRadius = radius;
nade.DetonateTime = Server.CurrentTime + Math.Max(0f, fuseSeconds); // think is scheduled now -> respected
if (!_nativeLogged) { _nativeLogged = true; log("explode-on-kill: native HE grenade spawned + armed OK", null); }
return true;
}
catch (Exception ex)
{
log("explode-on-kill: native HE invoke failed — manual blast fallback", ex);
_ok = false; // don't keep retrying a bad signature this session
return false;
}
}
// Snapshot the list — a lethal Deal fires player_death synchronously, which mutates bot state mid-loop.
private static void ManualBlast(CCSPlayerController attacker, Vector center, float baseDamage, float radius, Action<string, Exception?> log)
{
double r = Math.Max(1.0, radius);
foreach (var bot in Utilities.GetPlayers().ToList())
{
if (bot is not { IsValid: true, IsBot: true, IsHLTV: false } || !bot.PawnIsAlive) continue;
var bpos = bot.PlayerPawn.Value?.AbsOrigin;
if (bpos is null) continue;
double dx = bpos.X - center.X, dy = bpos.Y - center.Y, dz = bpos.Z - center.Z;
double dist = Math.Sqrt(dx * dx + dy * dy + dz * dz);
if (dist > r) continue;
float dmg = (float)(baseDamage * (1.0 - dist / r)); // linear falloff
if (dmg >= 1f) DamageDealer.Deal(bot, attacker, dmg); // non-raw -> scales + attributed + chains
}
if (!_manualLogged) { _manualLogged = true; log("explode-on-kill: native HE unavailable -> MANUAL radius blast (no engine VFX)", null); }
}
}

View file

@ -0,0 +1,67 @@
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// The weapon-inventory read surface (WeaponServices: MyWeapons / ActiveWeapon) plus the slot-select client commands.
// Centralizes the null-safe "walk / read the player's weapons" idiom shared by Abilities + Shop, so a
// CSSharp rename of WeaponServices/MyWeapons/ActiveWeapon — or a change to slot-select semantics — is one edit here.
// (The bare GiveNamedItem/RemoveItemByDesignerName loadout calls stay at their call sites: single-call, nothing to share.)
internal static class Inventory
{
// Slot-select client commands (engine INPUT, not schema). slot3 (the melee slot) is the neutral "rest" anchor; the zeus
// is deployed by name because the engine auto-deploys a granted grenade over the knife but not over a real weapon.
public const string SlotPrimary = "slot1";
public const string SlotSecondary = "slot2";
public const string SlotMelee = "slot3";
public const string UseZeus = "use weapon_taser";
// The player's currently-deployed weapon, or null (null-safe through pawn -> WeaponServices -> handle, IsValid-checked).
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerPawn? pawn)
{
var w = pawn?.WeaponServices?.ActiveWeapon.Value;
return w is { IsValid: true } ? w : null;
}
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerController p) => ActiveWeapon(p.PlayerPawn.Value);
// The active weapon's designer name, or "" when none — the common "what is this player holding?" probe.
public static string ActiveWeaponName(CCSPlayerPawn? pawn) => ActiveWeapon(pawn)?.DesignerName ?? "";
// TRUE if the designer name is a knife (any skin — weapon_knife, weapon_knife_t, weapon_bayonet, …) or the zeus. Lets the
// thorns abuse-guard tell "real melee in hand" from a gun (mirrors Shop's knife-neutral test, plus the zeus).
public static bool IsMeleeOrZeus(string designerName) =>
designerName == EngineNames.ShopMelee || designerName.Contains("knife") || designerName.Contains("bayonet");
// Every valid weapon the player holds (null-safe walk of WeaponServices.MyWeapons). Empty if no pawn / no services.
// Lazy (iterator) — fine for the cold classify-all walk; the hot exact-match check uses Holds (no enumerator alloc).
public static IEnumerable<CBasePlayerWeapon> Weapons(CCSPlayerController p)
{
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
if (weapons is null) yield break;
foreach (var h in weapons)
{
var w = h.Value;
if (w is { IsValid: true }) yield return w;
}
}
// The weapon's full magazine size from its VData, or -1 if unavailable (the No-Reload / infinite-clip top-up reads it).
public static int MaxClip(CBasePlayerWeapon w) => w.As<CCSWeaponBase>().VData?.MaxClip1 ?? -1;
// Whether the pawn's weapon inventory is readable at all (pawn + WeaponServices + MyWeapons present) — distinguishes
// "no inventory" from "an empty inventory" for callers that branch on it (e.g. PreferredSlotCmd's no-services fallback).
public static bool HasWeaponServices(CCSPlayerController p) => p.PlayerPawn.Value?.WeaponServices?.MyWeapons is not null;
// True if the player holds a weapon with this designer name. Direct walk (no iterator alloc) for the per-reconcile path.
public static bool Holds(CCSPlayerController p, string designerName)
{
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
if (weapons is null) return false;
foreach (var h in weapons)
{
var w = h.Value;
if (w is { IsValid: true } && w.DesignerName == designerName) return true;
}
return false;
}
}

View file

@ -0,0 +1,54 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// The one place pawn HP/armor are written: every write is paired with the required SetStateChanged so the schema-field
// names and the change-notification contract live together. Callers resolve the pawn; these never read config or pd.
internal static class PawnWriter
{
// MaxHealth MUST be set before Health, else Health clamps to 100. Sets both the pawn and the controller mirror.
public static void SetMaxHealth(CCSPlayerController p, CCSPlayerPawn pawn, int max)
{
pawn.MaxHealth = max;
p.MaxHealth = max;
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MaxHealth);
}
public static void SetHealth(CCSPlayerPawn pawn, int hp)
{
pawn.Health = hp;
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.Health);
}
public static void SetArmor(CCSPlayerPawn pawn, int armor)
{
pawn.ArmorValue = armor;
Utilities.SetStateChanged(pawn, EngineNames.CCSPlayerPawn, EngineNames.ArmorValue);
}
// The pawn movement-state write + its change-notification (the shop freeze/unfreeze). Caller owns any velocity reset.
public static void SetMoveType(CCSPlayerPawn pawn, MoveType_t moveType)
{
pawn.MoveType = moveType;
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MoveType);
}
// A capped ADD never REDUCES: if the pawn is already at/over the cap (e.g. an !og_reload lowered Max-HP below the
// live value) we no-op rather than snapping it down — so regen/lifesteal preserve an over-cap surplus.
public static void AddHealthCapped(CCSPlayerPawn pawn, int amount, int cap)
{
if (amount <= 0 || pawn.Health <= 0) return;
int next = Math.Min(cap, pawn.Health + amount);
if (next <= pawn.Health) return;
SetHealth(pawn, next);
}
public static void AddArmorCapped(CCSPlayerPawn pawn, int amount, int cap)
{
if (amount <= 0 || pawn.Health <= 0) return;
int next = Math.Min(cap, pawn.ArmorValue + amount);
if (next <= pawn.ArmorValue) return;
SetArmor(pawn, next);
}
}

View file

@ -0,0 +1,18 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// Game-rules access. mp_roundtime caps at 60 min so the round timer would expire and hang; the map ends only on the kill
// goal, so we override the round duration directly on the rules each round.
internal static class Rules
{
public static CCSGameRules? Current =>
Utilities.FindAllEntitiesByDesignerName<CCSGameRulesProxy>(EngineNames.GameRulesDesigner).FirstOrDefault()?.GameRules;
public static void MakeRoundEndless()
{
var gr = Current;
gr?.RoundTime = 999999; // ~277h
}
}

View file

@ -0,0 +1,30 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.UserMessages;
namespace Outnumbered.Engine;
// CS2 screen fade via the Fade user message. The exact message format is UNVERIFIED (hard to debug blind), so the send is
// wrapped: a wrong field name no-ops via onError instead of crashing. The blend/decision logic stays plugin-side.
internal static class ScreenFade
{
public static int Pack(int r, int g, int b, int a) => r | (g << 8) | (b << 16) | (a << 24);
public static void Send(CCSPlayerController p, int r, int g, int b, int a, bool clear, Action<Exception> onError)
{
try
{
// CUserMessageFade [106]: duration, hold_time, flags, color — all INT; color is one packed value (not nested clr).
var msg = UserMessage.FromPartialName("Fade");
msg.SetInt("duration", (int)(0.3f * 512)); // Q7.9 fixed point (seconds * 512); int, not float
msg.SetInt("hold_time", 0); // FFADE_STAYOUT holds it until the next fade
// hold a tint: FFADE_OUT|FFADE_STAYOUT. clear it: FFADE_IN|FFADE_PURGE (fades the held colour out + drops stayout).
msg.SetInt("flags", clear ? (0x1 | 0x10) : (0x2 | 0x8));
msg.SetInt("color", Pack(r, g, b, a)); // packed color32, R in the low byte
msg.Send(p);
}
catch (Exception ex)
{
onError(ex);
}
}
}

View file

@ -0,0 +1,106 @@
using System.Net.Sockets;
using System.Text;
namespace Outnumbered.Engine;
// Minimal request/response server on a Unix domain socket: one verb line in, one payload out, connection closes.
// Runs entirely on background threads — the handler must NEVER touch game state (Api.cs serves pre-serialized bytes,
// and the one async verb is DB-only). Lifecycle: the ctor binds (delete-before-bind clears a crash leftover; bind
// failures throw to the caller, who degrades gracefully), Dispose cancels the accept loop, closes the listener and
// unlinks the socket file — a hot-reload (Unload then Load in-process) can then re-bind the same path.
internal sealed class UdsServer : IDisposable
{
private const int MaxRequestBytes = 256; // a verb line; anything bigger is not our client
private static readonly TimeSpan IoDeadline = TimeSpan.FromSeconds(2);
private readonly Socket _listener;
private readonly string _path;
private readonly Func<string, Task<byte[]>> _handle;
private readonly Action<Exception> _onError;
private readonly CancellationTokenSource _cts = new();
internal UdsServer(string path, Func<string, Task<byte[]>> handle, Action<Exception> onError)
{
_path = path;
_handle = handle;
_onError = onError;
File.Delete(path); // a crash leaves the old socket file behind, and bind fails on an existing path
_listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
_listener.Bind(new UnixDomainSocketEndPoint(path));
_listener.Listen(16);
// 0660: owner (the game user) + group (shared with the site user) — never world-accessible. Unix-only API,
// guarded so a Windows dev build compiles clean (there the socket just keeps default ACLs).
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite);
_ = Task.Run(AcceptLoop);
}
private async Task AcceptLoop()
{
while (!_cts.IsCancellationRequested)
{
Socket conn;
try { conn = await _listener.AcceptAsync(_cts.Token); }
catch (OperationCanceledException) { return; } // Dispose
catch (ObjectDisposedException) { return; } // Dispose raced the accept
catch (Exception ex)
{
// A persistent accept fault (fd exhaustion) would otherwise re-throw instantly forever — one retry per
// second turns a pegged core on the game host into a single syscall/s until the fault clears.
_onError(ex);
try { await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token); }
catch (OperationCanceledException) { return; }
continue;
}
_ = Task.Run(() => Serve(conn));
}
}
// One connection = one verb line -> one payload. Deadlined so a stuck client can never pin resources; errors are
// reported (throttled by the caller) and the connection just drops — a game server must never care.
private async Task Serve(Socket conn)
{
using (conn)
{
try
{
// Inside the try: _cts.Token throws ObjectDisposedException when Dispose() wins the race against a
// queued Serve task (hot-reload while a client connects) — treated like the AcceptLoop's same race.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
deadline.CancelAfter(IoDeadline);
var buf = new byte[MaxRequestBytes];
int len = 0;
while (len < MaxRequestBytes)
{
int n = await conn.ReceiveAsync(buf.AsMemory(len, MaxRequestBytes - len), SocketFlags.None, deadline.Token);
if (n == 0) break; // peer closed without a newline
len += n;
if (Array.IndexOf(buf, (byte)'\n', 0, len) >= 0) break;
}
int nl = Array.IndexOf(buf, (byte)'\n', 0, len);
if (nl < 0) return; // no complete verb line within the cap — not our client, drop silently
byte[] payload = await _handle(Encoding.ASCII.GetString(buf, 0, nl).Trim());
int sent = 0;
while (sent < payload.Length)
{
int n = await conn.SendAsync(payload.AsMemory(sent), SocketFlags.None, deadline.Token);
if (n <= 0) break;
sent += n;
}
}
catch (OperationCanceledException) { /* deadline hit or server shutting down — drop the connection */ }
catch (ObjectDisposedException) { /* Dispose raced a queued connection — drop it */ }
catch (Exception ex) { _onError(ex); }
}
}
public void Dispose()
{
_cts.Cancel();
try { _listener.Dispose(); } catch (Exception) { /* already closed */ }
try { File.Delete(_path); } catch (Exception) { /* unlink is best-effort; delete-before-bind covers leftovers */ }
_cts.Dispose();
}
}

View file

@ -0,0 +1,81 @@
using System.Drawing;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
namespace Outnumbered.Engine;
// The one place a point_worldtext entity is created + configured (HUD panel, the 2 shop panels, the draft cards all
// share it). CreateEntityByName returns a NON-null wrapper even on failure (e.g. the entity limit) with a Zero Handle,
// so the guard checks the raw handle (no memory deref) before touching schema members. Callers supply the per-use
// font/justify/colour/border and own the last-text caching; the volatile "SetMessage" input + the Teleport placement
// go through SetText/Place so every engine touchpoint for this entity lives here.
internal static class WorldText
{
public static CPointWorldText? Create(float fontSize, float worldUnitsPerPx, string fontName, Color color,
bool drawBackground, float border, PointWorldTextJustifyHorizontal_t justify)
{
var e = Utilities.CreateEntityByName<CPointWorldText>(EngineNames.PointWorldText);
if (e is null || e.Handle == nint.Zero) return null;
e.MessageText = " ";
e.Enabled = true;
e.Fullbright = true;
e.FontSize = fontSize;
e.WorldUnitsPerPx = worldUnitsPerPx;
if (!string.IsNullOrEmpty(fontName)) e.FontName = fontName;
e.Color = color;
e.JustifyHorizontal = justify;
e.JustifyVertical = PointWorldTextJustifyVertical_t.POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER;
e.ReorientMode = PointWorldTextReorientMode_t.POINT_WORLD_TEXT_REORIENT_NONE;
e.DrawBackground = drawBackground;
e.BackgroundBorderHeight = border;
e.BackgroundBorderWidth = border;
e.DispatchSpawn();
return e;
}
// Push new text via the point_worldtext "SetMessage" input (the volatile input-name literal lives here, not at the 4
// call sites). Callers keep their own last-text cache and only call this on a change.
public static void SetText(CPointWorldText ent, string text) => ent.AcceptInput("SetMessage", ent, ent, text);
// Position + orient a panel. point_worldtext is moved via Teleport (no velocity); callers compute the eye-relative frame.
public static void Place(CPointWorldText ent, Vector pos, QAngle ang) => ent.Teleport(pos, ang, null);
// Tear a panel down: Remove the entity if it's still live, then null the caller's reference. Symmetric with Create —
// every point_worldtext create + destroy goes through this file.
public static void Destroy(ref CPointWorldText? ent)
{
if (ent is { IsValid: true }) ent.Remove();
ent = null;
}
public const float EyeZFallback = 64f; // ViewOffset.Z fallback when the pawn doesn't report one
// The eye-relative frame for placing a panel in front of a pawn's view: eye position + forward/right/up basis + the
// panel orientation (yaw+270 / 90-pitch = worldtext faces the player, upright across pitch/yaw). false if AbsOrigin is
// null. Callers add only their own per-panel offsets. Shared by the HUD + the shop/draft panels.
public static bool TryEyeFrame(CCSPlayerPawn pawn, out Vector eye, out Vector fwd, out Vector right, out Vector up, out QAngle ang)
{
eye = null!; fwd = null!; right = null!; up = null!; ang = null!;
var origin = pawn.AbsOrigin;
if (origin is null) return false;
var ea = pawn.EyeAngles;
float eyeZ = pawn.ViewOffset?.Z ?? EyeZFallback;
(fwd, right, up) = AngleVectors(ea);
eye = new Vector(origin.X, origin.Y, origin.Z + eyeZ);
ang = new QAngle(0f, ea.Y + 270f, 90f - ea.X);
return true;
}
// Source-engine AngleVectors with roll assumed 0 (HUD/shop panels never roll).
private static (Vector forward, Vector right, Vector up) AngleVectors(QAngle a)
{
const double d2r = Math.PI / 180.0;
double p = a.X * d2r, y = a.Y * d2r;
double sp = Math.Sin(p), cp = Math.Cos(p), sy = Math.Sin(y), cy = Math.Cos(y);
return (
new Vector((float)(cp * cy), (float)(cp * sy), (float)(-sp)),
new Vector((float)sy, (float)(-cy), 0f),
new Vector((float)(sp * cy), (float)(sp * sy), (float)cp));
}
}