feat(SendProxy): native per-net-var value override in core - #111
Conversation
|
d790424 to
1eff241
Compare
|
I'm not an ms maintainer, but I think there are a few things that could be improved.
|
f92c1f9 to
902d950
Compare
|
Reworked to your feedback — thanks: 1. Per-client only, drop the global spoofer. Done. The uniform/leaf-encoder path is gone; this is now purely per-client. It hooks the per-client encode stage (main thread, after the parallel pack joins), stores registrations in a pointer array indexed by entity index, and fires a callback ( 2. Also ran an adversarial pass and hardened it (try/catch around the callback so a plugin bug cant fail-fast the server on the encode path, propagate the BitCopy return, drop a stale name cache, gate unsupported field kinds). Supports int/float/bool/qangle/string (e.g. per-viewer player name). Kept draft — its built + staged but not live-tested yet, and Windows is compiled out for now (GetBitRange is inlined there → needs the index-based field-path resolve, which the standalone had but never verified on hardware). Details + tested/untested matrix in the updated description. |
|
also, it's best to avoid force pushing, |
|
perclient callback maybe impact your performance, and field path etc string, suggest to use MURMURHASH value merge all receivers into 1 callback |
Lets a plugin override the value of a networked field (send-prop) as it is written to a SPECIFIC client, without changing the real server value — so a client can be shown a different value than others (fake HP, per-viewer player name, etc.). Design (per maintainer review of the earlier uniform draft): per-client only, callback on the main thread, pointer-array storage, IBaseEntity/IGameClient. Native (Engine/src/hook/extern/SendProxyManager.cpp): - Hooks the per-client encode stage, which runs on the MAIN thread (the per-client send loop is synchronous, after the parallel PackEntities join): PerClientEncode (recipient) -> WriteDeltaEntity (entity index) -> WriteFieldList (serializer) -> GetBitRange (field path) -> BitCopyPrimitive. - At BitCopy, a registered (entity, field) fires the OnSendProxyValue forward into managed on the main thread; if overridden, the value is re-encoded via the field's own encoder into a scratch and emitted in place of the real bits. - Registrations in a pointer array by entity index; entity-delete cleanup via IEntityListener. Field type classified from the encoder-registry table. API: ISendProxyManager.Hook(IBaseEntity, field, SendProxyCallback) where the callback (IGameClient, IBaseEntity, ref SendProxyValue) runs per recipient. Exposed via ISharedSystem.GetSendProxyManager(). Kinds: int/float/bool/qangle/ string (string via a fixed inline buffer valid across the synchronous forward). The managed callback is wrapped in try/catch so a plugin exception can't fail-fast the server on the encode path. The value defaults to 0/empty (the real value is not decoded). Callbacks must be fast and read-only. gamedata: .asset/gamedata/networksystem.games.jsonc (linux + windows sigs). docs: docfx en-us + zh-cn example pages (one example per kind) + snippet. Windows: GetBitRange is inlined there, so field-path resolution would need an index-based path (designed in the reference plugin, never verified on hardware) — not ported; the install is compiled out on Windows (Linux-only for now). Not live-tested on any platform yet.
902d950 to
66caab2
Compare
|
btw GetBitRange/FieldPathSite are not necessary, cuz its already included in the serialized entity data |
|
Thanks — I dug into this against the encode path and the standalone plugin (which historically ran close to what youre describing). Proposal, want to align before I rework: Batching (merge receivers into 1 callback): agreed, adopting it. Fire the callback ONCE at the first per-client Field key: Id key on the engines packed field-path token, not a name-murmur. Override value: Id keep a typed value + let native encode once, rather than caller-supplied Questions so I build the right thing:
|
Per maintainer review: firing the callback once per (entity, field) per RECEIVER
was ~receiver-count too many managed transitions. Now it fires ONCE per tick at
the first per-client BitCopy of a (entity, field): the callback fills a native
per-slot table (SendProxyBatch.SetFor(client, value)), and every other receiver
that tick is served from the table with no further managed call. For a field on
a pawn seen by N players this is N managed calls -> 1.
- Native store: g_hooks[entity] set<field> -> map<field, FieldBatch{tick, type,
hasMask, SendProxyValue[64]}>. First BitCopy of a tick (gpGlobals->nTickCount)
refills via the new OnSendProxyBatch forward; the rest read the table.
- API: SendProxyCallback is now (IBaseEntity entity, SendProxyBatch batch); the
batch writes per-slot values straight into the native table.
- Owner-ALC purge, try/catch, entity-delete cleanup, Linux-only all retained.
Note: a field only proxies while it is actually networked that tick — static
entities (e.g. prop_dynamic) whose values don't change don't re-encode, so the
hook won't fire for them; regularly-networked entities (pawns) are the use case.
|
The content I gave earlier was just a rough direction from my early analysis. Normally, the callback should be triggered in |
- HIGH: a batch callback that Unhooks itself erased the map node native was still reading (UAF). Defer Unhook/UnhookEntity while dispatching and flush after the substitution. - HIGH: replace the hardcoded CServerSideClient slot offset 0x48 with the gamedata-resolved GetSlot() so a game update can't silently bleed one client's faked value to another. - Widen the managed dispatch try to cover the field marshal + entity resolve. - Doc: SendProxyBatch is callback-scoped (don't store); fix stale header/field comments (batch stores the value kind, not a per-receiver flow).
|
Implemented the batching (your idea #2) — pushed as additive commits (
Ran an adversarial review of the batching delta and fixed two real issues it found: a use-after-free if a callback unhooks itself (now deferred + flushed after substitution), and the hardcoded @2vg — thanks; switched to additive commits (no more force-push), and documented the static-entity limitation (a field only proxies while its actually networked that tick, so pawns yes / idle prop_dynamic no). On Still draft — not live-tested (no client yet). Windows stays compiled out pending the inlined-GetBitRange index path. |
|
as i have said repeatedly, GetBitRange/FieldPathSite is unnecessary, gamedata can be removed |
|
@2vg I decompiled it to check. The token originates in WriteFieldList, but only as a per-iteration loop local computed by an internal binary search over the changed-field list — it doesn't exist at our WriteFieldList entry detour (where we only grab the serializer). And So dropping GetBitRange means either re-implementing that match loop (owning engine-internal offsets across updates), or moving the capture to a WriteFieldList inner-site sig — which is exactly the Windows path already, i.e. the same hook + gamedata, just renamed. Nothing is saved, and GetBitRange stays 1:1 with each BitCopy for free. If there's a specific arg/site I'm missing, point me at it and I'll wire it. |
|
@Prefix no, u wrong |
|
Status: Done: per-client only (global spoofer dropped) · Open — your call:
Notes: Windows is compiled out for now (GetBitRange is inlined there → needs the index-based path). GetBitRange itself stays — decompiled it, the field token is a WriteFieldList loop-local behind a binary search, not reachable at our entry hook or in BitCopy's args (details above). |
|
Windows support is coming in the next commit — porting the index-based field-path resolver (WriteFieldList inner-site captures the field index; resolve index→leaf via the serializer DFS), so it wont be Linux-only. Both platforms will be built; Windows still needs a live check on a Windows box. |
Windows inlines GetBitRange, so instead of a CFieldPath* it captures the flattened-leaf INDEX at the WriteFieldList inner site (r12) and resolves the field name + leaf record by walking the serializer's leaf records in the same DFS order (ResolveFieldNameByIndex). The resolvers compile on both platforms; only the field-path mid-hook target/callback and the register read are gated by PLATFORM_WINDOWS. Removes the compile-out — both platforms now build the per-client path. Windows is compile-checked by CI and still needs a live check on a Windows server (same live-unverified status as Linux).
Addresses the fundamental divergence from the maintainer's design: we were
re-encoding the typed value at EVERY receiver's BitCopy (N encodes for N
receivers). Now the batch callback fills typed values once, then native encodes
each DISTINCT value ONCE into a cached bit blob and every receiver just
bit-splices its blob — exactly the SendProxyOverride{bitCount, replacementBits}
shape. 'everyone but owner sees X' = 1 encode + N-1 splices, not N encodes.
Substitute() split into EncodeToBlob (at fill) + SpliceBlob (per receiver);
FieldBatch caches deduped blobs (managed layout unchanged — blobs appended
after values). The managed API stays typed (plugins can't emit varint bits).
Next: key the hot path on the packed field-path token instead of resolving the
field name string every BitCopy.
The dominant per-tick cost was resolving the field NAME (CFieldPath walk +
string) at every BitCopy of every field of a hooked entity. Capture the packed
field-path token at GetBitRange (rdx; the r12 leaf index on Windows) and cache
the resolve per (serializer, token) -> {leafRec, type, kind, name}. The walk now
runs once per (serializer, field); every BitCopy after is an integer-keyed
lookup. Cache cleared on GameDeactivate so a reused serializer pointer across a
map change can't map an old token to the wrong field.
With encode-once this puts the whole hot path on the maintainer's plane:
integer field compare + bit splice, no per-receiver re-encode, no string walk.
Review found: with encode-once, a same-named leaf elsewhere in the entity's serializer tree (embedded component, different encoder/bit-count) would splice the first leaf's blob into it -> malformed client bitstream. Record the encode-time leafRec in the batch and require the applying leaf to match it, else pass the real value. Also bounds-check strLen in ValuesEqual before memcmp so a bogus length can't over-read.
… the name Kxnrl asked the callback to carry a hashed path value so the managed module identifies the field by hash instead of a string. The forward now passes MurmurHash2(fieldName) (native murmurhash.h, seed 0x31415926); Sharp.Core routes callbacks by (entity, hash) and computes the same hash at registration. Added Sharp.Core/Utilities/MurmurHash2.cs — a byte-for-byte port of the native MurmurHash2 (cross-checked: both produce identical values for the field names tested). Public API is unchanged (Hook still takes the field name); the hashing is internal.
…y limit Two points from review (2vg): - Idle cost: the 5 encode hooks were installed unconditionally at startup, so a server not using SendProxy still paid a per-field trampoline on the hottest loop. Install them on the first Hook call instead (latched; failure latched so it doesn't re-warn). Zero cost until a proxy is registered. - Static entities: an entity not re-encoded (e.g. prop_dynamic that isn't changing) never hits the per-client BitCopy, so a passive hook silently never fires. Documented on ISendProxyManager.Hook — proxy always-changing entities (pawns) or mark the field dirty each tick.
|
You were right on two — both fixed in
On GetBitRange I decompiled WriteFieldList ( Counting BitCopy to track the field also desyncs: the match loop advances the changed-field index on skips with no BitCopy, and BitCopy ( If I'm misreading that offset, point me at the specific arg and I'll wire it. |
|
Status after the recent commits — thanks for the reviews, @Kxnrl @2vg. Still a draft, not live-tested yet. @Kxnrl — your asks, all in:
Kept typed values + native encode instead of caller One open question: fire point. You suggested ComputeEntity/PackEntity; we fire per-client at BitCopy, since PackEntity has no receiver identity and writes the shared snapshot (per-client substitution there isn't possible/safe). Your PackEntity idea also enables a Source-1-style OnChange callback — that's a separate feature, not built here. Good with per-client BitCopy for the per-client override, or want it reconsidered? @2vg — your points:
|
…ath hooks Per 2vg: WriteFieldList seeks the src read cursor to each field's absolute start bit before every BitCopy, so the cursor value identifies the field. Capture the bit-offset table + snapshot buffer + count from WriteFieldList's 5th arg; at BitCopy, gate on the src data pointer, binary-search the table for the cursor to recover the flattened-leaf index, resolve index->leaf (the existing walk). Removes the per-field GetBitRange mid-hook (Linux) + WriteFieldList_FieldPathSite mid-hook (Windows) and both gamedata sigs, and unifies the two platforms (the table lives in WriteFieldList itself). Drops a full-context-save trampoline from the hottest loop. Measured ~6.6x cheaper field resolution vs the mid-hook. Live-verify item: the index->leaf DFS equivalence is only battle-tested on Windows so far; the Linux live test must confirm correct field resolution.
Review of the cursor-match refactor found two: - Zero-width fields share a start bit with their neighbor, so the binary search could resolve to the wrong (zero-width) field and splice a wrong-width blob. Match the LAST equal table entry (the field actually being copied) and pass through bitcount==0 copies outright. - If WriteFieldList's descriptor arg isn't a valid pointer, clear the table/ buffer/count captures instead of leaving the outer frame's stale — else a nested call resolves a fresh serializer against the wrong table.
|
please keep the code style and naming rules |
…oked index Entity indices are reused; if a delete was missed, a new entity at a previously hooked index would inherit the old entity's hooks and proxy the wrong entity. OnEntityCreated now warns + clears the stale index, mirroring TransmitManager.
…_<X>, house constants Per maintainer's style request, align the native file to TransmitManager's conventions: native impls SendProxyManager<Verb> (not SendProxy<Verb>), Detour_<Method> (not <Method>_Detour), MAX_ENTITY_COUNT / SCREAMING_SNAKE constants (not k-prefixed), g_pHooks. Renamed the local install helper so it no longer shadows installer.h's InstallDetour.
Lifecycle review vs TransmitManager: - Real bug: on a map change entity teardown isn't guaranteed per hooked entity, so leaked FieldMaps kept g_hasAnyHook true across maps. GameDeactivate now deletes every g_pHooks entry + resets g_hasAnyHook (alongside the g_resolve clear), matching TransmitManager's wholesale reset. - The no-lock design is correct (per-client send + entity listeners are all main-thread; TransmitManager's locks are Source1 legacy per its own TODO) — assert the main-thread assumption in PerClientEncode, since a violation would corrupt regardless of a lock.
…'s quiet logging) TransmitManager emits no info/success logs — only error-path WARNs. Remove the FLOG on successful install; keep the failure WARNs (fire only when SendProxy can't install) and the stale-hook WARN (matches TM's FERROR on the same case).
Per maintainer's style request, align to sibling managers (TransmitManager/ ClientManager): move SendProxyValueKind to Sharp.Shared/Enums/, SendProxyValue + SendProxyBatch to Sharp.Shared/Types/ (one interface per Managers file); nest the callback as ISendProxyManager.DelegateSendProxyBatch (like IClientManager.DelegateClientCommand); Native/Forward using-aliases; drop sealed; EntityIndex unit type in the native bindings; multi-line doc comments. No behavior change.
…pace Match the hook/extern siblings (TransmitManager et al.) which use static file- scope functions/globals rather than an anonymous namespace. File-local types get Sp-prefixed names (SpFieldType, SpStringHash, ...) so they're ODR-safe at global scope like TM's *_t types. No behavior change.
Match TransmitManager's comment density (~3%). Removed the section banners (TM uses none), the long header prose, and narrative comments that restated the code; kept every RE-offset comment and the key invariants (main-thread gate, deferred-erase UAF, cursor-match, encode-once, leaf-match guard). Comments only, no code change.
…-> 4) Sharp.Core/Managers/SendProxyManager.cs was ~10% narrative comments vs sibling managers' ~0.5%; collapse the verbose _hooks/ALC/dispatch preambles to one line each. Comments only, no code change.
|
Regarding about implementation, there are no big issues. |
| /// Per-slot value one client receives for the proxied field. Mirrors the native SendProxyValue layout. | ||
| /// </summary> | ||
| [StructLayout(LayoutKind.Sequential)] | ||
| internal unsafe struct SendProxyValue |
There was a problem hiding this comment.
Done in 3cd117b — payloads now share storage (anonymous union native side, LayoutKind.Explicit managed side), and the per-value kind field is dropped entirely since kind lives on the batch and nothing ever read it. 288 → 264 bytes per slot; a static_assert(sizeof == 264) pins the ABI mirror.
|
two points on the api and the scope:
_sendProxy.Hook(pawn, "m_iHealth", (entity, batch) =>
{
foreach (var client in _clients.GetGameClients())
{
if (client.Slot.AsPrimitive() != 0)
{
batch.SetFor(client, 1L);
}
}
});the per-field callback runs on the per-client encode hot path every tick the field is emitted, even when the spoofed value hasn't changed
it'd be better to state that limitation in the docs than to suggest a dirty mark workaround that doesn't land |
…anaged hooks on entity-delete/map-change, honest delivery contract in docs
|
@2vg On the register/Set question — I stress-tested both shapes before answering. Keeping the callback:
Where you're right, now fixed in 95a6161:
One self-found gap to disclose (RE, libengine2): a client's initial full snapshot goes through the EnterPVS/from-baseline writer, which never enters Agreed on keeping the delta-injection/visitation work out of core — happy to see it as an opt-in follow-up, and I'd gladly read the details you offered. |
…VS detour The per-client rework narrowed identity capture to WriteDeltaEntity_Internal; a client's initial full snapshot uses the EnterPVS/from-baseline writer, which bypassed it (t_entityIdx stayed -1 -> real bits). Detour it, capturing the entity index from the same per-entity ctx (+0x34). Best-effort install: a sig miss leaves the delta path unaffected. RE-verified both platforms, pending live test.
Verified against libengine2 both call sites: 2-arg (only rdi/rsi set) and the return is discarded, so void(void*,void*) is the exact ABI.
All 19 hardcoded engine-struct byte offsets (pack-context, WriteFieldList descriptor, flattened-serializer, fieldInfo, bf_write scratch, bf_read cursor, encoder registry) move into the gamedata Offsets sections and resolve via g_pGameData->GetOffset(), matching PR Kxnrl#109. A game update that shifts a layout is now a .jsonc edit, not a recompile; GetOffset FatalErrors loudly on a missing key. windows==linux preserves the original no-#ifdef behavior. The pack-context entity index is resolved once at install and shared by both entity-index detours.
…a too Finishes the offset migration: the bucket-record stride (16) and entry stride (0x80) were the last ABI-coupled literals — same game-update fragility as the member offsets. Local buffer sizes and the 0xFF no-params sentinel stay literals (our own allocations / engine convention, not layout offsets).
…gate Goal: a CS2 update never leaves SendProxy silently half-broken. - Entity-index offset now auto-derived from the WriteEnterPVS prologue at load (proven ==52 on libengine2.so + engine2.dll), gamedata as fallback, WARN on mismatch so a stale literal is visible and it self-heals on an update. - The remaining gamedata offsets are gated by VerifyOffsetPathOnce: no bit is ever spliced until three independent live invariants agree (bf_read cursor advances by exactly bitcount; start-bit table monotonic+bounded; serializer walk yields one leaf per field). Until verified, real bits pass through. A drifted offset can only make SendProxy inert (loud WARN, latched), never corrupt a client's stream. Verification latches within the first few snapshots so there is no steady-state cost; encode path also sanity-checks the bf_write scratch and refuses implausible param offsets.
…d accessors, named params, drop drift gate - String-anchor WriteDeltaEntity_Internal + WriteFieldList by the unique strings they reference (verified derived==byte-sig on both platform binaries); byte sigs kept as fallback. BitCopyPrimitive/EncoderRegistry stay byte-sig (no stable string anchor — documented why). - Replace all raw *(T*)(ptr+off) math with house-style typed accessors (SpSerializerNode/Field, SpFieldInfo, SpWriteInfo, SpPackContext, SpBfWrite/Read) over the gamedata offsets, mirroring CServerSideClient. - Name every detour param from the decompiled signature (server/client/entityCtx/ serializer/fieldDesc); opaque forwarded engine args are honest positional argN with a comment, not invented names. - Drop the runtime offset-drift verification gate (VerifyOffsetPathOnce + latch state) to match Kxnrl#109's model; keep the entity-index auto-derive + gamedata fallback. Direct substitution restored, behavior-preserving.
… Zydis; name every detour param - 9 more offsets now resolved from the binary at boot (bf_write/bf_read from BitCopyPrimitive, WriteInfo trio from WriteFieldList) via ZydisUtility instruction decode — each proven derived==gamedata on both platform binaries. Gamedata stays as fallback + boot mismatch WARN. 10 offsets total now boot-derived. - FieldInfo (4), serializer tree (4), encoder registry (5) stay gamedata: no single dual-platform-provable anchor (documented). BitCopy/EncoderRegistry stay byte-sig for the same reason. - Every detour param named from the decompiled signature (pDstBitWrite, pFieldList, pFieldContext, nFieldCount, pFrameSnapshot, pBaselineData, …) — no argN. - Trimmed gamedata comments to terse one-liners.
…eview finding) Adversarial review flagged the block-worthy hole: boot-derived offsets are preferred over the known-good gamedata literals, and with the verify gate removed nothing caught a wrong derivation — a future game build where a resolver misfires would silently corrupt every hooked client's stream (no crash). Restore a MINIMAL check (not the old 200k-counter sprawl): before the first substitution, verify against live data once — V3: the src cursor advances by exactly bitcount (proves BfRead/BfWrite_CurBit); V1: the serializer walk yields one leaf per bit-table entry (proves the tree offsets + t_fieldCount). Pass → substitute from there on; fail → latch off with a loud WARN. Until verified, real bits pass through. Makes 'prefer derived' safe: a wrong offset is now inert, not corrupt. One-time latch, no steady-state cost.
…erializers_R String-anchored on the recursive serializer-table writer (unique self-naming assert literal) — one scan yields the serializer-tree offsets (FieldCount/ FieldsArray/FieldStride/Child) + fieldInfo encoder dispatch/base/param-offset, each proven derived==known on both platform binaries (capstone model + a standalone harness running the exact shipped resolver under real Zydis). Wrong values are covered by the V1 drift gate (tree) and ClassifyLeaf/EncodeToBlob guards (dispatch), so a bad derivation is inert, never corrupt. Self-healing offsets now 17/23. Residual: FieldInfo_Name + 5 registry strides (no stable cross-platform anchor — Bucket_Stride encodes divergently) stay numeric gamedata; BitCopy/EncoderRegistry stay byte-sig (string anchor proven more fragile than the byte match). All fall back to gamedata if the anchor moves.
…emantics The 'Bucket_Stride encodes divergently' block was a red herring: shl-4 and a scale-8 indexed access both mean x16. ResolveEncoderRegistryStrides decodes the EFFECTIVE stride from the addressing form (fold-scale vs operand-scale x index prescale), yielding 16 on both platforms from InitFakeField (string-anchored on its 'Couldn't find type lookup' literal). Derives Bucket_Stride/Bucket_Count/ Entry_Stride/Entry_Name — proven derived==known on both binaries via the extended C++/Zydis harness. Self-healing offsets now 21/23. The last 2 are proven underivable, not skipped: EncoderEntry_Func (5 code-pointer fields pass every structural test identically) and FieldInfo_Name (+8 aliases Child, and fieldInfo is runtime-heap-allocated so it's absent from the static binaries). Both stay gamedata+guard (inert if wrong). BitCopy/EncoderRegistry stay byte-sig; their container InitFakeField is now string-anchored as the resilient companion.
…ruption path Adversarial review (corruption-coverage map) found the only class-(c) hole: QAngle is the sole substituted type whose encoder dereferences the fieldInfo params pointer (params[0] = a bit-count selector picking the whole bit layout). A wrong FieldInfo_EncoderBase/ParamOffset (future resolver misfire or real layout drift) feeds it a bogus selector → valid-but-wrong-format blob → silent client desync, uncaught by the drift gate (which returns before the encode path). Every other substituted type ignores params, so they're safe. Fix: KindForType(QAngle3) -> -1 (no forward fires; qangle passes through). Kept Vector3 (its encoder doesn't read params). Re-enable QAngle only once the gate trial-encodes a live field and compares the blob to the real bits. Also corrected the overstated 'gate covers all substitution' comments to state exactly what V3/V1 validate (cursor + tree/fieldcount) vs what relies on the encoder-map/overflow/ name/src-buffer guards.
What
Native per-client SendProxy in core: a plugin overrides the value of a networked field (send-prop) as it is written to a specific client, without changing the real server value — so different clients can be shown different values (fake HP, a per-viewer player name, etc.).
Design (converged with @Kxnrl's review)
IBaseEntity/IGameClientin the API.BitCopyPrimitive, fills a native per-slot table, and every other receiver that tick is served from it with no further managed call — N receivers cost 1 managed transition, not N.SendProxyOverride{bitCount, bits}).WriteFieldListseeks the src read cursor to each field's absolute start bit, so atBitCopythe cursor is matched against the bit-offset table → flattened-leaf index. This needs no separate field-path hook, is identical on Linux and Windows (offsets decompile-verified on both DLLs), and drops a per-field mid-hook trampoline from the hottest loop (~6.6× cheaper resolution in a microbench). Resolved once per (serializer, index), cached; cache dropped onGameDeactivate.SetFor(client, int/float/bool/qangle/string)) — plugins can't hand-emit varint bits; native owns the encode. Never modifies the shared snapshot or retains a foreign buffer (avoids the delayed-UAF that snapshot-mutation approaches hit on Linux).Mechanism
4 hooks, identical on both platforms:
PerClientEncode(recipient) ·WriteDeltaEntity_Internal(entity index) ·WriteFieldList(serializer + bit-offset table / snapshot buffer / field count) ·BitCopyPrimitive(match src cursor → field, substitute). Lazy-installed on the firstHook— a server not using SendProxy pays nothing. Registrations in a pointer array by entity index; cleanup on entity-delete, entity-recreate (reused index), map-change (GameDeactivate), and module-unload (ALC).Reviewed
Multiple adversarial passes + lifecycle/threading comparison vs
TransmitManager; struct layout verified byte-for-byte native↔managed; both DLLs decompiled to verify offsets. Fixed: callback try/catch (a plugin bug would else fail-fast the server on the hot path), a UAF if a callback unhooks itself (deferred + flushed), the slot offset via gamedataGetSlot(), wrong-field on zero-width leaves (match the last equal start bit), stale-capture on a bad descriptor arg, stale hooks on a reused entity index, leaked hooks across a map change. Verified no lock is needed (per-client send + entity listeners are main-thread) and asserted it. Code style/naming aligned toTransmitManager.Tested / untested
Both platforms build and their offsets are decompile-verified byte-identical, but neither is live-tested yet — that's the remaining gate. Known limitation (documented on the API): only entities encoded into a client's delta are proxiable — near-static entities like
prop_dynamicmay never fire the callback.Kinds
int, float, bool, qangle, string (byte-array out of scope).
Notes
TransmitManagerSource2-native rework (#348).