# SDKHooks

Per-entity hooks, SourceMod-shaped. Subscribe with `SDKHook(entity, type, callback)` and drop one with `SDKUnhook`. Shipped types include `OnTakeDamage` / `OnTakeDamagePost`, `SetTransmit`, lifecycle virtuals (`Spawn`, `Think`, `Use`, …), and `Weapon*` / `Reload`. There is also a named public `OnTakeDamage` ([lifecycle](https://s2script.com/docs/concepts/lifecycle)); `SDKHook` is the per-entity form. `Scope.entities.onDamage` and public `Damage.onPre` are gone.

```ts
import { SDKHook, SDKHookType, Entity, HookResult } from '@s2script/sdk';
import type { EntityRef, DamageInfo, Client } from '@s2script/sdk';

export function OnPluginStart(): void {
	for (const pawn of Entity.findByClass('player')) {
		SDKHook(pawn, SDKHookType.OnTakeDamage, onTakeDamage);
	}
	for (const prop of Entity.findByClass('prop_dynamic')) {
		SDKHook(prop, SDKHookType.SetTransmit, onSetTransmit);
	}
}
export function OnEntityCreated(entity: EntityRef | null, className: string): void {
	if (!entity) return;
	if (className === 'player') SDKHook(entity, SDKHookType.OnTakeDamage, onTakeDamage);
	if (className === 'prop_dynamic') SDKHook(entity, SDKHookType.SetTransmit, onSetTransmit);
}
function onTakeDamage(info: DamageInfo) {
	info.damage /= 2;
}
function onSetTransmit(_entity: EntityRef, client: Client) {
	if (client.isBot) return HookResult.Handled;
}
```

`SDKHook` is **not** load-window-only. Call it from `OnPluginStart` (entities already live), `OnEntityCreated`, `OnClientPutInServer`, or any time you hold a live `EntityRef`. Pair `Entity.findByClass` at start with `OnEntityCreated` so you cover both.

Import from `@s2script/sdk/sdkhooks` or the barrel `@s2script/sdk`. `DamageInfo` stays on `@s2script/sdk/damage`. `TraceAttackInfo` is on `@s2script/sdk/sdkhooks`. `HookResult` is on `@s2script/sdk/events` (also re-exported from the barrel). The SetTransmit viewer argument is a [`Client`](https://s2script.com/docs/api/modules/clients), not a raw slot.

## OnTakeDamage

Handlers mutate `info: DamageInfo` in place (`info.damage /= 2`). No return needed. `DamageInfo` is a **borrowed** view of the live `CTakeDamageInfo`: reading or writing it after the synchronous callback — including after `await` — throws `"expired borrowed view"`. Do not stash it. Valid on both pre and post; assigning `info.damage` only takes effect on pre. A non-finite `damage` write is refused (the prior value stays).

On CS2, `SDKHook` `OnTakeDamage` / `OnTakeDamagePost` run on the selected game package's damage function (trusted `takeDamageOld` via `legacy.damage.v1`). Public `SDKHook` signatures and `DamageInfo` fields are unchanged. Without a game package that provides damage, `SDKHook` for these types returns `false`. The wrapper runs for every subscriber and filters by victim in JavaScript; nested damage skips the plugin that caused it.

Optional `HookResult` (from `@s2script/sdk/events`):

- `Handled` zeroes the hit after every handler ran. Later hooks on that entity still run.
- `Stop` zeroes the hit and skips later hooks.

`Handled` does **not** skip later hooks. Multiple hooks on the same entity+type all run, in subscribe order. Either result still lets the engine damage function run — the block is the zeroed amount, not skipping the original.

Demos hook `"player"` — the pawn that takes bullet damage. Hooking every classname is `OnEntityCreated` with no filter; that is allowed, not required.

### OnTakeDamagePost

Same `CBaseEntity_TakeDamageOld` mux as `OnTakeDamage` (CS2 package `takeDamageOld` / `legacy.damage.v1`). Pre still runs before the original; post runs after with the info pointer still live. Public `OnTakeDamage` / SDKHooks behavior is unchanged. The post view is borrowed the same way — it expires when the synchronous callback returns.

```ts
SDKHook(entity, SDKHookType.OnTakeDamagePost, (info: DamageInfo) => void)
```

Return is ignored. `Handled` on the post-hook does **not** zero the hit (that is pre-only) and does not skip later post callbacks. `info.damage` assignment is frozen during post — reads still work, writes are ignored. A nested hit save/restores the current info/victim so an inner hit does not drop the outer post.

Operators with a `DispatchTraceAttack` hot-fix override must re-derive it under `CBaseEntity_TakeDamageOld` (no alias) in `addons/s2script/gamedata/cs2/custom/` — damage signature repair moved out of core gamedata. Do not deploy that recipe alone against an older runtime. Every operator repair is reported at boot.

Use post to observe the applied hit. Use pre to block or scale damage.

## SetTransmit

CS2 has no useful per-entity `CBaseEntity::SetTransmit` virtual; the engine path is the existing CheckTransmit POST hook (wiki `SDKHook_SetTransmit`).

```ts
SDKHook(entity, SDKHookType.SetTransmit, (entity, client) => HookResultValue | void)
```

Callback is `(entity, client)`. Omit return = Continue — the entity stays visible to that viewer (as far as this hook is concerned).

`Handled` / `Stop` hide this entity from this viewer by **clearing** that viewer's transmit bit. SetTransmit never **sets** a bit: it cannot show an entity the native mask already hid.

There is no `SetTransmitPost`.

### Handled vs Stop

- `Handled` — clear this viewer's bit. Later SetTransmit callbacks on that `(entity, viewer)` pair still run.
- `Stop` — clear this viewer's bit **and** skip later SetTransmit callbacks on that pair.

`Handled` does **not** skip later SetTransmit callbacks. Multiple hooks on the same entity all run, in subscribe order, until one returns `Stop`.

### AND-merge with `Transmit.setVisibleTo`

SetTransmit shares CheckTransmit with [`Transmit.setVisibleTo`](https://s2script.com/docs/api/modules/transmit). The mux runs **after** native `Transmit.setVisibleTo` bit clears. JavaScript only sees hooked entities whose bit is **still set**. Either API can hide; SetTransmit cannot un-hide a native mask clear.

| Native mask (`Transmit.setVisibleTo`) | SetTransmit result | Bit after mux                                             |
| ------------------------------------- | ------------------ | --------------------------------------------------------- |
| already clear                         | any / not called   | stays clear (no JS)                                       |
| still set                             | Continue           | stays set                                                 |
| still set                             | Handled            | cleared                                                   |
| still set                             | Stop               | cleared; later SetTransmit callbacks on that pair skipped |

An empty SetTransmit table means **zero JS** on CheckTransmit. Today's cost is unchanged when only native Transmit rules exist.

Fail-open: a core panic (or a missing `Client` constructor) is Continue — do not hide.

### When to use which

|                            | `Transmit.setVisibleTo`                                                                                   | `SDKHookType.SetTransmit`                          |
| -------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| Shape                      | Native mask: this entity is transmitted **only** to the given viewer slots (empty = hidden from everyone) | Per-viewer JS predicate each snapshot              |
| Hot path                   | Zero JS                                                                                                   | JS only for hooked entities whose bit is still set |
| Can hide                   | Yes                                                                                                       | Yes (`Handled` / `Stop`)                           |
| Can un-hide a native clear | No (AND-merge across plugins too)                                                                         | No                                                 |

Use the native mask when the viewer set is a list of slots you can write down. Use SetTransmit when visibility depends on live client state the mask cannot express (`client.isBot`, admin flags, round state, …). Combine them: a mask can narrow the set, then SetTransmit can hide further — never the other way around.

See the [transmit module](https://s2script.com/docs/api/modules/transmit) for `setVisibleTo` / `reset` / `resetAll`.

## Lifecycle virtuals

Registration is still `SDKHook(entity, SDKHookType.Think, callback)` / `SDKUnhook`. These are per-entity engine virtuals, not `OnEntitySpawned` / `onOutput` (those fire after `DispatchSpawn` and cannot skip the original `Spawn`).

### Spawn / Think

Callback `(entity)`. Pre: omit return = Continue. `Handled` / `Stop` skip the original virtual (`Stop` also skips later callbacks). `SpawnPost` / `ThinkPost` ignore the return.

Hook `Spawn` from `OnEntityCreated` — `OnEntitySpawned` is too late; the virtual already ran.

### PreThink / PostThink

This-only (`(entity)`). Return is ignored, including `PreThinkPost` / `PostThinkPost`.

### Use

Callback `(entity, activator, caller, type, value)`. `type` is `UseType` (`Off=0`, `On=1`, `Set=2`, `Toggle=3`). Pre SUPERCEDEs on `Handled` / `Stop`. `activator` / `caller` may be null. `UsePost` ignores the return.

```ts
import { UseType, HookResult } from '@s2script/sdk';
import type { EntityRef, UseTypeValue } from '@s2script/sdk';

function onUse(
	entity: EntityRef,
	activator: EntityRef | null,
	caller: EntityRef | null,
	type: UseTypeValue,
	value: number
) {
	if (type === UseType.Off) return HookResult.Handled;
}
```

### GetMaxHealth

Mutate `{ maxHealth }` in place. `Handled` / `Stop` SUPERCEDE with the new int.

```ts
import { HookResult } from '@s2script/sdk';

function onMaxHealth(info: { maxHealth: number }) {
	info.maxHealth = 200;
	return HookResult.Handled;
}
```

### ShouldCollide

Callback `(entity, collisionGroup, contentsMask, originalResult) => boolean`. Not `HookResult`. Last defined boolean wins; void keeps the original.

### VPhysicsUpdate / GroundEntChangedPost

`VPhysicsUpdate`, `VPhysicsUpdatePost`, and `GroundEntChangedPost` ignore the return.

### CanBeAutobalanced

Callback `(client, origRet) => boolean`. Last defined boolean wins. Hook the `EntityRef` you were given — not a slot number. The callback is skipped when there is no `Client` (never invent slot 0). `SDKHook` may still record if the instance has the virtual.

## Weapon*

`WeaponCanUse` / `WeaponCanUsePost`, `WeaponCanSwitchTo` / `WeaponCanSwitchToPost`, `WeaponDrop` / `WeaponDropPost`, `WeaponEquip` / `WeaponEquipPost`, `WeaponSwitch` / `WeaponSwitchPost`. Same `sdkhook_vp_add` path as the other virtuals.

```ts
SDKHook(entity, SDKHookType.WeaponDrop, (entity, weapon) => HookResultValue | void)
SDKHook(entity, SDKHookType.WeaponDropPost, (entity, weapon) => void)
```

Pre: omit return = Continue. `Handled` / `Stop` SUPERCEDE the original virtual (`Stop` also skips later callbacks). Post ignores the return.

CS2 gamedata for these VPs is still empty in the current dump — `SDKHook` returns `false` (does not throw) until a later RE slice fills `gamedata/sdkhooks/`.

## Reload

This-only pre like Spawn/Think: `(entity) => HookResultValue | void`. `Handled` / `Stop` SUPERCEDE the original; `Stop` truncates later callbacks. Missing VP → `false`. `ReloadPost` is not a this-void twin — wiki member, returns `false`.

## Typecheck-only

Wiki members so a port typechecks. No distinct CS2 function / thunk yet — `SDKHook` returns `false` and does not throw.

- `ReloadPost` — `(weapon, successful) => void`. Not a this-void VP twin.
- `OnTakeDamageAlive` / `OnTakeDamageAlivePost` — `DamageInfo`-shaped.
- `TraceAttack` / `TraceAttackPost` — `TraceAttackInfo` (`damage`, `damageType`, `ammoType`, `hitbox`, `hitgroup`, `attacker`, `inflictor`, `victim`). No distinct CS2 virtual yet (`OnTakeDamage` remains the `CBaseEntity_TakeDamageOld` mux).
- `FireBulletsPost` — `(entity, shots, weaponName) => void`. Often absent on the wiki.

## Registration

Hooks are books-gated per entity identity. Auto-unhooked on entity destroy and plugin unload. `SDKUnhook(entity, type, callback)` drops one matching entry early — callback identity is the function reference. You do not need to unhook in `OnEntityDestroyed` unless you want to drop a hook before the entity dies.

## Failures

`SDKHook` / `SDKUnhook` return `boolean`.

- `null` or a stale entity returns `false` and does not throw.
- A wiki name whose engine backing is missing or failed returns `false` and does not throw. Lifecycle gamedata rows start empty until the host self-resolves them against `libserver.so`. Weapon* / Reload CS2 rows are still empty in the current dump. `OnTakeDamage` / `OnTakeDamagePost` return `false` when no game package provides damage. `OnTakeDamageAlive`, `TraceAttack`, `FireBulletsPost`, and `ReloadPost` have no live path yet. A missing row skips that type without a boot FAIL. Do not copy SourceMod or CounterStrikeSharp vtable slot numbers.
- A string that is not a member of `SDKHookType` still throws.

See the [sdkhooks module](https://s2script.com/docs/api/modules/sdkhooks), [damage module](https://s2script.com/docs/api/modules/damage), and [transmit module](https://s2script.com/docs/api/modules/transmit).
