diff --git a/README.md b/README.md
index 77e5211..0615622 100644
--- a/README.md
+++ b/README.md
@@ -57,11 +57,10 @@ The C# wrapper is in:
examples/CounterStrikeSharp/SendProxyNative.cs
```
-It loads the native addon with `NativeLibrary.Load` and exposes byte-oriented and typed helpers. In a CSS plugin, resolve the native library from `Server.GameDirectory`:
+It loads the native addon with `NativeLibrary.Load` and exposes byte-oriented and typed helpers. In a CSS plugin, construct it from `Server.GameDirectory`:
```csharp
-string libraryPath = Path.Combine(Server.GameDirectory, "addons", "sendproxy", "bin", "linuxsteamrt64", "sendproxy.so");
-var sendProxy = new SendProxyNative(libraryPath);
+var sendProxy = SendProxyNative.FromGameDirectory(Server.GameDirectory);
int ruleId = sendProxy.SetInt32(
recipientSlot: viewer.Slot,
@@ -75,9 +74,17 @@ sendProxy.RemoveOverride(ruleId);
Use `SetBytes` for custom/fixed-layout fields and typed helpers such as `SetInt32`, `SetUInt32`, `SetFloat`, `SetBool`, and `SetColor` for common values.
-## Example CSS Plugin
+## Example CSS Plugins
-Example source:
+Health spoof smoke test:
+
+```text
+examples/CounterStrikeSharp/SendProxyHealth23/
+```
+
+It spoofs every player pawn's `CBaseEntity::m_iHealth` to `23` for all recipients without changing real health.
+
+Weapon visibility experiment:
```text
examples/CounterStrikeSharp/SendProxyWeaponVisibility/
@@ -90,6 +97,20 @@ It demonstrates:
- Coloring dropped AK-47s red for Terrorist viewers.
- Coloring dropped M4A1-S blue for CT viewers.
+Concealed-carry handle spoof:
+
+```text
+examples/CounterStrikeSharp/SendProxyConcealedCarry/
+```
+
+It hides owned primary weapons, secondary weapons, and grenades from every recipient except the owning player by spoofing weapon-related handles to `0xffffffff`:
+
+- `CBasePlayerPawn::m_pWeaponServices.m_hActiveWeapon`
+- `CBasePlayerPawn::m_pWeaponServices.m_hLastWeapon`
+- `CBaseEntity::m_hOwnerEntity` on carried weapon entities
+
+It intentionally does not set `EF_NODRAW` and does not remove or drop the real weapons.
+
This sample periodically reconciles desired rules and diffs them against active rule IDs. For production, prefer event-driven updates on player connect/disconnect/team changes/spawn/death and weapon pickup/drop/create/delete.
## Building the Metamod Addon
@@ -159,6 +180,8 @@ Install the built CSS plugin under:
game/csgo/addons/counterstrikesharp/plugins/SendProxyWeaponVisibility/
```
+Use the matching example directory name when building/installing another sample, for example `SendProxyHealth23` or `SendProxyConcealedCarry`.
+
Make sure `SendProxyNative.cs` is included in your CSS project or copied into your plugin source.
The example plugin expects the Metamod addon at:
diff --git a/examples/CounterStrikeSharp/SendProxyConcealedCarry/SendProxyConcealedCarry.csproj b/examples/CounterStrikeSharp/SendProxyConcealedCarry/SendProxyConcealedCarry.csproj
new file mode 100644
index 0000000..006a797
--- /dev/null
+++ b/examples/CounterStrikeSharp/SendProxyConcealedCarry/SendProxyConcealedCarry.csproj
@@ -0,0 +1,13 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/examples/CounterStrikeSharp/SendProxyConcealedCarry/SendProxyConcealedCarryPlugin.cs b/examples/CounterStrikeSharp/SendProxyConcealedCarry/SendProxyConcealedCarryPlugin.cs
new file mode 100644
index 0000000..bc4e18a
--- /dev/null
+++ b/examples/CounterStrikeSharp/SendProxyConcealedCarry/SendProxyConcealedCarryPlugin.cs
@@ -0,0 +1,248 @@
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Modules.Timers;
+using SendProxyInterop;
+using Timer = CounterStrikeSharp.API.Modules.Timers.Timer;
+
+namespace SendProxyConcealedCarry;
+
+public sealed class SendProxyConcealedCarryPlugin : BasePlugin
+{
+ private const string WeaponListField = "m_pWeaponServices.m_hMyWeapons";
+ private const string ActiveWeaponField = "m_pWeaponServices.m_hActiveWeapon";
+
+ private readonly Dictionary _activeRules = new();
+ private SendProxyNative? _sendProxy;
+ private Timer? _syncTimer;
+
+ public override string ModuleName => "SendProxy Concealed Carry";
+ public override string ModuleVersion => "0.1.0";
+ public override string ModuleAuthor => "OpenAI Codex";
+
+ public override void Load(bool hotReload)
+ {
+ _sendProxy = SendProxyNative.FromGameDirectory(Server.GameDirectory);
+ _syncTimer = AddTimer(0.25f, SyncRules, TimerFlags.REPEAT);
+ RegisterListener(_ => Server.NextFrame(SyncRules));
+ RegisterListener(_ => Server.NextFrame(SyncRules));
+ Server.NextFrame(SyncRules);
+ }
+
+ public override void Unload(bool hotReload)
+ {
+ _syncTimer?.Kill();
+ ClearRules();
+ _sendProxy?.Dispose();
+ _sendProxy = null;
+ }
+
+ private void SyncRules()
+ {
+ if (_sendProxy == null)
+ return;
+
+ Dictionary desired;
+ try
+ {
+ var viewers = Utilities.GetPlayers()
+ .Where(player => player.IsValid && !player.IsBot && !player.IsHLTV)
+ .ToArray();
+
+ var targets = Utilities.GetPlayers()
+ .Where(player => player.IsValid && !player.IsHLTV)
+ .ToArray();
+
+ var targetStatesByPawnIndex = targets
+ .Select(player => new
+ {
+ player.Slot,
+ Pawn = player.PlayerPawn.Value,
+ })
+ .Where(entry => entry.Pawn != null && entry.Pawn.IsValid)
+ .GroupBy(entry => (int)entry.Pawn!.Index)
+ .ToDictionary(
+ group => group.Key,
+ group =>
+ {
+ var first = group.First();
+ var activeWeapon = first.Pawn!.WeaponServices?.ActiveWeapon.Value;
+ int activeWeaponIndex = activeWeapon != null && activeWeapon.IsValid ? (int)activeWeapon.Index : -1;
+ return new TargetState(first.Slot, activeWeaponIndex);
+ });
+
+ desired = BuildDesiredRules(viewers, targetStatesByPawnIndex);
+ }
+ catch (NativeException)
+ {
+ return;
+ }
+
+ ReconcileRules(desired);
+ }
+
+ private Dictionary BuildDesiredRules(
+ IReadOnlyCollection viewers,
+ IReadOnlyDictionary targetStatesByPawnIndex)
+ {
+ var desired = new Dictionary();
+ foreach (var (pawnIndex, target) in targetStatesByPawnIndex)
+ {
+ foreach (var viewer in viewers)
+ {
+ if (viewer.Slot == target.OwnerSlot)
+ continue;
+
+ desired[new RuleKey(
+ ToEngineSlot(viewer),
+ pawnIndex,
+ "CBasePlayerPawn",
+ WeaponListField)] = Array.Empty();
+ }
+ }
+
+ foreach (var weapon in EnumerateConcealableCarriedWeapons(targetStatesByPawnIndex))
+ {
+ if (weapon.EntityIndex == weapon.ActiveWeaponIndex)
+ continue;
+
+ foreach (var viewer in viewers)
+ {
+ if (viewer.Slot == weapon.OwnerSlot)
+ continue;
+
+ desired[new RuleKey(
+ ToEngineSlot(viewer),
+ weapon.EntityIndex,
+ "CBaseEntity",
+ "m_hOwnerEntity")] = UInt32Bytes(0xffffffff);
+ desired[new RuleKey(
+ ToEngineSlot(viewer),
+ weapon.EntityIndex,
+ "CBaseEntity",
+ "m_CBodyComponent.m_pSceneNode.m_hParent.m_hOwner")] = UInt32Bytes(0xffffffff);
+ desired[new RuleKey(
+ ToEngineSlot(viewer),
+ weapon.EntityIndex,
+ "CBaseEntity",
+ "m_CBodyComponent.m_pSceneNode.m_hParent.m_name")] = UInt32Bytes(0);
+ desired[new RuleKey(
+ ToEngineSlot(viewer),
+ weapon.EntityIndex,
+ "CBaseEntity",
+ "m_CBodyComponent.m_pSceneNode.m_nParentAttachmentOrBone")] = Int16Bytes(-1);
+ desired[new RuleKey(
+ ToEngineSlot(viewer),
+ weapon.EntityIndex,
+ "CBaseEntity",
+ "m_CBodyComponent.m_pSceneNode.m_hierarchyAttachName")] = UInt32Bytes(0);
+ desired[new RuleKey(
+ ToEngineSlot(viewer),
+ weapon.EntityIndex,
+ "CBaseEntity",
+ "m_CBodyComponent.m_pSceneNode.m_bForceParentToBeNetworked")] = new byte[] { 0 };
+ }
+ }
+
+ return desired;
+ }
+
+ private void ReconcileRules(Dictionary desired)
+ {
+ if (_sendProxy == null)
+ return;
+
+ foreach (var key in _activeRules.Keys.Except(desired.Keys).ToArray())
+ {
+ _sendProxy.RemoveOverride(_activeRules[key]);
+ _activeRules.Remove(key);
+ }
+
+ foreach (var (key, value) in desired)
+ {
+ if (_activeRules.ContainsKey(key))
+ continue;
+
+ int ruleId = value.Length == 0
+ ? _sendProxy.SetVectorFirstFromField(
+ key.RecipientSlot,
+ key.EntityIndex,
+ key.ClassName,
+ key.FieldPath,
+ ActiveWeaponField)
+ : _sendProxy.SetBytes(
+ key.RecipientSlot,
+ key.EntityIndex,
+ key.ClassName,
+ key.FieldPath,
+ value);
+
+ if (ruleId != 0)
+ _activeRules[key] = ruleId;
+ }
+ }
+
+ private IEnumerable EnumerateConcealableCarriedWeapons(IReadOnlyDictionary targetStatesByPawnIndex)
+ {
+ foreach (var entity in Utilities.GetAllEntities())
+ {
+ if (!entity.IsValid || !entity.DesignerName.StartsWith("weapon_", StringComparison.Ordinal))
+ continue;
+
+ CBasePlayerWeapon weapon = entity.As();
+ if (!weapon.IsValid)
+ continue;
+
+ var owner = weapon.OwnerEntity.Value;
+ if (owner == null || !owner.IsValid)
+ continue;
+
+ if (!targetStatesByPawnIndex.TryGetValue((int)owner.Index, out var target))
+ continue;
+
+ yield return new ConcealableWeapon(
+ (int)weapon.Index,
+ target.OwnerSlot,
+ target.ActiveWeaponIndex);
+ }
+ }
+
+ private static byte[] UInt16Bytes(ushort value)
+ {
+ byte[] bytes = new byte[2];
+ BitConverter.TryWriteBytes(bytes, value);
+ return bytes;
+ }
+
+ private static byte[] Int16Bytes(short value)
+ {
+ byte[] bytes = new byte[2];
+ BitConverter.TryWriteBytes(bytes, value);
+ return bytes;
+ }
+
+ private static byte[] UInt32Bytes(uint value)
+ {
+ byte[] bytes = new byte[4];
+ BitConverter.TryWriteBytes(bytes, value);
+ return bytes;
+ }
+
+ private static int ToEngineSlot(CCSPlayerController player)
+ {
+ return Math.Max(0, player.Slot - 1);
+ }
+
+ private void ClearRules()
+ {
+ if (_sendProxy == null)
+ return;
+
+ foreach (int ruleId in _activeRules.Values)
+ _sendProxy.RemoveOverride(ruleId);
+ _activeRules.Clear();
+ }
+
+ private readonly record struct TargetState(int OwnerSlot, int ActiveWeaponIndex);
+ private readonly record struct ConcealableWeapon(int EntityIndex, int OwnerSlot, int ActiveWeaponIndex);
+ private readonly record struct RuleKey(int RecipientSlot, int EntityIndex, string ClassName, string FieldPath);
+}
diff --git a/examples/CounterStrikeSharp/SendProxyHealth23/SendProxyHealth23.csproj b/examples/CounterStrikeSharp/SendProxyHealth23/SendProxyHealth23.csproj
new file mode 100644
index 0000000..006a797
--- /dev/null
+++ b/examples/CounterStrikeSharp/SendProxyHealth23/SendProxyHealth23.csproj
@@ -0,0 +1,13 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/examples/CounterStrikeSharp/SendProxyHealth23/SendProxyHealth23Plugin.cs b/examples/CounterStrikeSharp/SendProxyHealth23/SendProxyHealth23Plugin.cs
new file mode 100644
index 0000000..d2161f4
--- /dev/null
+++ b/examples/CounterStrikeSharp/SendProxyHealth23/SendProxyHealth23Plugin.cs
@@ -0,0 +1,91 @@
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Modules.Timers;
+using SendProxyInterop;
+using Timer = CounterStrikeSharp.API.Modules.Timers.Timer;
+
+namespace SendProxyHealth23;
+
+public sealed class SendProxyHealth23Plugin : BasePlugin
+{
+ private const int RecipientAll = -1;
+ private const int SpoofedHealth = 23;
+
+ private readonly Dictionary _rulesByPawnIndex = new();
+ private SendProxyNative? _sendProxy;
+ private Timer? _syncTimer;
+
+ public override string ModuleName => "SendProxy Health 23";
+ public override string ModuleVersion => "0.1.0";
+ public override string ModuleAuthor => "OpenAI Codex";
+
+ public override void Load(bool hotReload)
+ {
+ _sendProxy = SendProxyNative.FromGameDirectory(Server.GameDirectory);
+ _syncTimer = AddTimer(0.25f, SyncRules, TimerFlags.REPEAT);
+ Server.NextFrame(SyncRules);
+ }
+
+ public override void Unload(bool hotReload)
+ {
+ _syncTimer?.Kill();
+ ClearRules();
+ _sendProxy?.Dispose();
+ _sendProxy = null;
+ }
+
+ private void SyncRules()
+ {
+ if (_sendProxy == null)
+ return;
+
+ int[] desiredPawnIndexes;
+ try
+ {
+ desiredPawnIndexes = Utilities.GetPlayers()
+ .Select(player => player.PlayerPawn.Value)
+ .Where(pawn => pawn != null && pawn.IsValid)
+ .Select(pawn => (int)pawn!.Index)
+ .Distinct()
+ .ToArray();
+ }
+ catch (NativeException)
+ {
+ return;
+ }
+
+ var desired = desiredPawnIndexes.ToHashSet();
+
+ foreach (int pawnIndex in _rulesByPawnIndex.Keys.Except(desired).ToArray())
+ {
+ _sendProxy.RemoveOverride(_rulesByPawnIndex[pawnIndex]);
+ _rulesByPawnIndex.Remove(pawnIndex);
+ }
+
+ foreach (int pawnIndex in desiredPawnIndexes)
+ {
+ if (_rulesByPawnIndex.ContainsKey(pawnIndex))
+ continue;
+
+ int ruleId = _sendProxy.SetInt32(
+ RecipientAll,
+ pawnIndex,
+ "CBaseEntity",
+ "m_iHealth",
+ SpoofedHealth);
+
+ if (ruleId != 0)
+ _rulesByPawnIndex[pawnIndex] = ruleId;
+ }
+ }
+
+ private void ClearRules()
+ {
+ if (_sendProxy == null)
+ return;
+
+ foreach (int ruleId in _rulesByPawnIndex.Values)
+ _sendProxy.RemoveOverride(ruleId);
+ _rulesByPawnIndex.Clear();
+ }
+}
diff --git a/examples/CounterStrikeSharp/SendProxyNative.cs b/examples/CounterStrikeSharp/SendProxyNative.cs
index 2c85808..2627bc6 100644
--- a/examples/CounterStrikeSharp/SendProxyNative.cs
+++ b/examples/CounterStrikeSharp/SendProxyNative.cs
@@ -6,19 +6,40 @@ namespace SendProxyInterop;
public sealed class SendProxyNative : IDisposable
{
+ private const string RelativeLibraryPath = "addons/sendproxy/bin/linuxsteamrt64/sendproxy.so";
+
private readonly IntPtr _library;
private readonly SetOverrideDelegate _setOverride;
+ private readonly SetVectorFirstFromFieldDelegate _setVectorFirstFromField;
private readonly RemoveOverrideDelegate _removeOverride;
private readonly ClearOverridesDelegate _clearOverrides;
private readonly MarkDirtyDelegate _markDirty;
+ private readonly DumpSchemaDelegate _dumpSchema;
+ private readonly DumpEnumDelegate _dumpEnum;
public SendProxyNative(string libraryPath)
{
_library = NativeLibrary.Load(libraryPath);
_setOverride = GetExport("SendProxy_SetOverride");
+ _setVectorFirstFromField = GetExport("SendProxy_SetVectorFirstFromField");
_removeOverride = GetExport("SendProxy_RemoveOverride");
_clearOverrides = GetExport("SendProxy_ClearOverrides");
_markDirty = GetExport("SendProxy_MarkDirty");
+ _dumpSchema = GetExport("SendProxy_DumpSchema");
+ _dumpEnum = GetExport("SendProxy_DumpEnum");
+ }
+
+ public static SendProxyNative FromGameDirectory(string gameDirectory)
+ {
+ string csgoDirectory = Path.GetFileName(gameDirectory).Equals("csgo", StringComparison.OrdinalIgnoreCase)
+ ? gameDirectory
+ : Path.Combine(gameDirectory, "csgo");
+
+ string libraryPath = Path.Combine(csgoDirectory, RelativeLibraryPath);
+ if (!File.Exists(libraryPath))
+ throw new FileNotFoundException("SendProxy Metamod addon was not found. Install it under game/csgo/addons/sendproxy.", libraryPath);
+
+ return new SendProxyNative(libraryPath);
}
public int SetBytes(int recipientSlot, int entityIndex, string className, string fieldPath, ReadOnlySpan value)
@@ -61,6 +82,11 @@ public sealed class SendProxyNative : IDisposable
return SetBytes(recipientSlot, entityIndex, className, fieldPath, bytes);
}
+ public int SetVectorFirstFromField(int recipientSlot, int entityIndex, string className, string vectorFieldPath, string sourceFieldPath)
+ {
+ return _setVectorFirstFromField(recipientSlot, entityIndex, className, vectorFieldPath, sourceFieldPath);
+ }
+
public bool RemoveOverride(int ruleId)
{
return _removeOverride(ruleId);
@@ -76,6 +102,16 @@ public sealed class SendProxyNative : IDisposable
return _markDirty(entityIndex, className, fieldPath);
}
+ public void DumpSchema(string className, string contains)
+ {
+ _dumpSchema(className, contains);
+ }
+
+ public void DumpEnum(string enumName)
+ {
+ _dumpEnum(enumName);
+ }
+
public void Dispose()
{
NativeLibrary.Free(_library);
@@ -89,6 +125,9 @@ public sealed class SendProxyNative : IDisposable
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
private delegate int SetOverrideDelegate(int recipientSlot, int entityIndex, string className, string fieldPath, byte[] value, int valueSize);
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
+ private delegate int SetVectorFirstFromFieldDelegate(int recipientSlot, int entityIndex, string className, string vectorFieldPath, string sourceFieldPath);
+
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.I1)]
private delegate bool RemoveOverrideDelegate(int ruleId);
@@ -99,4 +138,10 @@ public sealed class SendProxyNative : IDisposable
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
[return: MarshalAs(UnmanagedType.I1)]
private delegate bool MarkDirtyDelegate(int entityIndex, string className, string fieldPath);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
+ private delegate void DumpSchemaDelegate(string className, string contains);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
+ private delegate void DumpEnumDelegate(string enumName);
}
diff --git a/examples/CounterStrikeSharp/SendProxyWeaponVisibility/SendProxyWeaponVisibilityPlugin.cs b/examples/CounterStrikeSharp/SendProxyWeaponVisibility/SendProxyWeaponVisibilityPlugin.cs
index a1996ad..8f2a687 100644
--- a/examples/CounterStrikeSharp/SendProxyWeaponVisibility/SendProxyWeaponVisibilityPlugin.cs
+++ b/examples/CounterStrikeSharp/SendProxyWeaponVisibility/SendProxyWeaponVisibilityPlugin.cs
@@ -24,11 +24,11 @@ public sealed class SendProxyWeaponVisibilityPlugin : BasePlugin
public override void Load(bool hotReload)
{
- _sendProxy = new SendProxyNative(GetSendProxyLibraryPath());
+ _sendProxy = SendProxyNative.FromGameDirectory(Server.GameDirectory);
_syncTimer = AddTimer(0.25f, SyncRules, TimerFlags.REPEAT);
RegisterListener(_ => Server.NextFrame(SyncRules));
RegisterListener(_ => Server.NextFrame(SyncRules));
- SyncRules();
+ Server.NextFrame(SyncRules);
}
public override void Unload(bool hotReload)
@@ -45,9 +45,17 @@ public sealed class SendProxyWeaponVisibilityPlugin : BasePlugin
return;
var desired = new Dictionary();
- var viewers = Utilities.GetPlayers()
- .Where(player => player.IsValid && !player.IsBot && !player.IsHLTV)
- .ToArray();
+ CCSPlayerController[] viewers;
+ try
+ {
+ viewers = Utilities.GetPlayers()
+ .Where(player => player.IsValid && !player.IsBot && !player.IsHLTV)
+ .ToArray();
+ }
+ catch (NativeException)
+ {
+ return;
+ }
AddHealthRules(desired, viewers);
@@ -211,14 +219,6 @@ public sealed class SendProxyWeaponVisibilityPlugin : BasePlugin
_activeRules.Clear();
}
- private string GetSendProxyLibraryPath()
- {
- string libraryPath = Path.Combine(Server.GameDirectory, "addons", "sendproxy", "bin", "linuxsteamrt64", "sendproxy.so");
- if (!File.Exists(libraryPath))
- throw new FileNotFoundException("SendProxy Metamod addon was not found. Install it under game/csgo/addons/sendproxy.", libraryPath);
- return libraryPath;
- }
-
private readonly record struct RuleKey(int RecipientSlot, int EntityIndex, string ClassName, string FieldPath);
private readonly record struct RuleValue(byte[] Bytes)
diff --git a/src/detour.cpp b/src/detour.cpp
index cfac8ca..7e4f2d0 100644
--- a/src/detour.cpp
+++ b/src/detour.cpp
@@ -13,7 +13,7 @@ extern bool g_enabled;
extern bool g_armed;
using SendSnapshotFn = void (*)(void*, void*);
-using PackEntitiesFn = void (*)(void*, void*, int, void*, void*, void*, void*, void*, void*);
+using PackEntitiesFn = void (*)(void*, void*, int, void*, void*, void*, void*);
using PackEntityFn = void (*)(void*, void*, int, void*, void*, void*, void*, void*, void*, void*);
static SendSnapshotFn g_sendSnapshot = nullptr;
static PackEntitiesFn g_packEntities = nullptr;
@@ -26,8 +26,11 @@ static uint64_t g_packCalls = 0;
static uint64_t g_packEntityCalls = 0;
static uint64_t g_packEntityGenericSpoofs = 0;
static uint64_t g_fullUpdateWrites = 0;
+static uint64_t g_packEntitiesDebugLogs = 0;
+static uint64_t g_packEntityRuleDebugLogs = 0;
static int g_fullUpdateBudget = 0;
static thread_local CPlayerSlot g_currentRecipient(-1);
+static thread_local CPlayerSlot g_lastSnapshotRecipient(-1);
static CPlayerSlot SlotFromClient(void* client)
{
@@ -55,7 +58,9 @@ struct ScopedRecipient
static void Detour_SendSnapshot(void* client, void* snapshot)
{
++g_snapshotCalls;
- ScopedRecipient recipient(client ? SlotFromClient(client) : CPlayerSlot(-1));
+ CPlayerSlot slot(client ? SlotFromClient(client) : CPlayerSlot(-1));
+ g_lastSnapshotRecipient = slot;
+ ScopedRecipient recipient(slot);
if (client && g_fullUpdateBudget > 0)
{
int offset = g_gameConfig ? g_gameConfig->GetOffset("CServerSideClient_DeltaTick") : -1;
@@ -70,10 +75,26 @@ static void Detour_SendSnapshot(void* client, void* snapshot)
g_sendSnapshot(client, snapshot);
}
-static void Detour_PackEntities(void* self, void* arg1, int arg2, void* arg3, void* arg4, void* arg5, void* arg6, void* arg7, void* arg8)
+static void Detour_PackEntities(void* self, void* arg1, int arg2, void* arg3, void* arg4, void* arg5, void* arg6)
{
++g_packCalls;
- g_packEntities(self, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
+ CPlayerSlot recipient((arg2 >= 0 && arg2 < 64) ? arg2 : -1);
+ if (g_packEntitiesDebugLogs < 16)
+ {
+ SPMessage("pack_entities call %llu self=%p arg1=%p arg2=%d recipient=%d args=[%p %p %p %p]\n",
+ static_cast(g_packCalls),
+ self,
+ arg1,
+ arg2,
+ recipient.Get(),
+ arg3,
+ arg4,
+ arg5,
+ arg6);
+ ++g_packEntitiesDebugLogs;
+ }
+ ScopedRecipient scopedRecipient(recipient);
+ g_packEntities(self, arg1, arg2, arg3, arg4, arg5, arg6);
}
static void Detour_PackEntity(void* self, void* arg1, int entityIndex, void* entityData, void* arg4, void* arg5, void* arg6, void* arg7, void* arg8, void* arg9)
@@ -87,6 +108,26 @@ static void Detour_PackEntity(void* self, void* arg1, int entityIndex, void* ent
}
CPlayerSlot recipient = g_currentRecipient;
+ if (recipient.Get() < 0 && g_lastSnapshotRecipient.Get() >= 0)
+ recipient = g_lastSnapshotRecipient;
+ if (g_packEntityRuleDebugLogs < 64 && g_overrides.HasRulesForEntity(entityIndex))
+ {
+ SPMessage("pack_entity ruled call %llu entity=%d current=%d lastSnapshot=%d args self=%p arg1=%p data=%p arg4=%p arg5=%p arg6=%p arg7=%p arg8=%p arg9=%p\n",
+ static_cast(g_packEntityCalls),
+ entityIndex,
+ g_currentRecipient.Get(),
+ g_lastSnapshotRecipient.Get(),
+ self,
+ arg1,
+ entityData,
+ arg4,
+ arg5,
+ arg6,
+ arg7,
+ arg8,
+ arg9);
+ ++g_packEntityRuleDebugLogs;
+ }
auto applied = g_overrides.ApplyForPackedEntity(recipient, entityIndex, entityData);
g_packEntityGenericSpoofs += applied.size();
g_packEntity(self, arg1, entityIndex, entityData, arg4, arg5, arg6, arg7, arg8, arg9);
diff --git a/src/overrides.cpp b/src/overrides.cpp
index a8d5141..747d283 100644
--- a/src/overrides.cpp
+++ b/src/overrides.cpp
@@ -53,6 +53,34 @@ static std::vector SplitString(const char* value, char separator)
return out;
}
+static std::string NormalizeSchemaClassName(std::string typeName)
+{
+ while (!typeName.empty() && (typeName.back() == '*' || typeName.back() == '&' || typeName.back() == ' '))
+ typeName.pop_back();
+ while (!typeName.empty() && typeName.front() == ' ')
+ typeName.erase(typeName.begin());
+
+ constexpr const char* classPrefix = "class ";
+ constexpr const char* structPrefix = "struct ";
+ if (typeName.rfind(classPrefix, 0) == 0)
+ typeName.erase(0, strlen(classPrefix));
+ else if (typeName.rfind(structPrefix, 0) == 0)
+ typeName.erase(0, strlen(structPrefix));
+
+ return typeName;
+}
+
+static bool IsPointerSchemaType(const std::string& typeName)
+{
+ for (auto it = typeName.rbegin(); it != typeName.rend(); ++it)
+ {
+ if (*it == ' ')
+ continue;
+ return *it == '*';
+ }
+ return false;
+}
+
void OverrideManager::Clear()
{
for (const auto& rule : m_rules)
@@ -75,7 +103,7 @@ void OverrideManager::Compact()
RebuildIndex();
}
-bool OverrideManager::ResolveFieldPath(const char* className, const char* fieldPath, int32_t& offset, int32_t& size, std::string& error) const
+bool OverrideManager::ResolveFieldPath(const char* className, const char* fieldPath, int32_t& offset, int32_t& size, std::vector& addressSteps, std::string& error) const
{
if (!className || !className[0] || !fieldPath || !fieldPath[0])
{
@@ -92,6 +120,7 @@ bool OverrideManager::ResolveFieldPath(const char* className, const char* fieldP
offset = 0;
size = 0;
+ addressSteps.clear();
std::string currentClass = className;
for (size_t i = 0; i < parts.size(); ++i)
{
@@ -102,8 +131,11 @@ bool OverrideManager::ResolveFieldPath(const char* className, const char* fieldP
return false;
}
- offset += field.offset;
size = field.size;
+ FieldAddressStep step {};
+ step.offset = field.offset;
+ step.dereference = i + 1 < parts.size() && IsPointerSchemaType(field.typeName);
+ addressSteps.push_back(step);
if (i + 1 < parts.size())
{
if (field.typeName.empty())
@@ -111,13 +143,119 @@ bool OverrideManager::ResolveFieldPath(const char* className, const char* fieldP
error = "intermediate field has no schema type name";
return false;
}
- currentClass = field.typeName;
+ currentClass = NormalizeSchemaClassName(field.typeName);
}
}
+ if (!addressSteps.empty())
+ offset = addressSteps.front().offset;
+
return true;
}
+static uint8_t* ResolveRuleAddress(const CompiledOverrideRule& rule, void* entityData)
+{
+ auto* address = static_cast(entityData);
+ for (const auto& step : rule.addressSteps)
+ {
+ address += step.offset;
+ if (!step.dereference)
+ continue;
+
+ address = *reinterpret_cast(address);
+ if (!address)
+ return nullptr;
+ }
+ return address;
+}
+
+static uint8_t* ResolveAddressSteps(const std::vector& steps, void* entityData)
+{
+ auto* address = static_cast(entityData);
+ for (const auto& step : steps)
+ {
+ address += step.offset;
+ if (!step.dereference)
+ continue;
+
+ address = *reinterpret_cast(address);
+ if (!address)
+ return nullptr;
+ }
+ return address;
+}
+
+static void SaveAndWrite(std::vector& applied, uint8_t* address, const void* value, size_t size)
+{
+ AppliedOverride saved {};
+ saved.address = address;
+ saved.original.resize(size);
+ memcpy(saved.original.data(), address, saved.original.size());
+ memcpy(address, value, size);
+ applied.push_back(saved);
+}
+
+static void SaveAndWriteIfChanged(std::vector& applied, uint8_t* address, const void* value, size_t size)
+{
+ if (memcmp(address, value, size) == 0)
+ return;
+
+ SaveAndWrite(applied, address, value, size);
+}
+
+static void ApplyVectorFirstFromField(const CompiledOverrideRule& rule, void* entityData, std::vector& applied)
+{
+ auto* vectorAddress = ResolveAddressSteps(rule.addressSteps, entityData);
+ auto* sourceAddress = ResolveAddressSteps(rule.sourceAddressSteps, entityData);
+ if (!vectorAddress || !sourceAddress)
+ return;
+
+ const uint32_t sourceHandle = *reinterpret_cast(sourceAddress);
+ constexpr uint32_t invalidHandle = 0xffffffff;
+ const int32_t count = *reinterpret_cast(vectorAddress);
+ auto* elements = *reinterpret_cast(vectorAddress + 8);
+ if (!rule.debugLogged)
+ {
+ uint32_t h0 = elements && count > 0 ? *reinterpret_cast(elements) : invalidHandle;
+ uint32_t h1 = elements && count > 1 ? *reinterpret_cast(elements + 4) : invalidHandle;
+ uint32_t h2 = elements && count > 2 ? *reinterpret_cast(elements + 8) : invalidHandle;
+ const auto* raw = reinterpret_cast(vectorAddress);
+ SPMessage("vector apply rule %d recipient=%d entity=%d vec=%p src=%p count=%d elems=%p active=%08x first=[%08x %08x %08x] raw=%02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x\n",
+ rule.id,
+ rule.recipientSlot,
+ rule.entityIndex,
+ vectorAddress,
+ sourceAddress,
+ count,
+ elements,
+ sourceHandle,
+ h0,
+ h1,
+ h2,
+ raw[0], raw[1], raw[2], raw[3], raw[4], raw[5], raw[6], raw[7],
+ raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15],
+ raw[16], raw[17], raw[18], raw[19], raw[20], raw[21], raw[22], raw[23]);
+ rule.debugLogged = true;
+ }
+ if (count < 0 || count > 128)
+ return;
+
+ auto* countAddress = reinterpret_cast(&*reinterpret_cast(vectorAddress));
+ if (sourceHandle == invalidHandle)
+ {
+ const int32_t emptyCount = 0;
+ SaveAndWriteIfChanged(applied, countAddress, &emptyCount, sizeof(emptyCount));
+ return;
+ }
+
+ if (!elements)
+ return;
+
+ const int32_t activeOnlyCount = 1;
+ SaveAndWriteIfChanged(applied, countAddress, &activeOnlyCount, sizeof(activeOnlyCount));
+ SaveAndWriteIfChanged(applied, elements, &sourceHandle, sizeof(sourceHandle));
+}
+
void OverrideManager::RebuildIndex()
{
m_rulesByEntity.clear();
@@ -139,6 +277,17 @@ bool OverrideManager::MarkEntityFieldDirtyByOffset(int entityIndex, int32_t offs
return true;
}
+bool OverrideManager::MarkEntityFullDirty(int entityIndex)
+{
+ auto* identity = FindIdentityByIndex(entityIndex);
+ if (!identity || !identity->m_pInstance)
+ return false;
+
+ NetworkStateChangedData data(true);
+ identity->m_pInstance->NetworkStateChanged(data);
+ return true;
+}
+
int OverrideManager::AddRuleBytes(int recipientSlot, int entityIndex, const char* className, const char* fieldPath, const void* value, int valueSize, std::string& error)
{
if (recipientSlot < -1 || recipientSlot >= 64)
@@ -163,7 +312,7 @@ int OverrideManager::AddRuleBytes(int recipientSlot, int entityIndex, const char
rule.entityIndex = entityIndex;
rule.className = className ? className : "";
rule.fieldPath = fieldPath ? fieldPath : "";
- if (!ResolveFieldPath(rule.className.c_str(), rule.fieldPath.c_str(), rule.offset, rule.size, error))
+ if (!ResolveFieldPath(rule.className.c_str(), rule.fieldPath.c_str(), rule.offset, rule.size, rule.addressSteps, error))
return 0;
if (rule.size > 0 && valueSize > rule.size)
{
@@ -174,8 +323,72 @@ int OverrideManager::AddRuleBytes(int recipientSlot, int entityIndex, const char
auto* bytes = static_cast(value);
rule.value.assign(bytes, bytes + valueSize);
m_rules.push_back(rule);
+ if (rule.fieldPath.find("m_hMyWeapons") != std::string::npos)
+ {
+ SPMessage("rule %d recipient=%d entity=%d %s::%s size=%d valueSize=%d topOffset=%d steps=%zu\n",
+ rule.id,
+ rule.recipientSlot,
+ rule.entityIndex,
+ rule.className.c_str(),
+ rule.fieldPath.c_str(),
+ rule.size,
+ valueSize,
+ rule.offset,
+ rule.addressSteps.size());
+ }
RebuildIndex();
- MarkEntityFieldDirtyByOffset(entityIndex, rule.offset);
+ MarkEntityFullDirty(entityIndex);
+ return rule.id;
+}
+
+int OverrideManager::AddRuleVectorFirstFromField(int recipientSlot, int entityIndex, const char* className, const char* vectorFieldPath, const char* sourceFieldPath, std::string& error)
+{
+ if (recipientSlot < -1 || recipientSlot >= 64)
+ {
+ error = "recipient slot must be all/-1 or 0..63";
+ return 0;
+ }
+ if (entityIndex < 0)
+ {
+ error = "entity index must be >= 0";
+ return 0;
+ }
+
+ CompiledOverrideRule rule;
+ rule.id = m_nextId++;
+ rule.kind = OverrideRuleKind::VectorFirstFromField;
+ rule.recipientSlot = recipientSlot;
+ rule.entityIndex = entityIndex;
+ rule.className = className ? className : "";
+ rule.fieldPath = vectorFieldPath ? vectorFieldPath : "";
+ rule.sourceFieldPath = sourceFieldPath ? sourceFieldPath : "";
+ if (!ResolveFieldPath(rule.className.c_str(), rule.fieldPath.c_str(), rule.offset, rule.size, rule.addressSteps, error))
+ return 0;
+
+ int32_t sourceOffset = 0;
+ if (!ResolveFieldPath(rule.className.c_str(), rule.sourceFieldPath.c_str(), sourceOffset, rule.sourceSize, rule.sourceAddressSteps, error))
+ return 0;
+ if (rule.sourceSize > 0 && rule.sourceSize < 4)
+ {
+ error = "source field is smaller than a handle";
+ return 0;
+ }
+
+ m_rules.push_back(rule);
+ SPMessage("vector-first rule %d recipient=%d entity=%d %s::%s <- %s vectorSize=%d sourceSize=%d topOffset=%d steps=%zu sourceSteps=%zu\n",
+ rule.id,
+ rule.recipientSlot,
+ rule.entityIndex,
+ rule.className.c_str(),
+ rule.fieldPath.c_str(),
+ rule.sourceFieldPath.c_str(),
+ rule.size,
+ rule.sourceSize,
+ rule.offset,
+ rule.addressSteps.size(),
+ rule.sourceAddressSteps.size());
+ RebuildIndex();
+ MarkEntityFullDirty(entityIndex);
return rule.id;
}
@@ -186,7 +399,7 @@ bool OverrideManager::RemoveRule(int id)
if (rule.id != id)
continue;
rule.enabled = false;
- MarkEntityFieldDirtyByOffset(rule.entityIndex, rule.offset);
+ MarkEntityFullDirty(rule.entityIndex);
RebuildIndex();
return true;
}
@@ -197,7 +410,8 @@ bool OverrideManager::MarkEntityFieldDirty(int entityIndex, const char* classNam
{
int32_t offset = 0;
int32_t size = 0;
- if (!ResolveFieldPath(className, fieldPath, offset, size, error))
+ std::vector addressSteps;
+ if (!ResolveFieldPath(className, fieldPath, offset, size, addressSteps, error))
return false;
return MarkEntityFieldDirtyByOffset(entityIndex, offset);
}
@@ -212,19 +426,69 @@ std::vector OverrideManager::ApplyForPackedEntity(CPlayerSlot r
if (found == m_rulesByEntity.end())
return applied;
+ int effectiveRecipient = recipient.Get();
+ int uniqueRuleRecipient = -2;
for (size_t ruleIndex : found->second)
{
const auto& rule = m_rules[ruleIndex];
- if (!rule.enabled || (rule.recipientSlot != -1 && rule.recipientSlot != recipient.Get()))
+ if (!rule.enabled || rule.recipientSlot < 0)
+ continue;
+ if (uniqueRuleRecipient == -2)
+ uniqueRuleRecipient = rule.recipientSlot;
+ else if (uniqueRuleRecipient != rule.recipientSlot)
+ {
+ uniqueRuleRecipient = -2;
+ break;
+ }
+ }
+
+ if ((effectiveRecipient < 0 || effectiveRecipient == 0 || effectiveRecipient == 1) && uniqueRuleRecipient >= 0)
+ {
+ static int fallbackLogs = 0;
+ if (fallbackLogs < 64)
+ {
+ SPMessage("entity=%d unresolved recipient current=%d using unique rule recipient=%d\n",
+ entityIndex,
+ effectiveRecipient,
+ uniqueRuleRecipient);
+ ++fallbackLogs;
+ }
+ effectiveRecipient = uniqueRuleRecipient;
+ }
+
+ for (size_t ruleIndex : found->second)
+ {
+ const auto& rule = m_rules[ruleIndex];
+ if (!rule.enabled)
continue;
- auto* address = reinterpret_cast(entityData) + rule.offset;
- AppliedOverride saved {};
- saved.address = address;
- saved.original.resize(rule.value.size());
- memcpy(saved.original.data(), address, saved.original.size());
- memcpy(address, rule.value.data(), rule.value.size());
- applied.push_back(saved);
+ if (rule.recipientSlot != -1 && rule.recipientSlot != effectiveRecipient)
+ {
+ if (!rule.recipientMismatchLogged)
+ {
+ SPMessage("rule %d entity=%d matched packed entity but skipped recipient rule=%d current=%d field=%s::%s\n",
+ rule.id,
+ entityIndex,
+ rule.recipientSlot,
+ effectiveRecipient,
+ rule.className.c_str(),
+ rule.fieldPath.c_str());
+ rule.recipientMismatchLogged = true;
+ }
+ continue;
+ }
+
+ if (rule.kind == OverrideRuleKind::VectorFirstFromField)
+ {
+ ApplyVectorFirstFromField(rule, entityData, applied);
+ continue;
+ }
+
+ auto* address = ResolveRuleAddress(rule, entityData);
+ if (!address)
+ continue;
+
+ SaveAndWrite(applied, address, rule.value.data(), rule.value.size());
}
return applied;
diff --git a/src/overrides.h b/src/overrides.h
index 07eac99..665be16 100644
--- a/src/overrides.h
+++ b/src/overrides.h
@@ -13,6 +13,18 @@ struct AppliedOverride
std::vector original;
};
+enum class OverrideRuleKind
+{
+ Bytes,
+ VectorFirstFromField,
+};
+
+struct FieldAddressStep
+{
+ int32_t offset {};
+ bool dereference {};
+};
+
struct CompiledOverrideRule
{
int id {};
@@ -20,10 +32,17 @@ struct CompiledOverrideRule
int entityIndex {-1};
std::string className;
std::string fieldPath;
+ std::string sourceFieldPath;
int32_t offset {};
int32_t size {};
+ int32_t sourceSize {};
+ std::vector addressSteps;
+ std::vector sourceAddressSteps;
std::vector value;
+ OverrideRuleKind kind {OverrideRuleKind::Bytes};
bool enabled {true};
+ mutable bool debugLogged {false};
+ mutable bool recipientMismatchLogged {false};
};
class OverrideManager
@@ -32,18 +51,21 @@ public:
void Clear();
void Compact();
int AddRuleBytes(int recipientSlot, int entityIndex, const char* className, const char* fieldPath, const void* value, int valueSize, std::string& error);
+ int AddRuleVectorFirstFromField(int recipientSlot, int entityIndex, const char* className, const char* vectorFieldPath, const char* sourceFieldPath, std::string& error);
bool RemoveRule(int id);
bool MarkEntityFieldDirty(int entityIndex, const char* className, const char* fieldPath, std::string& error);
bool Empty() const { return m_rules.empty(); }
bool HasPackedOverrides() const { return !m_rulesByEntity.empty(); }
+ bool HasRulesForEntity(int entityIndex) const { return m_rulesByEntity.find(entityIndex) != m_rulesByEntity.end(); }
std::vector ApplyForPackedEntity(CPlayerSlot recipient, int entityIndex, void* entityData);
void Restore(const std::vector& applied);
private:
- bool ResolveFieldPath(const char* className, const char* fieldPath, int32_t& offset, int32_t& size, std::string& error) const;
+ bool ResolveFieldPath(const char* className, const char* fieldPath, int32_t& offset, int32_t& size, std::vector& addressSteps, std::string& error) const;
void RebuildIndex();
bool MarkEntityFieldDirtyByOffset(int entityIndex, int32_t offset);
+ bool MarkEntityFullDirty(int entityIndex);
std::vector m_rules;
std::unordered_map> m_rulesByEntity;
diff --git a/src/plugin.cpp b/src/plugin.cpp
index b12144d..0ef10b0 100644
--- a/src/plugin.cpp
+++ b/src/plugin.cpp
@@ -74,6 +74,21 @@ extern "C" __attribute__((visibility("default"))) int SendProxy_SetOverride(int
return id;
}
+extern "C" __attribute__((visibility("default"))) int SendProxy_SetVectorFirstFromField(int recipientSlot, int entityIndex, const char* className, const char* vectorFieldPath, const char* sourceFieldPath)
+{
+ std::string error;
+ int id = g_overrides.AddRuleVectorFirstFromField(recipientSlot, entityIndex, className, vectorFieldPath, sourceFieldPath, error);
+ if (!id)
+ {
+ SPWarning("SendProxy_SetVectorFirstFromField failed: %s\n", error.c_str());
+ return 0;
+ }
+
+ g_enabled = true;
+ g_armed = true;
+ return id;
+}
+
extern "C" __attribute__((visibility("default"))) bool SendProxy_RemoveOverride(int ruleId)
{
return g_overrides.RemoveRule(ruleId);
@@ -96,6 +111,16 @@ extern "C" __attribute__((visibility("default"))) bool SendProxy_MarkDirty(int e
return true;
}
+extern "C" __attribute__((visibility("default"))) void SendProxy_DumpSchema(const char* className, const char* contains)
+{
+ schema::DumpFields(className, contains);
+}
+
+extern "C" __attribute__((visibility("default"))) void SendProxy_DumpEnum(const char* enumName)
+{
+ schema::DumpEnum(enumName);
+}
+
PLUGIN_EXPOSE(SendProxyPlugin, g_SendProxy);
bool SendProxyPlugin::Load(PluginId id, ISmmAPI* ismm, char* error, size_t maxlen, bool late)
diff --git a/src/schema.cpp b/src/schema.cpp
index bfb74d6..5dbde8a 100644
--- a/src/schema.cpp
+++ b/src/schema.cpp
@@ -4,6 +4,7 @@
#include "schemasystem/schemasystem.h"
#include