ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
Some checks failed
CI / fuzz (push) Successful in 2m2s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 18s

This commit is contained in:
Kamal Tufekcic 2026-08-02 22:01:36 +03:00
commit 3410a79b6a
28 changed files with 30596 additions and 955 deletions

382
README.md
View file

@ -8,17 +8,16 @@ Here it takes **about half an hour, with nobody involved.** A timer notices the
```sh
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest # always the newest build
curl -fsSLO $R/gamedata-cs2.json # WHERE functions are — signatures + vtable offsets
curl -fsSLO $R/netvars-cs2.json # field offsets and types
curl -fsSLO $R/abi-cs2.json # HOW to call them — parameter and return types, re-judged per build
curl -fsSLO $R/rosetta-cs2.json
```
Also published: `bindings-<game>.json` (the callable surface the binary declares about itself — Pulse
bindings with a callable shim, entity IO, console commands, ConVars) and `manifest.json` (which build you
got). The
[artifacts section](#artifacts-schemas--output-formats) covers all of them.
**One file per game.** One record per function: where it is, what its machine code was measured to take, what
a declaration says it takes, what the binary declares may be done with it, and what it does in plain language
— plus the typed schema, and the surfaces that are not function-keyed (the Pulse registry, entity outputs,
classnames, ConVars). `manifest.json` says which build you got. The
[artifacts section](#artifacts-schemas--output-formats) covers the shape.
The output is framework-neutral; `source2rosetta-gen` renders it into whatever your stack speaks — the gamedata into your framework's locator format, and `abi-<game>.json` into **typed call sites** for the same functions. The deriver behind it is a standalone Rust tool — you only need that if you're self-hosting the pipeline or adding a game.
The output is framework-neutral; `source2rosetta-gen` renders it into whatever your stack speaks — one command writes both your framework's locator format and **typed call sites** for the same functions. The deriver behind it is a standalone Rust tool — you only need that if you're self-hosting the pipeline or adding a game.
## Docs
@ -33,10 +32,12 @@ Ballpark from a recent build, on a 16-core desktop. These move build-to-build
| | derived functions | declared surface | typed prototypes | typed schema | model | one-time distill |
|---|---|---|---|---|---|---|
| **CS2** | ~1,125 `core` + ~2,620 `high_confidence`, plus ~4,375 `experimental` name guesses | 580 Pulse bindings (127 host-callable), 784 commands, **1,551 ConVars**, 715 entity inputs / 226 outputs, 474 classnames | ~2,055 `verified` + ~80 `lower-bound`, 55 `mismatch` | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
| **Dota 2** | ~1,930 `core` + ~2,450 `high_confidence`, plus ~5,950 `experimental` | 500 Pulse bindings (99 host-callable), 855 commands, **1,170 ConVars**, 624 entity inputs / 187 outputs, 3,528 classnames | ~3,635 `verified` + ~100 `lower-bound`, 44 `mismatch` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
| **CS2** | ~1,125 `core` + ~2,865 `high_confidence`, plus ~4,375 `experimental` name guesses | **300 VScript bindings (246 located)**, 580 Pulse bindings (127 host-callable), 784 commands, 1,551 ConVars, 715 entity inputs / 226 outputs, 474 classnames | ~2,055 `verified` + ~80 `lower-bound`, 55 `mismatch`, 261 `return-only` | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
| **Dota 2** | ~1,100 `core` + ~4,045 `high_confidence`, plus ~5,740 `experimental` | **1,841 VScript bindings (1,599 located)**, 500 Pulse bindings (99 host-callable), 855 commands, 1,171 ConVars, 624 entity inputs / 187 outputs, 3,528 classnames | ~2,800 `verified` + ~99 `lower-bound`, 44 `mismatch`, 1,594 `return-only` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
**Declared surface** is what the binary states about itself, and it is a different kind of fact from the rest: no inference, no cross-build chaining, no confidence tier. The *host-callable* count is the subset of Pulse bindings invocable with an argument array alone — verified by calling each one on a live server of both games.
**Declared surface** is what the binary states about itself, and it is a different kind of fact from the rest: no inference, no cross-build chaining, no confidence tier. Two counts in it are subsets worth reading precisely. *Host-callable* is the Pulse bindings invocable with an argument array alone — verified by calling each on a live server of both games. *Located* is the VScript bindings whose implementation folds onto a function record as a real locator; the rest are documented but not addressable, and a C++ name registered at two addresses is dropped rather than guessed.
The VScript surface is the newest and it moves the `high_confidence` count more than anything else has: **+246 on CS2 and +1,599 on Dota**, every one a name Valve states in the binary alongside a declared return type. On Dota that is a 65% increase in the named surface, and it reaches gameplay verbs no other source in this project locates — `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget` and `ChangeTeam` are all absent from every tier of the previous release.
A full run live-validates what it ships and reports **0 dropped** on both games — for CS2 that is ~2,610 signatures and ~1,120 vtable offsets checked against a running server. Distilling the model is a one-time cost; after that each build's re-derive is minutes of compute, and the half hour in the headline is the whole loop: notice, update, derive, validate, publish.
@ -54,8 +55,8 @@ A CS2 update never rebuilds Dota, and vice versa. Two rules keep it honest: ever
| you want | use |
|---|---|
| the newest build, always | `…/releases/download/cs2-latest/gamedata-cs2.json` |
| a specific build, pinned | `…/releases/download/cs2-<buildid>-0/gamedata-cs2.json` |
| the newest build, always | `…/releases/download/cs2-latest/rosetta-cs2.json` |
| a specific build, pinned | `…/releases/download/cs2-<buildid>-0/rosetta-cs2.json` |
| to know what you got | `manifest.json` — carries `version = <game>-<buildid>-<patch>` |
Follow `-latest` to adopt updates as they land, or pin a buildid tag to adopt them deliberately; old snapshots stay up either way. Whichever you choose, **check the manifest's build id against the server you're actually running** before loading — that is what stops stale offsets meeting a changed binary. (`patch` counts rebuilds on the same binary, e.g. a merged contribution.)
@ -66,12 +67,14 @@ Follow `-latest` to adopt updates as they land, or pin a buildid tag to adopt th
The artifacts answer four different questions, and most useful work joins two or more of them:
- **`gamedata-<game>.json` — where the code is.** Every entry is a hook point or a call target: a byte signature or an RTTI vtable slot, tiered and, for `core`/`high_confidence`, checked against a running server.
- **`netvars-<game>.json` — what the state is.** Field offsets and types for every SchemaSystem class, plus the base graph, the enum tables and per-type sizes. This is the half that needs no hooking at all: a great deal of gameplay is readable and writable as plain memory.
- **`abi-<game>.json` — whether it is safe to call.** A declared prototype joined to the register footprint measured in *this* build, with a verdict per function. `verified` and `lower-bound` are callable; `mismatch` says the prototype in circulation is wrong for this binary.
- **`bindings-<game>.json` — what the binary declares about itself.** Console commands and ConVars with decoded flags, entity inputs and outputs, map classname → C++ class, and the typed Pulse registry with a callable entry point per binding.
- **`functions` — where the code is.** Every record is a hook point or a call target: a byte signature or an RTTI vtable slot, tiered and, for `core`/`high_confidence`, checked against a running server.
- **`schema` — what the state is.** Field offsets and types for every SchemaSystem class, plus the base graph, the enum tables and per-type sizes. This is the half that needs no hooking at all: a great deal of gameplay is readable and writable as plain memory.
- **`prototype`, on each record — whether it is safe to call.** A declared prototype joined to the register footprint measured in *this* build, with a verdict per function. `verified` and `lower-bound` are callable; `mismatch` says the prototype in circulation is wrong for this binary.
- **`bindings` and `surfaces` — what the binary declares about itself.** Console commands and ConVars with decoded flags, entity inputs and outputs, map classname → C++ class, the typed Pulse registry with a callable entry point per binding, and the VScript registry — the surface Valve exposes to Lua, each entry pairing a script-facing name with a C++ name, an English description and a declared return type.
Two of those are newer than the rest and worth calling out, because they change what a plugin can do:
Three of those are newer than the rest and worth calling out, because they change what a plugin can do:
**The VScript registry closes the biggest gap in the Dota surface.** 1,841 bindings on Dota and 300 on CS2, each pairing the name a script author types with the C++ name, Valve's own English description, and a declared return type — and 1,599 / 246 of them fold into the gamedata as real locators. It is the only source here that supplies gameplay VERBS on Dota: `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget`, `ChangeTeam`, `ModifyGold` and `AddExperience` are absent from every tier of the previous release and present now, which is why the [Dota section below](#dota-2) reads differently from how it did. They are script-facing wrappers rather than the underlying methods, and for a caller that is a feature: the wrapper's argument shape is the one Valve declared for a content author to use safely, and the wrapper is what the engine itself invokes.
**ConVars ship with their flags.** 1,551 on CS2 across four libraries, 781 in Dota's `libserver` — with `cheat`, `replicated`, `archive` and `notify` decoded, and the raw word beside them. The names are not the point: a consumer finds a convar by name at runtime with no gamedata at all. The *flags* are, because they are engine-declared authority. A host that wants to say "this module may change gameplay settings but not cheat-protected ones" can key that on what the engine itself declares instead of maintaining an allowlist by hand.
@ -95,7 +98,9 @@ What you do not get: `TryPlayerMove`, `WalkMove`, `Accelerate` and `TracePlayerB
#### Combat, damage and tracing
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two ABI notes: `CBaseEntity::DispatchTraceAttack` and `CBaseEntity::Event_Killed` are `verified` — and `Event_Killed` measures as the CS2-shaped `(CCSPlayerPawn*, CTakeDamageResult*)`, not the Source-1 `CTakeDamageInfo const&` everyone assumes — while `abi:CBaseEntity::TakeDamage` is tier `core` but verdict **`unverified`**: the declaration was never checked against this build. Build the struct by offsets and prefer the verified entry points.
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two ABI notes. `CBaseEntity::Event_Killed` is `verified` and measures as the CS2-shaped `(CCSPlayerPawn*, CTakeDamageResult*)`, not the Source-1 `CTakeDamageInfo const&` everyone assumes; `abi:CBaseEntity::TakeDamage` is tier `core` but verdict **`unverified`** — the declaration was never checked against this build. Build the struct by offsets and prefer the verified entry points.
**Do not use `CBaseEntity::DispatchTraceAttack`. Earlier revisions of this section recommended it, and it is mislocated** — the entry resolves to `CLogicRelay::Trigger`, which is a different function entirely. It is the clearest example in this file of why a locator that passes every check can still be wrong, so it is worth reading rather than just avoiding: its shipped pattern is a bare compiler prologue with no distinguishing content, so it is unique in the library by luck rather than by identity; the address holds real executable code, so live validation passed it; and `Trigger(hActivator, hCaller)` on a relay measures the same `int=3, ret=int` footprint as the declared `(CBaseEntity*, CTakeDamageInfo*, CTakeDamageResult*)`, so the ABI check called it `verified`. Three independent guards, none of which is an identity check. What caught it was **Valve's VScript registry naming that same address `Trigger`, with the description "Triggers the logic_relay"** — and the disassembly agreeing, every offset it touches being a named `CLogicRelay` field (`m_OnTrigger` at `+0x7a0`, `m_bDisabled`, `m_bPassthoughCaller`). Found 2026-08-01 by the [alias grouping](#functions--one-record-each), which is what made two sources' accounts of one address comparable at all.
Weapon rebalancing is entirely schema work. `CCSWeaponBaseVData` is 84 fields over 2,216 bytes: damage, headshot multiplier, armour ratio, penetration, range falloff, cycle time, seven per-stance inaccuracy terms, four recoil terms, the spray-recovery transition bullets, price, kill award — and `m_nRecoilSeed`/`m_nSpreadSeed`, the per-weapon RNG seeds that generate CS2's deterministic spray patterns. Reach it with `FindWeaponVDataByName` (`verified`); there is no member offset from a weapon entity to its VData.
@ -109,7 +114,7 @@ Two things make this domain unusually workable. The entire combat pipeline lands
`CCSPlayerPawn::m_pBot` is a typed `CCSBot*` on the pawn, and `CCSBot` is 140 typed fields over 24,088 bytes: current enemy, visible parts, goal position, path index, heard noise, panic and hurry timers, stuck state with a velocity ring buffer, radio timestamps, and the whole aim model (`m_lookPitch`/`m_lookYaw` with velocities, `m_aimError`, `m_aimFocus`, the reaction-queue indices). Every `CountdownTimer` is itself a schema class, so timers are readable *and* writable. A live bot-brain inspector, a custom difficulty curve, or an anti-stuck watchdog all need zero hooking.
The aim and behaviour functions — `UpdateLookAngles`, `UpdateReactionQueue`, `BendLineOfSight`, `FindMostDangerousThreat`, plus the direct verbs `SetBotEnemy`, `SetState`, `Panic`, `Blind`, `Retreat` — are `core` or `high_confidence` byte signatures, but **none has a declared prototype**: `abi-cs2.json` has no entry for any `CCSBot::` method. You get an address and a register count.
The aim and behaviour functions — `UpdateLookAngles`, `UpdateReactionQueue`, `BendLineOfSight`, `FindMostDangerousThreat`, plus the direct verbs `SetBotEnemy`, `SetState`, `Panic`, `Blind`, `Retreat` — are `core` or `high_confidence` byte signatures, but **none has a declared prototype**: not one `CCSBot::` method carries one. You get an address and a register count.
The state machine is more limited than it first looks. All 16 behaviour states appear as `core` entries and a `OnEnter=0 / OnUpdate=1 / OnExit=2 / GetName=3` slot convention is visible across them. But those offset-form entries carry no class binding — `IdleState::OnEnter`, `HideState::OnEnter` and `PickupHostageState::OnEnter` are all bare "slot 0" — so resolving a specific state's vtable needs a class pointer the artifacts do not supply. Eleven of the state methods (including `CCSBot::SetState` and eight `OnUpdate` slots) ship as byte signatures instead, and those you can resolve directly.
@ -141,7 +146,7 @@ The lowest-risk half of this domain needs no signatures: `env_shake`, `env_fade`
`UTIL_DispatchEffect`/`UTIL_DispatchEffectFilter` are `core`, `verified`, params-complete, and their one non-trivial argument — `CEffectData` — is fully mapped (20 fields, 112 bytes, SysV `memory`). That pairing is the ideal case these artifacts exist to produce.
Recipient filters are the gap in this domain, and several otherwise-attractive routes run through them. The filtered dispatchers (`UTIL_DispatchParticleEffectFilter_Position`/`_Attachment`, `UTIL_SayTextFilter`, `UTIL_SayText2Filter`, `SoundOpGameSystem::StartSoundEventString`) are real and verified — but the *only* recipient-filter symbol anywhere in `gamedata-cs2.json` is `CRecipientFilter::AddAllPlayers`, whose ABI entry is `unverified` with an empty parameter list. There is no `AddRecipient`, no per-team filter, no single-user filter. You get "dispatch to everyone" and a filter-shaped hole you must fill from your own framework. The genuinely per-client route that *is* covered is the `point_soundevent` entity: `StartSoundOnSingleClient` targets one player index and fires an `m_onSoundFinished` output when the sound ends.
Recipient filters are the gap in this domain, and several otherwise-attractive routes run through them. The filtered dispatchers (`UTIL_DispatchParticleEffectFilter_Position`/`_Attachment`, `UTIL_SayTextFilter`, `UTIL_SayText2Filter`, `SoundOpGameSystem::StartSoundEventString`) are real and verified — but the *only* recipient-filter symbol anywhere in the catalogue is `CRecipientFilter::AddAllPlayers`, whose ABI entry is `unverified` with an empty parameter list. There is no `AddRecipient`, no per-team filter, no single-user filter. You get "dispatch to everyone" and a filter-shaped hole you must fill from your own framework. The genuinely per-client route that *is* covered is the `point_soundevent` entity: `StartSoundOnSingleClient` targets one player index and fires an `m_onSoundFinished` output when the sound ends.
The sound-operator system itself is unusually complete — start-by-string, the 11-argument raw start, set-param-string, and stop-with-filter are all `core` with `verified`, params-complete prototypes from a single provenance. The exception is flagged loudly: `SoundOpGameSystem::StopSoundEvent` is verdict `mismatch` (measured footprint exceeds declared), so use `StopSoundEventFilter`.
@ -167,7 +172,9 @@ Because command flags are decoded, the client-reachable attack surface is exactl
Dota's surface is materially larger, and the difference is structural rather than incidental: **3,528 registered entity classnames against CS2's 474**, and 2,958 schema classes / 17,668 fields against 1,899 / 12,330. The reason is that in Dota every ability and every item is a networked entity with its own class — 2,155 `CDOTA_Ability*` classnames (795 of them `special_bonus_*` talents, 1,360 regular abilities), 660 `CDOTA_Item*`, 231 unit types, 130 heroes. What that buys is identification: given any script name a mod author types, you get the exact C++ class. What it does not buy is per-ability hooking — only a minority of those classes carry fields or functions of their own; the shared bases (`CDOTABaseAbility` 54 fields, `CDOTA_Item` 63, `CDOTA_BaseNPC` 269) are where the data lives.
The shape of Dota's coverage is also different from CS2's. Its `core` tier is bigger but narrower: 919 `CModifierFactory<…>` entries and several hundred game-system factories account for most of it. The classic gameplay verbs a Dota modder expects — cast, apply damage, issue an order, add a modifier — are **not in it**. Dota's strength here is observation and schema; CS2's is invocation.
The shape of Dota's coverage is also different from CS2's. Its `core` tier is narrow: 919 `CModifierFactory<…>` entries and several hundred game-system factories account for most of it, and the classic gameplay verbs a Dota modder expects are not in *that* tier.
**They are in `high_confidence`, via the VScript registry, and that is recent.** `AddNewModifier`, `CastAbilityOnTarget`, `AddItemByName`, `ChangeTeam`, `ModifyGold`, `AddExperience` — cast, apply, give, pay, add a modifier — all fold as located names with a Valve-declared return. Earlier releases of this file said Dota's strength was observation and CS2's was invocation; that is no longer the split. What separates them now is that CS2's verbs are mostly native methods while Dota's are mostly script wrappers, which mainly changes which argument shapes you get for free.
#### Custom game rules
@ -181,7 +188,9 @@ The shape of Dota's coverage is also different from CS2's. Its `core` tier is bi
The vocabulary is complete too: `modifierfunction` has all 409 `MODIFIER_PROPERTY_*`/`MODIFIER_EVENT_*` values, `modifierstate` all 65 states, and `CDOTA_BaseNPC::m_nUnitState64` is a `uint64` — one read decodes stunned/silenced/rooted/hexed/disarmed/magic-immune for any unit. `CDOTA_Buff` is 38 fields including `m_hScriptScope`, the handle back into the Lua object.
The wall: there is no `AddNewModifier` or `RemoveModifierByName` at any tier, and `CDOTA_ModifierManager` exposes only 7 of its 904 bytes — no vector of active buffs. You can hook creation and read a buff you hold; you cannot enumerate a unit's modifiers or apply one, except through the debug command `dota_modifier_test <entityindex> <modifiername> <duration>`, whose handler is `verified`.
The wall used to be that nothing could APPLY one. **The VScript registry breaks it:** `AddNewModifier` folds as a `high_confidence` locator with a Valve-declared return, alongside the rest of the script-facing verb set — `AddItem`, `AddItemByName`, `CastAbilityOnTarget`, `CastAbilityNoTarget`, `ChangeTeam`, `AddSpeechBubble`. None of these existed at any tier before it landed. They are the script WRAPPERS rather than the underlying C++ methods, which is the right target anyway: their argument shapes are the ones Valve declared for a content author to call safely.
What is still missing is the read side of the same surface. `RemoveModifierByName` is not in the registry either, and `CDOTA_ModifierManager` still exposes only 7 of its 904 bytes — no vector of active buffs. So you can hook creation, read a buff you hold, and now apply one; you still cannot enumerate a unit's modifiers, except through the debug command `dota_modifier_test <entityindex> <modifiername> <duration>`, whose handler is `verified`.
#### Match telemetry
@ -189,7 +198,7 @@ The wall: there is no `AddNewModifier` or `RemoveModifierByName` at any tier, an
The engine also publishes things people normally reconstruct from replays: `m_fCreepDistanceSafe`/`Mid`/`Off` are per-team lane-equilibrium floats refreshed on their own timer, `m_flAvailableLaneGold` is a lane gold pool, and `CDOTAGameRules::m_hEnemyCreepsInBase` is literally a handle vector of creeps in your base. `CDOTA_NeutralSpawner` records `m_iStackingCreditPlayerID` and a per-team `m_bSeenClearedByTeam`, so camp analytics needs no heuristics. Every creep death carries `m_flTimeOfDeath`, `m_vWsKillOrigin` and `m_vWsKillDirection`.
Reading is the strong direction. There is **no gold or XP mutator** at any usable tier (the only match is `CDOTATurboGameMode::FilterModifyGold`, experimental), so a plugin writes the ledger or tunes the passive knobs.
Reading was the strong direction until the VScript registry landed; the economy is now writable. `ModifyGold`, `ModifyGoldFiltered` ("gives this hero some gold, using the gold filter"), `SetGold`, `SpendGold` and `AddExperience` are all located, as are the passive knobs — `SetGoldPerTick`, `SetGoldTickTime`, `SetStartingGold`, `SetLoseGoldOnDeath`, `SetMinimumGoldBounty` / `SetMaximumGoldBounty`. Better than a mutator, the FILTERS are exposed too: `SetModifyGoldFilter`, `SetModifyExperienceFilter` and `SetExecuteOrderFilter` install a script callback the engine consults, which is interception rather than a race with the engine's own bookkeeping. Only the experimental `CDOTATurboGameMode::FilterModifyGold` was reachable before.
#### Map, encounters and scripting
@ -274,6 +283,15 @@ ELF parsing is by hand. Three things make a stripped binary readable at all:
On top of that sits a whole-binary **cross-reference index**: for every referenced address, the instructions that reference it. That gives the string-anchored locator ("which function uses this string?"), which several later stages depend on.
**"Which function" is an attribution, and it is only as good as the entry list.** Nearest-entry-at-or-below is
how an instruction is credited to a function, and CS2 strips `.eh_frame` from the game code, so a
`[entry, next_entry)` range routinely spans a real function plus unindexed neighbours and inherits their
strings. Anchor RECOVERY — using an anchor to locate a function that no byte-signature found — therefore
applies the same test anchor DERIVATION does: every instruction that loads the string must fall inside the
candidate's own flow-reachable code, walked from its entry. On CS2 that rejects 38 of 476 recoveries; on
Dota, whose FDEs span the whole `.text`, it rejects 4. The asymmetry is the point — the test fires where the
attribution is genuinely weak and stays quiet where it is not.
All Source-2 `.so` files link at vaddr 0, so a runtime address is simply `load_base + file_vaddr`.
### 2. Derive from the binary's own reflection
@ -282,9 +300,9 @@ Valve compiles two reflection systems into every module, and both are read direc
**RTTI** gives the type hierarchy and vtable layout: which classes exist, what they inherit, and the ordering of every vtable. Where a slot is read *directly* — the fold's offset locators and the experimental band — a slot index is a fact, not a guess.
**SchemaSystem** gives class → field metadata. Be precise about what is static here: **name and offset are in the file; the TYPE is not.** A field's type pointer is a null placeholder on disk and is populated only at runtime, which is why typed netvars require a live process and why an offline run ships no `netvars-<game>.json` at all.
**SchemaSystem** gives class → field metadata. Be precise about what is static here: **name and offset are in the file; the TYPE is not.** A field's type pointer is a null placeholder on disk and is populated only at runtime, which is why the typed schema requires a live process and why an offline run states `"schema": null` instead.
### 3. Names Valve ships in the binary — three sources, and only two locate
### 3. Names Valve ships in the binary — four sources, and only three locate
Every Source-2 module names some of its own functions. This is ground truth from the shipped binary — not inference, not cross-game transfer — so it outranks every derived name, and it re-derives on every build with no input to maintain and nothing to bootstrap: the source travels with the binary.
@ -292,13 +310,26 @@ Every Source-2 module names some of its own functions. This is ground truth from
|---|---|---|
| **entity-IO datadesc** | the C++ handler name (`InputKill`) with the handler's address | **yes** |
| **console-command registration** | the command name (`bot_add`) with its callback | **yes** |
| **VScript binding registry** | a script-facing name (`TakeDamage`) with a C++ name, an English description, a return type and the implementation | **yes** |
| **Pulse binding registry** | a qualified `Class::Method` with display name, description, call policy, a full typed signature and an invocation shim | **not as a C++ symbol** |
**The VScript registry is the fourth, and it is the only one that states a RETURN TYPE.** Valve exposes a subset of the C++ surface to script — Lua in Dota's custom games, a smaller set in CS2 — and each exposed method is registered with a descriptor carrying both names, Valve's own prose, a `ScriptDataType_t` return and a pointer to the implementation. That is a locator, a prototype and documentation in one record.
It is not a table walk, for the same reason the Pulse signatures are not: **the descriptors are built at runtime and are zeroes on disk.** A scan of Dota's `libserver.so` finds 2,268,664 `R_X86_64_RELATIVE` relocations and not one points at a description string. What is static is the code that fills them in, so the same answer applies — constant-propagate the initialiser rather than read the table. Three distinct registration forms are recovered: a packed pair of name pointers, a single string duplicated when both names are the same, and a record base copied between registers mid-construction.
The return-type decoding is DERIVED rather than assumed. An early reading fitted two observations to Source 1's historical `FIELD_*` ordering and was wrong; joined against 389 bindings whose return type Valve's published dump states, `5` is `int` and `6` is `bool`. The raw word ships beside the decoding regardless.
**It is very nearly disjoint from everything else here, and the exceptions are measured rather than assumed.** On Dota not one of the 1,652 implementations shares an address with an existing catalogue entry, and no name is shared either — a `Script_TakeDamage` is a script-facing WRAPPER, a different function from the `TakeDamage` it wraps. **On CS2 twelve are not wrappers**: `SetAbsOrigin`, `SetAbsVelocity`, `ScriptSetAbsAngles`, `Script_SetModelScale` and eight others are bound straight to the native method, so they land on an address the catalogue already names. That is a fact about the two games' bindings rather than a defect — where a native signature is already script-callable, Valve binds it directly — and both names now ship, each [declaring the other as an alias](#functions--one-record-each) instead of appearing as two unrelated functions.
An earlier revision of this section generalised the Dota measurement to both games and said the surface was disjoint outright. It is not, and the alias field is how a consumer sees where.
A C++ name registered at more than one address is a different case and is still dropped rather than guessed (2 of 1,650 on Dota, 0 on CS2), the same rule the ambiguous datadesc handlers follow.
**Two of the Pulse record's three code pointers do not locate anything, and the mistake is instructive.** An early pass folded the pair at `+24`/`+32` as locators for a headline `+782` names. They are *descriptor accessors* — every CS2 `libserver` binding measures the same empty `int=0 float=0` footprint, and they disassemble to a lazy-init singleton returning a static vector. Folding them would have shipped `CBaseEntityAPI::GetAbsOrigin` pointing at a zero-argument accessor: a locator that resolves, passes live validation as executable code, and is still the wrong function.
**The third pointer, at `+72`, is a real entry point** — one per binding, never shared. It is not the bound C++ method and cannot be folded as one (that method's address is genuinely unrecoverable offline; the shim dispatches indirectly). It is a fixed-signature marshalling stub, and calling it *invokes the binding*. That is verified by doing it: `SetRenderAlpha` and `SetRenderColor` were called on a live CS2 server and moved the entity's `m_clrRender`, and a `SetRenderColor` on Dota set the RGB bytes while leaving the alpha byte untouched — proof that dispatch honours the binding's DECLARED `PulseValueType_t`, so a caller cannot smuggle a mistyped argument past it. Every eligible binding is re-checked on each derive by calling it with a sentinel handle (see [the standing oracles](#the-standing-oracles)).
So the honest yield from this table is **zero C++ symbol locators, a complete typed API surface, and a callable entry point per binding** — all of which ship in `bindings-<game>.json`, with `call.needs` stating what a host must supply for each.
So the honest yield from this table is **zero C++ symbol locators, a complete typed API surface, and a callable entry point per binding** — all of which ship under `surfaces.pulse`, with `call.needs` stating what a host must supply for each.
**The datadesc handlers are class-qualified** by joining each record's array against the SchemaSystem: a datadesc array also carries field descriptors, and a `(member, offset)` pair is something the schema states from an entirely different table, so the class whose schema satisfies every pair in the array owns it. 653 of 715 CS2 handlers qualify this way. This is what makes `InputEnable` — a distinct handler on 48 classes — nameable at all; unqualified names that resolve to several addresses are still **dropped, not guessed**. Note for consumers: qualification **renamed 343 shipped keys** (`InputActivateSkybox``CAmbientGeneric::InputActivateSkybox`).
@ -334,7 +365,7 @@ So each function's observable **SysV-AMD64 shape** (which argument registers are
The footprint is a deliberate **lower bound** — a callee that ignores an argument reads fewer registers than it is passed — and that is checked rather than asserted. Valve's entity-IO datadesc declares hundreds of independent handlers to one fixed prototype, and every one measures within it: **CS2 715/715, Dota 624/624**, on every derive.
Types cannot be recovered from a stripped binary, so `abi-<game>.json` joins *declared* prototypes to that measurement and judges each one. See [the manifest](#abi-gamejson--declared-prototypes-judged-against-this-build).
Types cannot be recovered from a stripped binary, so each record's `prototype` joins a *declared* prototype to that measurement and judges it. See [the verdicts](#prototype--declared-types-judged-against-this-build).
### 6. Validate — against a live server, not a spec
@ -362,9 +393,19 @@ The contract is blunt: **"degrades or stops loudly, never lies."** An entry live
| `core` | derived and, in a full run, live-validated — the load-bearing gamedata |
| `high_confidence` | names folded in as verified offsets/sigs — Valve's own in-binary sources (`valve-table` provenance, ground truth) first, then macOS ground-truth transfer, dictionary-exact, and gated extrapolation |
| `experimental` | the least-filtered band — every graded name guess, each with a **resolvable locator** but an **unverified name**. **Never live-validated.** |
| `unresolved` | catalogued but not confidently produced this build, with a closed-vocabulary reason (`sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`) and no locator |
| `unresolved` | catalogued but not confidently produced this build, with a closed-vocabulary reason (`sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`, `name-contradicted`) and no locator |
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just disappears.
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just
disappears, and that is now a checked property rather than a habit: after every pass, whatever the catalogue
named and no locator or flag accounts for is swept into `unresolved` with a reason naming which of the three
things went wrong. Measured on the current builds, `core + high_confidence + experimental + unresolved`
accounts for **1,601 of 1,601 CS2 catalogue names and 2,131 of 2,131 on Dota**.
The reason matters as much as the presence, because most of what the sweep catches was never a function:
86 CS2 names and 193 Dota ones carry only an `offset` variant (a raw member offset this tool does not
derive) or name no library to search — the catalogue is harvested from other people's dumps, and a dumper's
own JSON keys (`build_number`, `dwEntityList`, `attack`) arrive looking exactly like function names.
Reporting those as "functions we failed to locate" would trade one silence for a louder untruth.
**Naming is not derivation.** AI-assisted name extrapolation exists but is producer-side dev tooling, not part of the shipped deriver; every proposed name is gated on self-naming or ground-truth corroboration and then live-validated. A wrong *name* mislabels a real slot; it never touches the **offset**, which comes from RTTI.
@ -387,14 +428,20 @@ A `produce` run **aborts rather than publish** a collapsed artifact. Each surfac
| console commands | recovered-command count | the registrar is found by SHAPE, so a reworked constructor yields **zero** commands rather than wrong ones — correct, and invisible without this |
| host-callable Pulse shims | count of `call.needs == "args-only"` | the registry can read perfectly and every signature recover perfectly while a codegen change makes each shim appear to read another argument — retiring the one callable tier without failing anything |
| ConVars | recovered-convar count | found by a DIFFERENT test than commands — convergence of registrar wrappers on a shared core, not a sentinel in the callee — so it can collapse while commands keep working |
| VScript bindings | recovered-binding count | a THIRD identification test again: a record base computed by the initialiser's own `idx*5 << 4 + [class+0x28]`. The floor is set deliberately loose because the reader recovers three distinct registration forms, and losing any ONE of them would still clear a tight floor while quietly dropping a third of the surface |
| VScript owning classes | attributed-binding count, **full runs only** | the one floor an offline run skips rather than fails, because zero is correct there by construction — the class is live-only. It is separate from the row above because it fails in the opposite direction: that floor guards the offline READER against a Valve reshape, this one guards the LIVE WALK, and the walk breaking leaves every binding recovered, described and located with no class on any of them. `class` is what `gen`'s `moddota` format groups by, so the release would clear every other gate while both renderers emitted nothing |
| typed netvars | fraction of fields typed | a runtime type-layout reshape resolves every field "untyped" and would otherwise ship a typeless schema at exit 0 |
| schema enums | enum count | read by shape like the class table, so a reshape yields zero rather than wrong |
| live validation | pass rate, above a minimum sample | |
| schema classes | class count | the largest table, and the one every other schema claim rests on — the `schema` section itself, the entity-output and datadesc joins, the derived type layouts, and half the identity check's conjunction. Nothing else covers it: the enum floor passes at zero classes, and the live oracle's own class gate is SKIPPED below its minimum sample, which is the collapse range |
| derived functions | `core` + `high_confidence` count | the headline product, and the one place a pass-RATE gate cannot help: the live oracle scores entries that reached the gamedata document, and a signature that failed to resolve never enters it — so a derive emitting forty functions instead of four thousand passes at 100% |
| live validation | pass rate, above a minimum sample | covers the derived locators too, not only the Valve-table oracles: the stage judging the primary product used to print its tally and discard it |
### `validated` is three-valued
`true` / `false` / `null`, and `null` is not a synonym for failure. Three cases ship `null` legitimately: the library is not mapped in the vanilla server, the class is not a vtable class (an engine special or a carried member offset), or the entry has no locator to check. An **offline run ships the whole monolith `null`** — absence of validation, not failed validation. Treat `null` as "not checked here", never as "checked and passed".
**`true` means every locator the entry carries was checked, not the first one found.** An entry may hold a signature *and* a vtable offset — five CS2 `core` entries do — and each is a separate claim about the running server. Both are validated; the entry is dropped if either is confidently bad; and if any half went unchecked the whole entry degrades to `null` rather than letting the checked half vouch for the other. That last rule is the one that matters to a hooking consumer: those offsets ship into ModSharp's `VFuncs` and Metamod's `Offsets`, where a wrong slot is what crashes a plugin.
### The standing oracles
A dozen free, mostly two-sided checks run on **every** derive and are reported. Two-sided means the two halves are read from different places by readers that don't know about each other, so agreement is evidence and disagreement is a defect:
@ -408,15 +455,39 @@ A dozen free, mostly two-sided checks run on **every** derive and are reported.
- **Pulse element stride** — derived by consensus per image, unanimous across six libraries in both games.
- **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,912/1,912 Dota.
- **Pulse shim invocation** — the only *behavioural* oracle here: every binding the artifact calls `args-only` is invoked on the live server with a sentinel entity handle, which the engine's own resolve rejects before touching anything. CS2 **67/67 clean**. It verifies a claim the artifact makes rather than a value it reports, and it is safe to run in CI precisely because the sentinel path mutates nothing — every argument slot the measurement calls unused is passed as null, so a slot that is actually used faults, and a fault is caught and the thread restored.
- **Pulse descriptors, against the live ones** — the reconstruction check. A binding's typed signature is *constant-propagated out of an initialiser*, not read from data: the elements are written at runtime and are zeroes on disk. So the shipped `params` were, until this landed, an unverified inference. The oracle reads what the running server actually holds and compares: **383/383 on `libserver` and 155/155 on `libpulse_system`, with returns 139/139, zero disagreements.** The trick is that the regions are lazy-init singletons a normal match never populates — a standard game executes no Pulse graph — so the oracle *calls the accessor first*. Those are the same `+24`/`+32` accessors the fold refuses to treat as locators: nullary, `int=0`, body builds a static once. Worthless as locators, and exactly what makes this check possible.
- **field-gap size calibration**, the semantic call sweep, and a 500-iteration live fuzz.
One of these found a real defect on its first run: 34 of 715 handlers measured float arguments a `void(ptr, ref)` cannot have, which traced to the ABI reader treating a `call` as fall-through so a callee's *return* propagated backwards as a phantom argument.
**One of these is not like the others, and it is the newest: the IDENTITY check.** Every oracle above verifies that a locator *resolves* — that the address is real, the slot is real, the declaration matches the measurement. None of them asks whether it resolves to the **right function**, and a wrong locator passes all of them: the address holds real code, so live validation confirms it; the pattern is unique in its library, so the scan is clean; and if the impostor happens to take the same number of arguments, the ABI verdict reads `verified`.
So one check asks the other question, from two things the binary states about an address and a name that is a `Class::Method`:
- **Valve's VScript registry names the address something else.** Ground truth — the binary naming its own function.
- **The code operates on a different class.** A `CFoo::` method reaches its object through `this`, so every `this + N` it touches must satisfy `N < sizeof(CFoo)`, and the SchemaSystem states that size offline.
**Both must hold, and the conjunction is the whole design.** Either alone rejects good entries, measured rather than supposed: a dozen CS2 bindings are bound *straight* to the native method instead of through a script wrapper, so `SetAbsOrigin` and `CBaseEntity::SetAbsOrigin` legitimately share an address (as do `ScriptSetSize` and `CBaseModelEntity::SetCollisionBounds`, whose names do not even resemble each other); and separately, six entries reach past their class because their NAME carries the wrong prefix while the locator is fine — four `CPathMover::` entries that are really `CFuncMover` setters, two `CBasePlayerController::` that are really `CCSPlayerController`. All eight of those still ship.
Across the 3,988 CS2 entries that resolved before it ran, the conjunction fires **once**, and that one had shipped in a release: `CBaseEntity::DispatchTraceAttack` resolved to `CLogicRelay::Trigger`. It now ships as `name-contradicted` instead of as a locator. Because n=1, it refuses the entry rather than failing the release.
**It runs where the model LEARNS, not only where the artifact is written**, and that placement is the point. The same locate step feeds the incremental fold and the distill, so a check applied only at emit time would leave the model recording the impostor's fingerprint — and the strict fingerprint check would then *confirm* the wrong address on the next build. That is exactly how this entry survived: the model had learned the decoy, so the guard that should have caught it vouched for it instead.
That sentence described the design before it described the code. The fold-path call passed the library's FILE name to a map keyed by its SHORT one, so the lookup missed, the `?` returned, and the check silently passed on every call — while the line four below it applied the very normalisation the lookup omitted. It was dead on the one path the paragraph above says matters most, and the shipped model carried **eight** fingerprint observations plus a consensus ABI for `DispatchTraceAttack` — the wrong function's, `Trigger(hActivator, hCaller)`'s `int=3`. Re-distilling with the check live removes exactly that one name and nothing else.
Worth stating precisely, because the honest version is less dramatic than it sounds: the contamination was **latent, never active**. The emit-time check rejected the entry regardless of what the model held, so a derive against the contaminated model and one against the clean model produce byte-identical artifacts. What the dead guard cost was not a wrong locator today but a loaded one tomorrow — had the conjunction's other half ever drifted (Valve renames the binding, the class size moves), those eight observations were sitting ready to confirm the decoy as correct.
The `this`-tracking is deliberately conservative — a register stops holding `this` on any write that is not a move from another register already holding it, all caller-saved registers drop across a `call`, and a path merge keeps only what holds on both paths — so the error direction is a missed contradiction, never a false accusation.
Two of these have found real defects. The entity-IO ABI check, on its first run: 34 of 715 handlers measured float arguments a `void(ptr, ref)` cannot have, which traced to the ABI reader treating a `call` as fall-through so a callee's *return* propagated backwards as a phantom argument. And the descriptor oracle settled a question the multi-library duplicate check had been reporting with no way to resolve — for every binding it can read, the two libraries' accounts are identical and both match the live descriptor, so the reported disagreement is not in the parameter or return lists.
### What is NOT gated
Stated because "we check things" is worthless without a boundary. There is **no** floor on the validate-live drop rate, the live-fuzz fault rate, the schema class count, or the RTTI class / base-graph size. A regression in any of those is reported, not refused.
Stated because "we check things" is worthless without a boundary. There is **no** floor on the live-fuzz fault rate or the RTTI class / base-graph size. A regression in either is reported, not refused.
**One oracle is currently reporting.** The Pulse registry is keyed by qualified name, so a binding registered by several modules keeps one row, and every duplicate is compared against the row already present. On the current builds **CS2 flags 331 of 419 repeat registrations and Dota 271 of 359** as disagreeing — so for those names `bindings-<game>.json` carries one module's account of the signature, not a merged one. It is flagged on every run and is not yet resolved; if you consume `params`/`returns` for a multiply-registered binding, know that.
The list used to be longer. The validate-live drop rate, the schema class count and the derived function count were all ungated and are now floored — see the table above. They are called out here rather than quietly removed because the gap they left was structural rather than an oversight of three numbers: **every gate measured what was emitted, never what failed to be emitted**, so a collapsed derive scored 100% on a shrinking denominator.
**One oracle is currently reporting.** The Pulse registry is keyed by qualified name, so a binding registered by several modules keeps one row, and every duplicate is compared against the row already present. On the current builds **CS2 flags 331 of 419 repeat registrations and Dota 271 of 359** as disagreeing — so for those names `surfaces.pulse` carries one module's account of the signature, not a merged one. It is flagged on every run and is not yet resolved; if you consume `params`/`returns` for a multiply-registered binding, know that.
*Which* module's account it carries is now the highest-precedence one, matching the rule `GameProfile::libs` states and every sibling table in that fold already followed — the earlier library wins, with one exception: a later library that recovered a typed signature replaces an earlier one that did not, since precedence must not cost information. It previously kept the LAST registration read, so 224 of 580 CS2 rows shipped another module's account of a name libserver also registers — and, because the live shim and descriptor oracles run against the server image, the rows that got verified were not the rows that got shipped.
---
@ -444,7 +515,7 @@ cargo build --release -p source2rosetta-core # → ./target/release/source2roset
| `fold-model` | Roll an existing model forward by ONE build (`model N + build → N+1`), reading only the model and that one binary. The production update path (also a sidecar inside `produce`). |
| `integration-test` | Stand-alone CI live oracle: launch a vanilla server, populate it, and verify derived gamedata against it — schema oracle, a semantic ptrace CALL on a live pawn, and (with `--gamedata`) a full validate-live plus optional live fuzzing. |
| `backfill` | Give an extrapolated name a real cross-build timeline — resolve its string anchor in every corpus build, or chain a vtable slot through the model — and report history depth + consistency (how a guess graduates to first-class). |
| `classify-change` | `--prev`/`--new``skip` / `normal` / `shift` + the exact % of function bodies that changed, comparing with position-dependent bytes masked so a pure layout shift reads as unchanged. An **operator primitive** — nothing in the shipped pipeline invokes it; the poller dispatches a derive on any buildid change. |
| `classify-change` | `--prev`/`--new``skip` / `normal` / `shift` + the exact % of function bodies that changed, comparing with position-dependent bytes masked so a pure layout shift reads as unchanged. Enumerates functions over relocation code-pointers decoded call targets `.eh_frame` starts — the FDE list alone covers ~12% of a CS2 binary, since Valve strips unwind info from the game code and leaves it only for the statically-linked runtime tail. An **operator primitive** — nothing in the shipped pipeline invokes it; the poller dispatches a derive on any buildid change. |
| `filter-corpus` | Collapse runs of code-identical builds to one representative, label each transition `normal`/`shift`, and segment the timeline into toolchain eras. Writes an **advisory** selection manifest; the distill does not read it (see [corpus curation](#getting-the-corpus-only-to-bootstrap-a-model)). |
### Quickstart
@ -456,6 +527,8 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
./target/release/source2rosetta --game cs2 produce \
--seed mappings/seed-cs2.json \
--corpus-model model-cs2.json \
--prototypes mappings/prototypes.json \
--semantics mappings/semantics-cs2.json \
--target <build-dir> \
--out-dir out
@ -464,6 +537,8 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
./target/release/source2rosetta --game cs2 produce \
--seed mappings/seed-cs2.json \
--corpus-model model-cs2.json \
--prototypes mappings/prototypes.json \
--semantics mappings/semantics-cs2.json \
--target <build-dir> \
--game-dir <cs2-install> \
--out-dir out
@ -553,42 +628,104 @@ A full `produce` run writes a self-contained release set per game into `--out-di
| File | What it is | When |
|------|-----------|------|
| `gamedata-<game>.json` | The **monolith** — the tiered function catalogue (signatures + vtable offsets) with provenance and live-validation folded inline | always |
| `abi-<game>.json` | The **prototype manifest** — declared parameter/return types, each judged against the footprint measured in this build | always |
| `bindings-<game>.json` | The **declared callable surface** — what the binary says about itself: Pulse bindings (with a callable shim), entity IO, entity classnames, console commands, ConVars | always |
| `netvars-<game>.json` | The **typed schema** — every SchemaSystem class → field → offset/type, plus the base graph and type layouts | full (`--game-dir`) runs only |
| `rosetta-<game>.json` | **The release** — one record per function, plus the typed schema and the surfaces that are not function-keyed | always |
| `model-<game>.json` | The **per-game model** — the distilled facts derivation reads instead of the corpus | when the run folds an existing model |
| `manifest.json` | Volatile release metadata: `{ version, artifacts: [...] }` | always |
Wall-clock and other volatile metadata live only in `manifest.json`; the other artifacts carry no timestamp, so they are **byte-reproducible** — the same build in yields the same JSON out.
Wall-clock and other volatile metadata live only in `manifest.json`; the release artifact carries no
timestamp, so it is **byte-reproducible** — the same build in yields the same JSON out. Verified rather
than asserted: two consecutive derives over identical inputs produce byte-identical `rosetta-<game>.json`,
`model-<game>.json` and `manifest.json`.
### `gamedata-<game>.json` — the monolith
Reproducibility is a property that has to be defended at every tie-break, not only at the obvious ones. A
`HashMap` iterated to build an output is the usual culprit, but so is a *stable* sort on a key that repeats:
two libraries registering the same schema class, or one class registering a script name twice, leave the
survivor of the dedup decided by hash order. Those are sorted on a total key now.
### `rosetta-<game>.json`
```jsonc
{
"meta": { "game_key", "game", "source_build", "version",
"counts": { "core", "high_confidence", "experimental", "unresolved" } },
"core": { "<fn name>": <MonoEntry>, ... },
"high_confidence": { "<fn name>": <MonoEntry>, ... },
"experimental": { "<fn name>": <MonoEntry>, ... },
"unresolved": { "<fn name>": { "reason", "detail" }, ... }
"meta": { "game_key", "game", "source_build", "version", "counts", "alias_groups",
"aliased_names", "merged", "joined" },
"functions": { "<fn name>": <FunctionRecord>, ... },
"unresolved": { "<fn name>": { "reason", "detail" }, ... },
"schema": { "classes", "bases", "enums", "types", "meta" }, // null on an offline build
"surfaces": { "pulse", "entity_outputs", "entity_classes", "convars", "unjoined" }
}
```
A **`MonoEntry`** is a locator flattened to the top level, plus its grading and — where the address resolved offline — its measured `abi` footprint inline. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with `?` wildcards. By deriver convention an entry carries one or the other; the readers handle the rare both-present case deterministically.
**One record per function is the whole point.** Where a function is, what its machine code was measured to
take, what a declaration says it takes, what the binary declares may be done with it, and what it means are
five different KINDS of fact about one thing — separately derived, separately trustworthy, and useless apart.
A consumer answering "may I call this, and how" needed four files open at once to find out.
Two further keys appear where they were established, and both are part of locating rather than decoration:
What is NOT function-keyed stays whole under `surfaces`: a Pulse binding names no C++ function, an entity
output is a member rather than a method, a classname binds one name to another, and a ConVar is
configuration rather than code.
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone locates nothing, since it only means anything relative to a particular vtable. Taken from the class the derivation actually chained the offset through, never parsed out of the entry name: a base-declared method routinely sits in a derived class's vtable, so those are different facts and only the measured one locates.
- **`anchors`** — distinctive string literals the function references, each unique to it within its library. Not a third locator competing with the sig-XOR-offset pair, but a supplement with a *different failure mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that moves instructions. Emitted alongside the signature, never instead of it.
### `functions` — one record each
`reason` on an unresolved entry comes from a closed vocabulary: `sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`.
A **`FunctionRecord`** opens with its `tier` (`core` / `high_confidence` / `experimental`) and the locator
flattened to the top level. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a
non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with
`?` wildcards. By deriver convention an entry carries one or the other.
Console-command handlers ship under the key **`ConCommand::<name>`**. The prefix says what the entry *is* — the handler bound to that command — rather than claiming a C++ symbol; `ent_fire`'s real method name appears nowhere in the binary.
Two further locator keys appear where they were established:
### `abi-<game>.json` — declared prototypes, judged against this build
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone
locates nothing, since it only means anything relative to a particular vtable. Taken from the class the
derivation actually chained the offset through, never parsed out of the entry name: a base-declared method
routinely sits in a derived class's vtable, so those are different facts and only the measured one locates.
- **`anchors`** — distinctive string literals the function references, each unique to it within its library.
Not a third locator competing with the sig-XOR-offset pair, but a supplement with a *different failure
mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that
moves instructions. Emitted alongside the signature, never instead of it.
Gamedata says *where* a function is; it never says what it takes. Types cannot be recovered from a stripped binary, so they come from declarations — and a declaration must be checked before anything calls through it, because a stale one produces a call that resolves, validates, and loads the wrong registers.
Then, each labelled by where it came from:
| field | what it is |
|---|---|
| `measured` | the SysV argument footprint read out of THIS build's machine code — a lower bound, never an over-count |
| `validated` | the live-validation verdict, **three-valued** — see [below](#validated-is-three-valued) |
| `aliases` | the other shipped names for this same function |
| `provenance` | how the name and locator were arrived at, and at what confidence |
| `prototype` | the DECLARED parameter and return types, and this build's verdict on them — see [the verdicts](#prototype--declared-types-judged-against-this-build) |
| `bindings` | what the binary itself declares may be done with this function — see [below](#bindings--what-the-binary-declares) |
| `description` | what it does, in plain language, and whether that text is derived or generated |
**`aliases` — the other shipped names for the same function, and the one key that is about the RELEASE rather
than the locator.** Several names on one function is normal here and not a defect: the catalogue is assembled
from independent sources that spell the same function differently, so `CreateEntityByName`,
`UTIL::CreateEntityByName`, `CBaseEntity::CreateEntityByName` and `CGameEntitySystem::CreateEntityByName` are
four names on one address. Dropping three of them would break whichever spelling a given consumer's code
already uses, so all four ship and each one names the other three. On CS2 that is **80 functions covering 172
names, 4.3% of the resolved surface**; on Dota it is a single group, because that catalogue is far less of a
merge.
Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,987 is
exactly right about NAMES and describes **3,895 distinct functions**. And read it before hooking: two names on
one address detoured independently is one trampoline chain claimed twice.
Grouping is by locator identity across `core` + `high_confidence` — a shipped pattern is generated at the
resolved address and confirmed unique in its library, so an identical `(library, pattern)` pair cannot be two
functions, and a vtable entry keys on `(class, slot)`. **Two limits, both deliberate.** `experimental` is
excluded, because a shared target between two unverified guesses is not evidence of a shared meaning (that
band states the converse relation — one name guessed at several addresses — through `provenance.collision`).
And a bare `offset` with no `class` is never grouped: it names no vtable, so two of them carrying slot 3 are
not evidence of anything. Grouping those anyway would assert 1,080 CS2 names to be aliases of each other in 70
fictitious groups. So an entry with no `aliases` means *none was derivable*, which for a bare slot is not the
same as *none exists*.
Console-command handlers are keyed **`ConCommand::<name>`**. The prefix says what the entry *is* — the handler
bound to that command — rather than claiming a C++ symbol; `ent_fire`'s real method name appears nowhere in
the binary.
### `prototype` — declared types, judged against this build
A locator says *where* a function is; it never says what it takes. Types cannot be recovered from a stripped
binary, so they come from declarations — and a declaration must be checked before anything calls through it,
because a stale one produces a call that resolves, validates, and loads the wrong registers.
**The verdict is the product**, and there are six:
@ -601,41 +738,87 @@ Gamedata says *where* a function is; it never says what it takes. Types cannot b
| `unverified` | nothing to check it against |
| `ambiguous` | several signatures on offer and no measurement to separate them |
Types come from three places, and `provenance` says which: a source declaration; the engine's own **dispatch contract**; or, for a return with neither, the measured register class (written `ret=…` so it can never be mistaken for a declared type — the measured class is wrong about known-void functions roughly seven times in eight).
Types come from three places, and `provenance` says which: a source declaration; the engine's own **dispatch
contract**; or, for a return with neither, the measured register class (written `ret=…` so it can never be
mistaken for a declared type — the measured class is wrong about known-void functions roughly seven times in
eight).
**Two dispatch contracts exist**, and both are stronger than any header because nobody has to have written the function down for the way the engine invokes it to be known: an entity-IO handler is invoked through `void(CEntityInstance*, InputData_t&)`; a console-command handler through the command-context and command pair, plus a receiver where the registration dispatches through an object. Which of the three callback forms a command uses is recorded at the registration site, so the contract is keyed on it rather than assumed. A contract is judged as a **lower bound** — it describes how the function is *invoked*, so only an over-count refutes it.
**Two dispatch contracts exist**, and both are stronger than any header because nobody has to have written the
function down for the way the engine invokes it to be known: an entity-IO handler is invoked through
`void(CEntityInstance*, InputData_t&)`; a console-command handler through the command-context and command
pair, plus a receiver where the registration dispatches through an object. Which of the three callback forms a
command uses is recorded at the registration site, so the contract is keyed on it rather than assumed. A
contract is judged as a **lower bound** — it describes how the function is *invoked*, so only an over-count
refutes it.
In the artifact, parameters are spelled as **pointers** (`CCommandContext*`, `CCommand*`), not as the C++ reference types.
Parameters are spelled as **pointers** (`CCommandContext*`, `CCommand*`), not as the C++ reference types.
`vtable` is present exactly when the locator is a vtable offset that live validation confirmed: a slot is only
reachable through an object, so it states a receiver no measurement can see.
`source2rosetta-gen --abi … --format <framework>` turns this into **call sites**: typed C# fields for CounterStrikeSharp, a C++ typedef header for Metamod plugins, an `[AddressKey]` interface for ModSharp, and runtime type descriptors for Swiftly and Plugify. Both `verified` and `lower-bound` entries with a settled receiver are emitted, and every output marks the lower-bound ones.
### `bindings` — what the binary declares
### `bindings-<game>.json` — the declared callable surface
A LIST, because one function can be several — and because a `kind` tells a consumer which it got:
What the binary *says about itself*, as opposed to what the derivation *infers about it*. Kept out of the monolith deliberately: `gamedata` answers "where is this function", this answers "what may I do with it, and how". **Six sections:**
- `entity-input` — the map-facing input name this handler answers, and the class the datadesc join qualified.
- `command` — the console name, Valve's help text, decoded flags with the raw word beside them, and which of
the three callback forms the registration used.
- `vscript` — the script-facing name a content author types, the owning class, Valve's own description, and
the declared return type.
- `pulse` — bindings keyed by qualified `Class::Method`, each with Valve's display name and description, a decoded call policy (receiver kind, mutating, blocking) with the raw words beside it, the recovered typed signature, and — where the record carries one — a `shim` address plus a `call` block saying how to invoke it. `call.needs` is the field to read:
- `args-only` — callable with an argument array and nothing else; the other argument slots may be null. **Validated by calling every such binding on a live server of both games.**
- `output-sink` — it returns a value, and writes through a register-file object a host does not have. Read the state through `netvars-<game>.json` instead; the Pulse getters are redundant with schema fields.
- `pulse-context` — needs a live `CPulseExecCursor` or graph instance. Not host-callable.
- `other-slots` — reads an argument whose role is not established (the `CPulseCell_*` family, which are graph *node* implementations rather than API bindings). Not host-callable.
**A row is attached only when the record cannot contradict it.** A name is unique only within a module:
`AddOutput` is registered in three libraries as three different functions, and `cl_particles_dumplist` in two,
while the catalogue holds one entry under each name. Asserting every registration onto that one record would
be a claim about code it does not locate — so a row joins on matching library (or onto a vtable-located record,
which names no library to contradict), and the rest are stated under `surfaces.unjoined`.
`call.reads` carries the measured slot list beside the decoding, so a build that changes the contract is re-readable rather than silently mis-labelled.
- `entity_inputs` — the map-facing input name, the C++ handler, its owning class where the schema join qualified it, and the handler's **address** (these also ship as gamedata).
- `entity_outputs` — the events an entity fires and where the subscriber list lives on the instance.
- `entity_classes` — map classname → the C++ class it constructs (`func_door``CBaseDoor`). Names to names, no addresses.
- `commands` — console commands with description, decoded flags, the raw flags word, the callback form, the measured ABI shape and the handler **address** (these also ship as gamedata).
- `convars` — the configuration half of the console surface: name, help text, decoded flags, the raw flags word, and the ConVar object's address. Emitted for the **metadata**, not as a locator — a consumer finds a convar by name at runtime with no gamedata at all, so the name alone would add nothing. The flags are the payload: `cheat`, `replicated`, `archive`, `notify` are engine-*declared* authority, which is what lets a host decide what a plugin may change without a hand-maintained allowlist. Decoded by a **convar-specific** bit table, not the command one — see [Known limitations](#known-limitations).
**`descriptor` on a Pulse binding is NOT a locator** — see [above](#3-names-valve-ships-in-the-binary--three-sources-and-only-two-locate). Any Pulse count is a count of *registrations*, not distinct bindings.
### `netvars-<game>.json` — the typed schema
### `schema` — the typed schema, and it is LIVE-ONLY
Every SchemaSystem class → field → offset and type, plus two sections that are easy to miss and load-bearing:
`bases`, the class base graph, without which an inherited field is unresolvable; and `types`, per-type size
and SysV register class, needed to compute a by-value argument's register cost. Of a field's attributes,
`type` and `kind` are read from the **live process**; `size` and the name hash are derived offline.
- `bases` — the class base graph. Without it an inherited field is unresolvable.
- `types` — per-type size and SysV register class, needed to compute a by-value argument's register cost.
**An offline build states `"schema": null`** — an explicit null, not an absent key, because absence would be
indistinguishable from a build that resolved zero classes.
Of a field's attributes, `type` and `kind` are read from the **live process**; `size` and the name hash are derived offline.
### `surfaces` — what is not function-keyed
- `pulse` — bindings keyed by qualified `Class::Method`, each with Valve's display name and description, a
decoded call policy (receiver kind, mutating, blocking) with the raw words beside it, the recovered typed
signature, and — where the record carries one — a `shim` address plus a `call` block saying how to invoke
it. `call.needs` is the field to read:
- `args-only` — callable with an argument array and nothing else; the other argument slots may be null.
**Validated by calling every such binding on a live server of both games.**
- `output-sink` — it returns a value, and writes through a register-file object a host does not have. Read
the state through the `schema` section instead; the Pulse getters are redundant with schema fields.
- `pulse-context` — needs a live `CPulseExecCursor` or graph instance. Not host-callable.
- `other-slots` — reads an argument whose role is not established (the `CPulseCell_*` family, which are
graph *node* implementations rather than API bindings). Not host-callable.
`call.reads` carries the measured slot list beside the decoding, so a build that changes the contract is
re-readable rather than silently mis-labelled.
- `entity_outputs` — the events an entity fires and where the subscriber list lives on the instance.
- `entity_classes` — map classname → the C++ class it constructs (`func_door``CBaseDoor`). Names to names,
no addresses.
- `convars` — the configuration half of the console surface: name, help text, decoded flags, the raw flags
word, and the ConVar object's address. Emitted for the **metadata**, not as a locator — a consumer finds a
convar by name at runtime with no gamedata at all, so the name alone would add nothing. The flags are the
payload: `cheat`, `replicated`, `archive`, `notify` are engine-*declared* authority, which is what lets a
host decide what a plugin may change without a hand-maintained allowlist. Decoded by a **convar-specific**
bit table, not the command one — see [Known limitations](#known-limitations).
- `unjoined` — declared rows that belong to no function record here, split by kind, each keeping its own
`library` and `addr`. Two things put a row here and they differ in kind: **nothing located it** (the
implementation did not resolve, or an ambiguous handler name was dropped rather than guessed), or **the name
belongs to another module's function**, as above. Keeping them is what makes the fold lossless — every row
the deriver read is either on the function it describes or stated here.
`unresolved` sits beside `functions` rather than inside it: those names have no locator, so they are not
function records. `reason` comes from a closed vocabulary: `sig-drifted`, `offset-low-conf`, `unresolved`,
`abi-drift`, `name-contradicted`.
**`name-contradicted` is the newest and means something different from the rest.** The others say a locator was
not *found*; this one says a locator **was** found and the binary proves it is a different function, so it was
refused. The entry needs a new locator rather than another build — see [the identity check](#the-standing-oracles).
### `model-<game>.json` — the per-game model
@ -643,16 +826,43 @@ The distilled facts derivation reads instead of the corpus. Not a consumer artif
### Rendering — the `gen` binary
`source2rosetta-gen` takes the monolith (`--from`), the schema (`--netvars`) or the manifest (`--abi`) and renders the matching format. The **input** chooses what is rendered; the `--format` id chooses for whom.
`source2rosetta-gen --from rosetta-<game>.json --format <who-it-is-for> --out <dir>` writes the files that
consumer reads. A framework gets **two**: the gamedata its loader resolves through, and the typed call sites
that go through it — `cssharp`, `metamod` (also SourceMod's VDF), `modsharp`, `swiftly`, `plugify`. Beside
them, `cs-sdk` / `netvars` render the schema, and `moddota` renders the VScript surface for the Dota
custom-game ecosystem — both shapes it publishes, `api.json` and `api.d.ts` — grouping members by owning class
with Valve's own description as the doc comment.
Every published artifact renders all nine, because a release is always derived against a running server. It
is only a **locally derived OFFLINE artifact** that renders fewer: the schema is live-only and so is the
owning class `moddota` groups by, so those three have nothing to work from and `gen` says so rather than
writing an empty file.
**The artifact states its own game and the output follows it**, so there is no `--game` flag. Two formats are
game-keyed and both key by the game DIRECTORY, which is what `meta.game_key` holds: Metamod's
`Games { csgo | dota }` section, which a plugin looks up by the engine's own `GetGameDir()`, and Plugify's
top-level key, matched against the `S2SDK_GAME_NAME` its plugin was built with. Two consumers cannot run on
Dota 2 at all — CounterStrikeSharp and Swiftly both resolve their binaries out of `<dir>/csgo/bin/` — and
`gen` declines those rather than write a file that can never load, with `--force` to override.
**Every call site carries its description into the generated source**, as a C# XML doc comment, a C++ header
comment, or a field in the data formats — so hovering `CBaseEntity_SetParent` in an editor says what it does
instead of only what it takes. Each one states **whose sentence it is**: Valve's own, read from a registry in
the binary, or this project's reading of the build. Valve's always wins where both exist, and a generated one
only ever fills a gap. On the rendered surface that is 2,073 CS2 and 2,326 Dota call sites, 717 / 774 of them
in Valve's own words.
Two things to know before you diff outputs:
- The default `cssharp` gamedata output is **JSONC** — it carries comment banners, so a strict JSON parser will reject it.
- The `swiftly` gamedata format emits **signature entries only**; vtable-offset entries are omitted (that is over a thousand entries on CS2), because that framework takes offsets through a separate file.
- The `model` format emits a tier-selected `Gamedata` with no `meta`, so its output cannot be fed back in via `--from`.
- The `modsharp` format now emits a **`refs`** block where one was derived — `refs.strings` for string anchors and `refs.vtable` for an offset entry's class, both ModSharp's own keys. An entry with anchors and no byte pattern is emitted as `refs`-only, which is how several of ModSharp's own hand-written entries are written; if you diff against an older render, those rows are additions rather than changes.
- The default `cssharp` gamedata output is **JSONC** — it carries comment banners, so a strict JSON parser
will reject it.
- The `swiftly` gamedata format emits **signature entries only**; vtable-offset entries are omitted (that is
over a thousand entries on CS2), because that framework takes offsets through a separate file.
- The `modsharp` format emits a **`refs`** block where one was derived — `refs.strings` for string anchors and
`refs.vtable` for an offset entry's class, both ModSharp's own keys. An entry with anchors and no byte
pattern is emitted as `refs`-only, which is how several of ModSharp's own hand-written entries are written.
---
See [source2rosetta-gen](crates/source2rosetta-core/README.md) for the full format table.
## Provenance
@ -673,7 +883,7 @@ This is a publishable tool, so where declarations and names come from is a hard
- **Linux x86-64 only.** SysV register classification, `/proc`-based validation and ptrace are all platform-specific.
- **Dota's core leans on the unverified fallback** — 69.8% of it, against CS2's 0.4%. Those entries are marked; treat the marker as real.
- **`experimental` is never live-validated.** Resolvable locator, unverified name.
- **Offline runs ship no netvars and no `validated` state**, because field types and validation both require a running process.
- **Offline runs state `"schema": null` and no `validated` verdicts**, because field types and validation both require a running process.
- **Two games cannot be produced concurrently** on one host (fixed server port).
- **The Pulse duplicate-registration disagreement is open** — see [What is NOT gated](#what-is-not-gated).
- **ConVar flag bits are decoded by a convar-specific table, and three bits are unnamed.** FCVAR is not one flag space across object types — decoding convars with the *command* table mislabels bit 0 on 185 Dota and 56 CS2 convars with a name Valve's own dumps give to none of them. The convar table was derived against both games' published dumps (1,939 convars pooled) keeping only bits that hold at 100% precision; bits 0, 1 and 2 are set often and match nothing cleanly, so they stay unnamed and survive in `flags_raw`.