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; }