SIGN IN SIGN UP

shim: two-tier detour + the shape fix its live gate found (#79)

* shim: two-tier detour — a near-allocated 5-byte jump, and relocating what we steal

s2detour refused any prologue with a relative or rip-relative instruction in its 14-byte steal
window, because it copied stolen bytes into the trampoline blindly. That blocked three targets:
both hooks from the declarative-inbound-hooks slice (degraded at the live gate) and
OnPrecacheResource (abandoned for a class-vtable patch).

Reading the prior art shrank the fix. Modern SourceMod does not hand-roll this — CDetour delegates
to safetyhook, which hooks in two tiers: a 5-byte `E9 rel32` with the trampoline allocated within
+/-2GB, falling back to the 14-byte absolute jump only when that allocation fails. Our detour.cpp
was only that fallback tier, which is exactly why we hit refusals SourceMod does not.

Both tiers now exist, and either relocates what it steals. Every CS2 function we target opens with
`push rbp; mov rbp,rsp; push <r>`, so the near tier steals 6 position-independent bytes and the
offending instructions fall outside the window entirely. Relocation still earns its place: the
residual case's rip-relative store IS instruction zero, so no window shrink reaches it.

The near tier jumps to an ISLAND in the trampoline page, not to the handler. The handler lives in
our .so and need not be within 2GB of the game's; the near page is, by construction.

Near allocation probes candidate addresses with MAP_FIXED_NOREPLACE rather than parsing
/proc/self/maps: the kernel is the authority on what is free, the answer cannot go stale between the
read and the map, and there is no parser to get wrong.

Also fixes a latent guard bug found while reading. engine_hooks.cpp proved only [target, target+14)
executable, but s2detour steals WHOLE instructions and routinely reads past 14 (18 for
TerminateRound) — the SEGV-inside-hde64_disasm the guard existed to prevent. The probe is now passed
INTO s2detour and consulted per instruction before decoding it: exact instead of approximate, no
duplicated width constant, and drivable from a test. This supersedes the "kPatchWindow 14 -> 28"
ledger item, which would have papered over it.

The byte-level half is split into detour_reloc.{h,cpp} — engine-free, allocation-free — so the
SHIPPED relocator is driven against hand-authored buffers at pretend addresses. 47 checks: the
prologue corpus (all four working detours plus both blocked ones, asserting the near steal is a
strict prefix of the far steal — the concrete "this cannot regress an existing detour"), byte-exact
displacement rewriting including an instruction carrying BOTH a disp32 and an imm8, a semantic
round-trip through a real mmap'd page, every refusal asserted on its reason string, the probe proven
to gate every instruction, and BOTH tiers installed end-to-end on a real function in the test binary.
Three mutations were driven to prove the assertions can fail.

Install now returns InstallResult rather than bool: the reason is surfaced verbatim instead of
flattened into one string ("short branch in stolen prologue" and "leaves the executable range" want
different responses from whoever reads the log), and which tier ran is logged, because after the
fact only the log can say whether a server took a 5-byte or a 14-byte patch. All five call sites
land with it.

* fix(hooks): relay an unknown inbound param at full width — TerminateRound's arg3 is a pointer

The live gate segfaulted the server on the first engine-driven onTerminateRound. Minidump: SIGSEGV
at libserver.so+0x1329221 (`mov (%r15),%eax`), faulting on 0x00000000_8651f7b0 — a stack address
with its top 32 bits gone, while RSP was 0x00007ffd_8651f4a0.

Disassembling the real CCSGameRules::TerminateRound (vaddr 0x1384ac0) settles it:

    movss %xmm0,-0xd8(%rbp)   ; arg0 delay   32-bit  OK
    mov   %esi,%r15d          ; arg1 reason  32-bit  OK
    mov   %rdx,-0xe0(%rbp)    ; arg2 stored FULL 64-bit -> IT IS A POINTER

The descriptor declared that param `int32_t` (shape this_f32_i32_i32_i32, "_unused3"). The thunk read
edx (truncating) and called the original with edx (zero-extending), so the engine banked half a
pointer and something else dereferenced it much later — which is why the crash landed ~374KB away
from TerminateRound and looked like it came from nowhere.

`_unused3` was never unused. It was a pointer we were cutting in half.

Adds shape this_f32_i32_i64_i64 (id 2): same call, trailing pair carried at full register width as
an OPAQUE PASS-THROUGH class (kParamI64) with no accessor and no `params` entry, so JS can neither
read nor write them and they reach the original bit-for-bit. Widening a pass-through is always safe
— SysV leaves the upper half of a 32-bit arg undefined, so copying the whole register preserves
whatever was actually there — while narrowing never is.

THE RULE, now stated in hook_dispatch.h: for an INBOUND hook an unknown parameter must be relayed at
FULL WIDTH. `int` does not mean "we do not care about this arg", it means "truncate whatever the
engine put in that register". OUTBOUND `calls` is the opposite — there WE choose the value and 0 is
an honest null, which is exactly why the shipping terminateRound() never broke.

Drops "_unused3"/"_unused4" from `params`, so hooks.generated.d.ts stops promising two readonly
numbers that were never readable. check-hook-shapes.sh confirms core and shim agree on all three
shapes; the new test pins that the narrow and wide 4-arg forms stay DISTINCT ids, because collapsing
them would hand a descriptor the truncating thunk back invisibly.

* test(ws): capture core's log in the failing test — dummy_log_fn was discarding the one diagnostic

The ws self-close test still fails in CI with 'reached stage sent' AFTER the deferred-drop fix, so
that fix was not the whole cause. Worse, the test could not say why: it used dummy_logger(), whose
log fn is a literal no-op, so the WARNs added for exactly this case ("'{owner}' does not own ws conn
{id} — handler NOT subscribed") were thrown away in the only place they mattered.

Switches it to the capturing logger and dumps the captured lines into the assertion. A refused
ownership gate now shows up in the failure text, which is the difference between "the conn was gone"
and "the socket went quiet" — the two remaining explanations for stage='sent'.

Also adds the changeset for the @s2script/cs2 type change from the shape fix: onTerminateRound's
view no longer carries _unused3/_unused4, which were typed `readonly number` and never readable.

* fix(core): the async id allocator is process-global — a thread_local one collided with the engines

ROOT CAUSE of the intermittent ws CI failures, reproduced locally on demand and proven by reverting.

NEXT_ASYNC_ID was a thread_local. libtest runs every #[test] on its OWN thread, so the counter
restarted at 1 for each test — while the threadpool, http, ws and net engines it hands ids to are
PROCESS-global and carried on across all of them.

So a threadpool completion from an earlier test, arriving during a later one, found the later test's
resolver under the same id, removed it, and resolved it with `undefined`. JS then got
`WebSocket.connect(...).then(id => ...)` with id === undefined, the prelude built a socket wrapper
closing over it, and every subsequent native saw conn 0:

    WARN: __s2_ws_on: 'wsclose' does not own ws conn 0 — 'message' handler NOT subscribed
    WARN: __s2_ws_send: 'wsclose' does not own ws conn 0 — the message was NOT sent

which is exactly the observed symptom: connect resolves, nothing is subscribed, nothing is sent, and
the test waits out its full budget for an echo that was never asked for.

The threadpool loop's own comment states the intended rule — "a stale id from a prior isolate has no
entry and skips this". A resettable counter is what made it false. An id space must be at least as
global as the registries it indexes.

Production was never exposed (one process, one main thread, counter never resets), but the hazard is
real for any future re-init on a fresh thread, which is precisely what the test harness was.

Reproduction, before and after, under 12 busy-loop processes on 16 cores:
  thread_local : 4 of 6 full-suite runs FAILED, each burning the full 30s poll budget (36.7s)
  process-wide : 6 of 6 passed, 6.3s
Reverting just this hunk restores the failures (2 of 3), which is what pins it as the cause rather
than a coincidence.
G
Gabriel Hirakawa committed
952c41c90e1cd68db081dcf055e7ec525a5e10e1
Parent: 5a87b22
Committed by GitHub <noreply@github.com> on 8/3/2026, 7:45:05 PM