Skip to content

feat(SendProxy): native per-net-var value override in core - #111

Open
Prefix wants to merge 34 commits into
Kxnrl:masterfrom
Prefix:feat/sendproxy-core
Open

feat(SendProxy): native per-net-var value override in core#111
Prefix wants to merge 34 commits into
Kxnrl:masterfrom
Prefix:feat/sendproxy-core

Conversation

@Prefix

@Prefix Prefix commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Built + reviewed on Linux and Windows, converged with maintainer feedback. Not yet live-tested with a connected client — please validate before merge.

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.).

var sp = sharedSystem.GetSendProxyManager();
sp.Hook(pawn, "m_iHealth", (entity, batch) =>
{
    foreach (var c in clients.GetGameClients())
        if (c.Slot.AsPrimitive() != 0) batch.SetFor(c, 1L); // everyone but the player sees 1 HP
});

Design (converged with @Kxnrl's review)

  • Per-client only (no global spoofer), IBaseEntity/IGameClient in the API.
  • Batched: the callback fires once per (entity, field) per tick at the first per-client 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.
  • Bit-plane hot path: each distinct overridden value is encoded once into a bit blob and every receiver just bit-splices it — no per-receiver re-encode (SendProxyOverride{bitCount, bits}).
  • Field identity by cursor-match (thanks @2vg): WriteFieldList seeks the src read cursor to each field's absolute start bit, so at BitCopy the 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 on GameDeactivate.
  • Value is typed (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 first Hook — 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 gamedata GetSlot(), 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 to TransmitManager.

Tested / untested

Linux Windows
Engine compiles + links ✅ (clang) via CI
Sharp.Core / Sharp.Shared build
Hook offsets decompile-verified ✅ (networksystem.dll)
Live (values apply to a client) pending ⏳ pending (needs a Windows box)

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_dynamic may never fire the callback.

Kinds

int, float, bool, qangle, string (byte-array out of scope).

Notes

  • Related to the TransmitManager Source2-native rework (#348).

@Kxnrl

Kxnrl commented Jul 4, 2026

Copy link
Copy Markdown
Owner
  1. Adding a global spoofer would increase complexity. I’d suggest only adding a per-client spoofer, that should be enough. It can use a pointer array and call the callback on the main thread.
  2. always use IBaseEntity instead of EntityIndex in interfaces.

@Prefix
Prefix force-pushed the feat/sendproxy-core branch from d790424 to 1eff241 Compare July 4, 2026 04:41
@2vg

2vg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

I'm not an ms maintainer, but I think there are a few things that could be improved.

  • Some signatures can be found using string-based searches. Please add refs->strings
  • Please remove the unnecessary costs incurred when the proxy is not in use. In the current implementation, lock and map lookup costs are incurred continuously, even when the proxy is idle.
  • Please verify the behavior for entities such as prop_dynamic. If I understand correctly, these are classified as static entities and do not enter the encoding process until their actual values ​​change; this means that calling StateChanged is pointless. Furthermore, those entities basically do not undergo changes in delta changeframes.(cuz no field value changed basically) If that is not going to be fixed, it would be best to explicitly state that only entities which are consistently present in frames such as Pawns are eligible to act as proxies.
    • However, if editing the original snapshot, this may already have been achieved, because the decision of whether to create a changeframe entry is based on a comparison of snapshots. so please verify it.

@Prefix
Prefix force-pushed the feat/sendproxy-core branch 2 times, most recently from f92c1f9 to 902d950 Compare July 4, 2026 05:13
@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

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 (OnSendProxyValue forward) on the main thread at BitCopyPrimitive — no worker-thread re-entrancy, no managed code in the shared pack.

2. IBaseEntity in interfaces. Done — the API is Hook(IBaseEntity entity, string field, SendProxyCallback) and the callback is (IGameClient client, IBaseEntity entity, ref SendProxyValue value); no EntityIndex in the public surface.

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.

@2vg

2vg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

also, it's best to avoid force pushing,
because it makes it impossible to tell exactly what was changed in each commit
in effect, they end up having to review everything for every single commit

@Kxnrl

Kxnrl commented Jul 4, 2026

Copy link
Copy Markdown
Owner

perclient callback maybe impact your performance, and field path etc string, suggest to use MURMURHASH value
like this

struct SendProxyOverride
{
    uint16_t receiverSlot;
    uint16_t bitCount;
    const void *replacementBits;
};

using SendProxyCallback = int(__cdecl *)(
    uint32_t entIndex,
    uint16_t receiverSlot,
    uint32_t encodedFieldPath,
    const void *originalBits,
    uint32_t originalBitCount,
    SendProxyOverride *outOverrides,
    uint32_t maxOverrides);

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.
@Prefix
Prefix force-pushed the feat/sendproxy-core branch from 902d950 to 66caab2 Compare July 4, 2026 05:27
@2vg

2vg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

btw GetBitRange/FieldPathSite are not necessary, cuz its already included in the serialized entity data
simply need to match the cursor
but cuz bitcopy is also called on a separate buffer, please check the data pointer

@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

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 BitCopyPrimitive encounter of a given (entity, field) in a tick, on the main thread, fill a per-slot override table, and serve receivers 2..N from that table with no further managed calls. Thats the plugins PerClientDispatch model. For a field on a pawn with N viewers this is N→1 managed calls.

Field key: Id key on the engines packed field-path token, not a name-murmur. GetBitRanges 3rd arg is already a stateless packed token ((i0+1)<<22)|((i1+1)<<11)|(i2+1), stable per (serializer, field). We capture the CFieldPath at that midhook today; capturing the token too lets the hot path match by (serializer, token) with a lazy-learned verdict cache — zero string work per field (no name walk, no marshaled string, no string dict). A murmur of the field name cant equal the engine token, so it wouldnt remove the resolve cost; happy to use murmur as the registration-time key if you want the natives string-free there. (Windows has no CFieldPath — field index in a register — so (serializer, int) is the right abstraction on both.)

Override value: Id keep a typed value + let native encode once, rather than caller-supplied replacementBits. Managed plugin authors cant produce varint/quantized/string bit-strings, and bitCount isnt known until after encoding. So native encodes each distinct value ONCE per tick via the fields own encoder into a cached scratch, then splices per slot for the rest of the loop (your SendProxyOverride{slot,bitCount,bits} as the internal cache entry, just not the plugin-facing type).

Questions so I build the right thing:

  1. Callback firing once at the first per-client encounter (main thread) — is that your intent? Your signature has both a scalar receiverSlot and an outOverrides[], which read as per-receiver.
  2. OK to substitute the engines packed token/index for the hot-path key (murmur only at registration if desired)?
  3. Typed value + native encode-once acceptable instead of caller replacementBits?
  4. maxOverrides = 64 (slots), non-overridden slots get the real value — good?

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.
@Kxnrl

Kxnrl commented Jul 4, 2026

Copy link
Copy Markdown
Owner

The content I gave earlier was just a rough direction from my early analysis. Normally, the callback should be triggered in ComputeEntity / PackEntity; that way you can also implement something similar to Source 1’s OnChange callback. Ideally, when adding a callback, it should return a hashed path value, then carry that value in the callback so the managed module knows what it is. As for the native types you mentioned, I think you can decide that part yourself.

- 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).
@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Implemented the batching (your idea #2) — pushed as additive commits (86163ab batching, 0bb491a review fixes):

  • Fires once per (entity, field) per tick at the first per-client BitCopy: the callback fills a native per-slot table, every other receiver that tick is served from it with no further managed call. N receivers → 1 managed transition. SendProxyCallback is now (IBaseEntity entity, SendProxyBatch batch) with batch.SetFor(client, value).
  • Kept typed values + native encode-once rather than caller-supplied replacementBits (managed authors cant emit varint/quantized bits, and bitCount isnt known pre-encode); the {slot, bitCount, bits} shape lives as the internal cache. The field key is still the CFieldPath resolve for now — the (serializer, packed-token) fast-path key we discussed is the next optimization once youre good with the token substitution over a name-murmur.

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 CServerSideClient slot offset (now GetSlot() via gamedata). The native↔managed batch struct layout was verified byte-for-byte identical.

@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 refs.strings: added where the sig has a verified string anchor (PerClientEncode); I dont want to add anchors I havent confirmed uniquely resolve, since a wrong one silently breaks resolution — will add more as I verify them.

Still draft — not live-tested (no client yet). Windows stays compiled out pending the inlined-GetBitRange index path.

@2vg

2vg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

as i have said repeatedly, GetBitRange/FieldPathSite is unnecessary, gamedata can be removed
the data is already present in the args

@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

@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 BitCopyPrimitive(dst, src, bitcount) carries no field identity: src is the one shared snapshot buffer (same for every field), bitcount is just the bit width.

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.

@2vg

2vg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

@Prefix no, u wrong
src's read cursor carries it, u already advance src+0x10 per field
that's the start bit

@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Status:

Done: per-client only (global spoofer dropped) · IBaseEntity/IGameClient in the API · batched — one callback per (entity, field) per tick fills a per-slot table, N receivers = 1 managed call. Linux Engine + Sharp.Core build green; ran two adversarial reviews and fixed the blockers (callback try/catch, deferred-unhook UAF, gamedata GetSlot() instead of a hardcoded offset).

Open — your call:

  1. Field key: proposed keying on the engine's own packed field-path token rather than a name-murmur (a murmur of the field name can't equal the engine token, so it wouldn't remove the resolve). Wire it if you're good with the token.
  2. Override value: kept a typed value + native encode instead of caller replacementBits — managed plugins can't emit varint/quantized bits and bitCount isn't known pre-encode. OK, or do you want raw bits?

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).

@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

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.

Prefix added 7 commits July 4, 2026 08:17
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.
@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

You were right on two — both fixed in a4bf887:

  • Idle cost: hooks now install lazily on the first Hook, so a server not using SendProxy pays nothing on the encode path.
  • prop_dynamic / static entities: documented that only entities encoded into a delta are eligible (a passive hook on a non-changing entity never fires).

On GetBitRange I decompiled WriteFieldList (FUN_00443b60) to check the cursor claim. Per field it does:
iStack_18af8 = *(int*)(param_5[1] + (idx+1)*4) — it seeks src's read cursor (src+0x10) to that field's absolute start bit from the bit-offset table, then copies (end-start) bits. The cursor is set to an arbitrary absolute offset each field, not advanced sequentially, so its value carries no field identity — identity is the packed token in rdx that FUN_00426260 decodes.

Counting BitCopy to track the field also desyncs: the match loop advances the changed-field index on skips with no BitCopy, and BitCopy (FUN_00500b70) has other callers on separate buffers. So dropping the hook means re-deriving what it already gives, at higher cost.

If I'm misreading that offset, point me at the specific arg and I'll wire it.

@Prefix

Prefix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Status after the recent commits — thanks for the reviews, @Kxnrl @2vg. Still a draft, not live-tested yet.

@Kxnrl — your asks, all in:

  • per-client only, IBaseEntity in the API ✅
  • one merged callback — fires once per (entity, field) per tick, fills all receivers ✅
  • encode once → splice bits per receiver = your SendProxyOverride{bitCount, replacementBits} ✅ (9731cb4)
  • field keyed on murmur not string: hot path is integer (serializer, packed-token) keyed, and the callback carries MurmurHash2(field) to managed, not the name — verified byte-identical native↔managed ✅ (14d9035, f4a1d9e)
  • Windows built via the index-based resolver ✅ (0d7b03a)

Kept typed values + native encode instead of caller replacementBits (managed can't emit varint/quantized bits), and didn't add originalBits — both under your "decide the native types yourself".

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:

  • idle cost → the encode hooks now install lazily on the first Hook; a server not using SendProxy pays nothing ✅ (a4bf887)
  • prop_dynamic / static entities → documented that only entities encoded into a delta are eligible (or dirty the field each tick) ✅ (a4bf887); the StateChanged rescue still wants a live check
  • GetBitRange/FieldPathSite → kept; decompiled WriteFieldList, it seeks src+0x10 to each field's absolute start bit from the bit-offset table, so the cursor carries no field identity (detail above)
  • additive commits, no force-push — followed

Prefix added 2 commits July 4, 2026 10:23
…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.
@Kxnrl

Kxnrl commented Jul 4, 2026

Copy link
Copy Markdown
Owner

please keep the code style and naming rules

Prefix added 8 commits July 4, 2026 11:00
…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.
@Prefix
Prefix marked this pull request as ready for review July 4, 2026 12:50
…-> 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.
@2vg

2vg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Regarding about implementation, there are no big issues.
But please conduct actual tests on both Windows and Linux.
Instead of simple tests, please use ur intended use cases and include multiple players in the session.
Check for any server issues, covering scenarios such as round endings and map changes.
If time permits, please try a spoof test targeting the prop_dynamic entity I mentioned earlier, and check for any anomalies on both your client and others.
If it doesn't work with static entities but still wish to support them, I can provide an alternative idea (though I am still testing it myself).

/// Per-slot value one client receives for the proxied field. Mirrors the native SendProxyValue layout.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct SendProxyValue

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not union

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@2vg

2vg commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

two points on the api and the scope:

  1. callback or register/set api
        _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
i cant find a case where the callback form enables anything a set(client, ent, field, value) api (called from OnTick or an event) doesn't, even a genuinely per-tick value like a spoofed angle is just OnTick -> set api
the register form keeps plugin logic off the encode path and only re-encodes on change. Is there a use case that actually requires the callback shape?


  1. static fields can't be spoofed, and the documented workaround doesn't hold

it'd be better to state that limitation in the docs than to suggest a dirty mark workaround that doesn't land
delivery piggybacks on the field naturally being in the delta, and the per-client delta is driven by the engine's snapshot comparison (calcDelta), not the dirty bit, so a field only reaches this hook when its real value changes
StateChanged on an otherwise static field triggers a repack, but the bit identical result is dropped from the changeframe, never enters the delta, and bit copy never fires (i confirmed it on a my test server)
so the "mark the field dirty each tick to force it into the delta" note in the api docs won't actually work for a static value, and entities like prop_dynamic can't be spoofed at all with this design
making static fields work needs active delta injection, fabricating the field into the per-client changeframe (inclusion), and for a truly static entity, breaking its snapshot entry-reuse so it's even visited (visitation)
i have both working (tested on Win/Linux), but they're materially more complex and reach into pack-side engine structures, probably a fair question whether that belongs in core where stability is the priority
that is because, while i concluded that my method is the best and offers the highest performance, it is also the most complex
Ultimately, the biggest downside is the need to re implement processing equivalent to what the engine itself performs(I'm not sure if my re-implementation is correct, but)
i'll leave this to Kyle, i can share all the details myself if necessary

…anaged hooks on entity-delete/map-change, honest delivery contract in docs
@Prefix

Prefix commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@2vg On the register/Set question — I stress-tested both shapes before answering. Keeping the callback:

  1. The volatile input is the recipient set, not the value (joins, reconnects, team switches, flag events). The callback re-derives it at delivery time; a persistent Set table turns every recipient-set change into per-plugin bookkeeping (re-Set on connect/reconnect/round), and the failure mode when a plugin gets it wrong is a silent truth leak to exactly the client it meant to blind. It also invites last-writer-wins conflicts between plugins, which the per-tick refill + single-owner registration makes structural today.
  2. The cost flips on the workloads where cost exists. The well-behaved callback is one reverse transition filling all 64 slots (~1–3µs), tens of µs/tick fleet-wide. For genuinely per-tick values (spoofed angles), Set degenerates into 64 native calls + 64 re-encodes per tick — more than the callback it replaces. For static spoof values the callback's cost is already near-zero since it only fires when the field is in a delta. If profiling ever disagrees: the right evolution is an opt-in static mode (native reuses cached blobs, re-dispatches only when the recipient set changes) — the table's caching under the callback's shape — not a public Set surface.

Where you're right, now fixed in 95a6161:

  • The "mark the field dirty each tick" doc note is gone — your changeframe test disproves it. Replaced with the hard contract: an override is delivered only while the field is being (re)networked in a delta; a changed spoof value alone triggers nothing.
  • Found the same leak class inside my own impl while at it: the distinct-value blob pool was capped at 16 with silent real-value passthrough on overflow. Pool is now 64 (= slots), overflow structurally impossible.

One self-found gap to disclose (RE, libengine2): a client's initial full snapshot goes through the EnterPVS/from-baseline writer, which never enters WriteDeltaEntity_Internal — the only site that captures entity identity — so the override is not applied to a late joiner's first frame. Both API shapes share this gap; it's now stated in the docs, and wiring the EnterPVS-side capture (same per-entity ctx, entity index at +0x34) is the planned follow-up before undraft. Worth noting it's also another point for the callback: once that hook lands, first-snapshot coverage is automatic; a Set table would still depend on plugin connect-event timing.

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.

Prefix added 11 commits July 8, 2026 06:47
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants