core: subscribe_into — own the subscribe-native core once (9 natives converted) (#59)
A4e from the core stabilization audit (#49). Stacked on #58.
SCOPE CHANGED FROM THE AUDIT, deliberately. A4e was specified as: consolidate
the 17 Channels statics into one keyed store with namespaced names, then
collapse ~19 subscribe natives into a generic __s2_hook_on/_off pair, then a
generic ffi dispatch entry. This builds the deduplication WITHOUT the
consolidation, because consolidation buys much less than it appears to:
1. A generic native still needs a per-family table. The subscribe natives are
not uniform wrappers — several fire an engine-op follow-up on the first
subscriber (event_subscribe, the usermsg bitmap via usermsg_hook_sub,
detour installs). A string-routed __s2_hook_on still needs a 19-row table
describing those: the same hand-maintained table, relocated.
2. It taxes the hot path. Namespaced keys (event:player_death) put a format!
allocation into every dispatch, including per-tick ones.
3. The duplication that actually exists is in the natives' BODIES, and can be
removed directly — no storage change, no C-ABI change, no live gate.
subscribe_into owns the core that 19 natives repeated verbatim: pull the handler
Local, root it as a Global, resolve the calling plugin from the context slot,
look up that plugin's generation (the reload-liveness token dispatch checks
later), allocate a subscription id, store the row. Returns
(sub_id, was_first_on_this_channel) — was_first being exactly what callers key
their engine-op follow-up on. The "legacy" owner fallback is preserved: a
subscription from a non-plugin context is still stored and still dispatches, it
simply never matches a plugin id for liveness or teardown.
Nine converted, each ~6 lines to 1: s2_client_subscribe, s2_damage_subscribe,
s2_chat_on_message, s2_client_command_listen, s2_map_start_subscribe,
s2_precache_subscribe, s2_cookie_on_cached, s2_cvar_on_change,
s2_event_subscribe.
PARTIAL ON PURPOSE. Six convertible natives are not converted (s2_ws_on,
s2_net_on, s2_usercmd_subscribe, s2_usermsg_on, s2_output_subscribe,
s2_entity_listener_on, s2_event_subscribe_pre): they interleave logic between
the steps — s2_usermsg_on resolves a canonical name and populates two side maps
before subscribing; s2_entity_listener_on installs a listener on first
subscribe. The conversion used an EXACT-MATCH transform, so anything not
matching the common shape verbatim was left alone rather than guessed at. Each
remaining one is an individual judgement about interleaved logic and belongs
where it can be read as such. Two others (s2_concommand, s2_topmenu_add_item)
share the generation lookup but are not Channels-backed at all.
Net -11 lines (48 insertions, 59 deletions), and the line count is not the
point: the helper carries ~35 lines of doc. The value is that owner/generation
resolution now lives in ONE place — that logic is on the reload-liveness path,
and copy-pasted 19 times is how it drifts.
cargo test -p s2script-core: 468 passed. CI=1 make ci: green. Behaviour-
identical, so the regression net is the existing per-capability tests. G
Gabriel Hirakawa committed
743d8aef21790e38458edeb0d2e9a38997b1ed0e
Parent: 2bea145
Committed by GitHub <noreply@github.com>
on 8/1/2026, 6:08:08 AM