# source2rosetta

**Current CS2 and Dota 2 gamedata — re-derived from every Valve build, proven on a live server, published automatically.**

When Valve ships an engine update, every Metamod / CounterStrikeSharp plugin breaks until someone hand-reverse-engineers fresh gamedata: function signatures, vtable offsets, netvar layouts. That has historically taken days, sometimes weeks.

Here it takes **about half an hour, with nobody involved.** A timer notices the new build, re-derives the whole surface from the stripped `.so` libraries the dedicated server maps, launches its own vanilla server and *calls the functions* to prove they resolve, then publishes to a fixed URL. No one is paged and nothing is hand-checked — and if any stage fails, the run stops and the previous release stays up. What ships is never a guess.

```sh
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest   # always the newest build
curl -fsSLO $R/rosetta-cs2.json
```

**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 — 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

- **[ATTRIBUTIONS.md](ATTRIBUTIONS.md) — start here.** This tool stands on a decade of community reverse-engineering, catalogues, dumpers, and research. The credits come first because the work does.
- **🎯 Render a release for your framework** → **[source2rosetta-gen](crates/source2rosetta-core/README.md)** — one command turns the JSON above into CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK. No build, no corpus; what most people are here for.
- [CONTRIBUTING.md](CONTRIBUTING.md) — add or back-fill a gamedata entry.
- [LICENSE](LICENSE) — AGPL-3.0.

## Results

Ballpark from a recent build, on a 16-core desktop. These move build-to-build — treat them as orders of magnitude, not guarantees.

| | derived functions | declared surface | typed prototypes | typed schema | model | one-time distill |
|---|---|---|---|---|---|---|
| **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. 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.

---

## Staying current — the part with no human in it

Each game runs its own loop, independently:

1. A timer polls Steam every 15 minutes, comparing the installed build id against the live one.
2. On a change it updates the install and runs a single `produce`: derive → live-validate → typed netvars → roll the model forward.
3. It publishes an immutable `<game>-<buildid>-<patch>` snapshot, then moves `<game>-latest` onto it.

A CS2 update never rebuilds Dota, and vice versa. Two rules keep it honest: every stage **hard-fails rather than substituting** an older or on-disk input, and every `core` / `high_confidence` entry that *can* be checked against the live process is checked before it ships. (A handful legitimately cannot — see [`validated` is three-valued](#validated-is-three-valued).) A failed run publishes nothing and leaves the previous release standing.

| you want | use |
|---|---|
| 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.)

---

## What can you build with this?

The artifacts answer four different questions, and most useful work joins two or more of them:

- **`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.

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.

**Most of the Pulse surface is callable.** Each binding carries a `shim` address and a `call.needs` verdict; the `args-only` tier — roughly 110 on CS2, 82 on Dota within `libserver` — is invocable with an argument array and nothing else, through Valve's own marshalling, which enforces the binding's declared types. Those are *actions* (teleport, ignite, change team, start a mover, spawn a template), which is the half no field write can do; reading state remains the schema's job and is better served there.

Tiers stay visible throughout. `core` and `high_confidence` are buildable today; the `experimental` band is fenced off at the end and is a different kind of thing entirely.

---

### CS2

#### Movement and player physics

The whole per-tick movement chain is individually hookable — `PhysicsSimulate` → `ProcessMovement` → `MoveInit` → `CheckParameters` → `PlayerMove` → `FullWalkMove` → `{Friction, AirMove/AirAccelerate, CategorizePosition, CheckVelocity, StartGravity, CheckFalling}` → `PostPlayerMove`. Every stage is `core` with a validated signature, and 22 ABI entries declare the `CMoveData*` they hand you. `AirAccelerate` resolves as `void(CCSPlayer_MovementServices*, CMoveData*, Vector&, float, float)`, `verified`, with a measured footprint of three integer and two float registers — so a surf/KZ/bhop server that rewrites air acceleration in flight is a detour and two float writes, not a reverse-engineering project. Watch two edges: `PreWalkMove`'s prototype is `unverified`, and `GroundAccelerate` has a validated signature but no declared prototype at all (footprint only).

Jumping splits into two independently hookable implementations — `CheckJumpButtonModern(CCSPlayerModernJump*, CMoveData*)` and `CheckJumpButtonLegacy(CCSPlayerLegacyJump*, CMoveData*)`, both `verified` — and both jump objects are fully laid out, down to sub-tick press and landing fractions. That plus `m_flAccumulatedJumpError` and `m_bHasWalkMovedSinceLastJump` is enough for either a legitimising autohop or a bhop-script detector built on press-phase distribution.

Surf detection does not need heuristics: `CCSPlayer_MovementServices::m_flTicksSinceLastSurfingDetected` is the engine's own signal, sitting next to the ground normal, surface friction and the surface-property token. Per-player speed control is `CCSPlayerPawn::GetPlayerMaxSpeed` (`verified`) plus `m_flMaxspeed`, `m_flStamina` and `m_flVelocityModifier`. Crouch work has a real predicate to hook — `CanUnduck` is `bool(CCSPlayer_MovementServices*, CMoveData*)`, so returning false pins a player crouched with full engine consistency rather than by poking a netvar. And the input side is intercepted at `CPlayer_MovementServices::RunCommand`, with `m_nButtons`, the fully enumerated 64-bit `InputBitMask_t`, the `uint32[64]` per-button last-press command numbers, and the four sub-tick move fractions all readable.

What you do not get: `TryPlayerMove`, `WalkMove`, `Accelerate` and `TracePlayerBBox` are all `unresolved` (`sig-drifted`) this build, and `CMoveData` itself is not a schema class — you get a correctly-typed pointer and no field offsets.

#### 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::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.

Tracing has one fully `verified`, params-complete entry point — `TraceShape(const void*, const Ray_t&, const Vector&, const Vector&, const CTraceFilter*, trace_t*)` — corroborated by two independent declarations. The catch is structural and worth planning for: `Ray_t`, `trace_t`, `CGameTrace` and `CTraceFilter` are not schema types, so you get the call shape and must supply the struct definitions. If you would rather not, `CPulseServerFuncs::GetTraceHit` is a fully typed ray cast with six named return values, and `DealDamage`/`DealRadiusDamage` sit beside it — a complete custom hitscan weapon with no native code.

For inventory and loadout rules, eight `CCSPlayer_WeaponServices` entry points carry `verified`, params-complete prototypes including `this` (`EquipWeapon`, `DropWeapon`, `SwitchWeapon`, `GetWeaponBySlot`, and the `CanEquip`/`CanSwitch`/`CanUse` predicates), and `CCSPlayer_ItemServices::CanAcquire` is a verified buy gate. Skins and StatTrak are pure offsets: the `CEconEntity` fallback block, `CAttributeContainer::m_Item` → a fully laid-out `CEconItemView`, and `CAttributeList::SetOrAddAttributeValueByName` (`verified`) to write.

Two things make this domain unusually workable. The entire combat pipeline lands in `core` and `high_confidence` — the experimental band contributes nothing to it. And `CCSScript_EntityScript` comes out as a contiguous named vtable run with a three-stage damage pipeline — `OnBeforePlayerDamage`, `OnModifyPlayerDamage`, `OnPlayerDamage` (veto / scale / observe) — which is exactly the shape a perk or RPG mod wants, though none of those slots has a typed prototype, only a measured register count.

#### Bots and the nav mesh

`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**: 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.

Nav work is better served from the sides than the middle. `CPulseServerFuncs::GetEntityNavMeshPosition` and `GetEntityHeightAboveNavMesh` are typed with no signature dependency at all; `CCSBot::m_playerTravelDistance` is a `float32[64]` of nav travel distance from that bot to every player slot, maintained by the engine every tick — real walk distances for free. Runtime map flow is `CFuncNavBlocker` with `m_nBlockedTeamNumber` (per-team blocking) driven by `BlockNav`/`UnblockNav`, whose handlers are `verified` and params-complete. The 44 `nav_*` editor commands survive in the dedicated server with verified `void(CCommandContext*, CCommand*)` handlers, including `nav_check_connectivity` — but nearly all of them carry the `cheat` flag, so a CI map-validation harness runs with `sv_cheats` on, not on a public server. There is no `CNavArea` or `CNavMesh` schema class: area-level data means calling engine functions with pointers you obtained from other engine functions.

#### Entities, spawning and map mechanics

The runtime-spawn chain is `core` and `verified` end to end: `CreateEntityByName` → `CEntityKeyValues::FindOrCreateKeyValues`/`SetString` → `CEntityKeyValues::AddConnectionDesc` → `CGameEntitySystem::DispatchSpawn`. `AddConnectionDesc`'s nine-parameter prototype maps field-for-field onto the schema struct `EntityIOConnectionData_t`, and its `targetType` argument is enumerated by `EntityIOTargetType_t` — prototype, struct layout and legal constants derived separately and agreeing. The 474-entry classname table (`prop_dynamic` → `CDynamicProp`, and the alias forms Valve registers separately) tells you what to pass. One hole: nothing here tells you how to *allocate* the `CEntityKeyValues` object.

Entity I/O is where one hook shape covers an enormous surface. 715 CS2 inputs are enumerated with class, handler symbol and address, 673 have a live signature, and the overwhelming majority carry the same `verified` prototype `void(CEntityInstance*, InputData_t&)` — so a single trampoline plus a dispatch table keyed by address is an IO firewall for community maps. Do check before assuming universality: about fifteen handlers declare something else (`CBaseFilter::InputTestActivator` takes a `CBaseEntity*`; `CGamePlayerEquip::InputTriggerForActivatedPlayer` takes an `InputData_t*`; the `CMathCounter` arithmetic inputs declare only `inputdata_t&`), and thirteen are verdict `unverified`. To fire rather than intercept, `CGameEntitySystem::AddEntityIOEvent` is `verified` with a full 10-parameter prototype including the delay float. The limit: `InputData_t` and `Variant_t` have no schema layout, so you get the hook point and not a payload decoder.

Trigger volumes have a single global arbiter — `CBaseTrigger::PassesTriggerFilters` is `verified` `bool(CBaseEntity*)` at vtable 270 — and a passive reader: `m_hTouchingEntities` is a live handle vector at a fixed offset. Collision surgery is netvar-only and complete: `VPhysicsCollisionAttribute_t` exposes `m_nInteractsAs`/`m_nInteractsWith`/`m_nInteractsExclude` as three `uint64` masks, which is exactly the knob a no-block plugin flips.

`func_mover` is a trap for anyone working from the IO graph: it has zero entity inputs and zero entity outputs, so an audit of that graph concludes it is inert. Its entire runtime surface is 44 typed `CFuncMoverAPI` Pulse methods plus 98 netvar fields. CS2's spline-mover system lives outside the classic IO graph.

#### Game rules, teams and economy

`CCSGameRules` is 189 fields over 70,720 bytes, reached by finding `cs_gamerules` and reading `CCSGameRulesProxy::m_pGameRules` — the bare `GameRulesPointer` signature is `unresolved` this build, so use the entity route. From there `RestartRound` and `GoToIntermission` are `verified`, `TerminateRound` is `lower-bound` with a delay/reason/reward-vector prototype, and warmup, phase and reset drivers are validated addresses with **no** declared prototype — a real gap in an otherwise strong domain. The round-end presentation block (winner, reason, message string, fun-fact token, mute flags) is all writable, and `m_iMatchStats_RoundResults[30]` with the two per-round alive-count arrays means a full competitive match history is readable from one pointer with no event subscription.

Money is a plain `int32`: `m_pInGameMoneyServices` → `m_iAccount`, with start/spent/next-round fields beside it and the whole team loss-bonus model on the rules object. Note there is **no per-player money function at any tier** — no `AddAccount`, no `GiveMoney` anywhere in the search — so an economy mod writes the ledger and replicates with `NetworkStateChanged` (`verified`; its sibling `StateChanged` is verdict `mismatch`, so use the right one).

Team moves have three independent paths — `CBaseEntity::ChangeTeam` at vtable 102, `CBasePlayerController::SwitchSteam` (`verified`), and the command path — gated by `CCSGameRules::WillTeamHaveRoomForPlayer` (`verified`). Two of the alternatives are weaker than they look: `CCSPlayerController::SwitchTeam` and `HandleCommandJoinTeam` are `unverified`, and `abi:CCSPlayerController::ChangeTeam` is `return-only` (no parameter list). Respawn waves are two arrays on the rules object (`m_TeamRespawnWaveTimes`, `m_flNextRespawnWave`), and spawn-point pools with round-robin cursors sit beside them — though the engine's *choice* of spawn point is not exposed as a hookable function. One inconsistency to resolve in-engine before relying on either: `CCSPlayerController::Respawn` and `CCSPlayerController::RoundRespawn` are both reported at vtable 272 while the base-class `RoundRespawn` is at 270 — two names cannot share one slot.

Stats need no events at all. `CSMatchStats_t` derives from `CSPerRoundStats_t` and already tracks `m_i1v1Count`/`m_i1v1Wins`, `m_i1v2*`, `m_iEntryCount`/`m_iEntryWins`, `m_iEnemy5Ks` through `m_iEnemy2Ks`, and shots fired vs shots on target — the clutch and entry numbers most stats plugins recompute from kill events.

#### Effects, sound and networking

The lowest-risk half of this domain needs no signatures: `env_shake`, `env_fade`, `env_beam`, `env_instructor_hint`, `env_fog_controller`, `env_tonemap_controller` and the render/glow block on `CBaseModelEntity` are all datadesc-sourced inputs plus exact schema offsets, with their enums (`ShakeCommand_t`, `ViewFadeMode_t`, `BeamType_t`, `RenderFx_t`) fully enumerated. `CParticleSystem` networks 64 entity-attached control points *and* 64 control-point names, so a beam tracking two moving players is state, not a call.

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

Both game-event stacks ship: the legacy `CGameEventManager` with fixed vtable getters, and the modern `CGameEventSystem` as a contiguous `verified` run (`PostEventAbstract` at 15, `PostEntityEventAbstract` at 17, register/unregister at 12/13). Bind to the concrete classes — the `IGameEventManager2::`/`IGameEventSystem::` interface aliases are all `unresolved`. Transport for custom messages is there (`CServerSideClient::SendNetMessage` is `core`/`verified`; the broadcast, channel and registry entries are `high_confidence`, several of them AI-derived names that passed live validation), but **no protobuf field layouts exist anywhere** — you get the pipe and the message id, never the payload shape.

Voice routing splits cleanly: `CServerSideClient::IsHearingClient` (`verified`, vtable 21) is the per-listener decision hook and `CLCMsg_VoiceData` (vtable 39) the inbound handler — both `core`. Everything that would let you touch the raw voice *stream* (`IsProximityHearingClient`, `SendVoiceData`, `ProcessVoiceData`) is experimental, i.e. a guess.

#### Performance, profiling and integrity

A real tick profiler is buildable because both halves are present: `IGameSystem::LoopPostInitAllSystems->pEventDispatcher` is the `core` anchor, and the payloads it delivers are schema-laid-out — `EventAdvanceTick_t::m_nTotalTicksThisFrame` tells you the server ran N ticks in one frame, `EventSetTime_t::m_flRenderFrameTimeUnbounded` is the pre-clamp cost that reveals a hitch. Bring your own monotonic clock: there is no `Plat_FloatTime` in the artifacts.

Per-entity think attribution is pure schema. `CBaseEntity::m_aThinkFunctions` is a `CUtlVector<thinkfunc_t>` and `thinkfunc_t` exposes the raw `m_think` function pointer, the `CUtlStringToken` naming the context, and next/last think ticks — so you can walk every entity, see which contexts are due this tick, and wrap only those. The same `m_think` pointer doubles as an integrity signal: it must land inside `libserver`'s text range.

That is one leg of a defensive integrity monitor for the server process an operator owns. The others: 628 `core` and 1,401 `high_confidence` signatures have a wildcard-free first eight bytes, which is exactly where an inline detour lands, so prologue snapshots detect another module hooking the engine underneath you; and 705 ABI entries carry an explicit vtable index, so named slots can be snapshotted and diffed. Every locator names its owning library, so "is this pointer still in the module that owns it" is answerable per key.

The console surface is the most mechanically reliable thing in the release. 755 of CS2's 784 registered commands carry a hookable `ConCommand::<name>` locator with a `verified` prototype — that is *all* commands, not a diagnostics subset, though the diagnostics family within it is broad (`stats_print`, `sv_packstats` with its `clear` argument, the `vprof_*` family, `mem_dump`, `net_stats_json`, `status_json`, `lrucache_stats`, `check_nofilefd`). Hooking is the reliable direction: the artifacts give you the callback address, not the `CCommand` layout needed to synthesize a call. `logaddress_add_http` ships log fan-out to an arbitrary URI with no sidecar, and tier0 carries a complete scripted-test harness (`Test_StartScript`, `Test_LoopForNumSeconds`, `Test_Checkpoint`, `Test_ExitProcess` with a chosen exit code) that is a CI rig Valve already wrote.

Because command flags are decoded, the client-reachable attack surface is exactly enumerable rather than folklore: **30 CS2 commands carry `client_can_execute`**, including `ent_setpos` and `ent_setang` — which move *arbitrary entities* — alongside `give`, `god`, `noclip`, `kill`, `explode`, `setpos_player`, `callvote` and `replay_start`. Exactly two carry `server_can_execute` (`echo`, `play`), which answers a question plugin authors argue about: the server cannot push arbitrary console commands to clients through the normal path. That static audit is solid, and it now extends to ConVars: their flags are decoded the same way, so `cheat`-guarded and `replicated` tunables are enumerable rather than assumed. Detecting a *runtime* change to any of those flags is a different matter — every cvar-registry accessor (`CCvar::GetConVarFlags`, `CCvar::FindCommand`, `CCvar::RegisterConCommand`) is experimental with a guessed name.

---

### Dota 2

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

`CDOTABaseGameMode` is 110 networked fields at exact offsets, and it is recognisably the Lua `GameRules:GetGameModeEntity():SetXxx()` API re-expressed as memory: fog of war, custom XP curves (`m_nCustomXPRequiredToReachNextLevel` is a networked int vector — replace the whole curve), respawn scaling, buyback rules, the attribute-to-stat coefficients (`m_flStrengthHP`, `m_flAgilityArmor`, `m_flIntelligenceSpellAmpPercent`), per-rune-type toggles as a `bool[10]` indexed by `DOTA_RUNES`, custom shops, ability-upgrade whitelists, HUD visibility bits, camera distance and min/max attack speed. Reach it via `CGameSystemReallocatingFactory<CGameRulesGameSystem,…>::GetStaticGameSystem` → `CDOTAGamerulesProxy::m_pGameRules` → `m_hGameModeEntity`. Four of these knobs also have typed Pulse setters that avoid raw writes.

`CDOTAGameRules` itself is 326 fields and reads like a design document: the Roshan respawn *phase machine* is explicit (`ERoshanSpawnPhase` = ALIVE / BASE_TIMER / VARIABLE_TIMER — the variable window is a modelled state), pause has per-player budgets, and there are three distinct kinds of night with separate timers and a `HeroID_t` attributing which hero caused it.

#### Modifiers, and the catalogue nobody else has

919 `CModifierFactory<…>` entries sit in `core` with live-validated byte signatures — 812 `::Create`, 71 `::Destroy`, 36 `::IsSameType`, covering 826 distinct modifier classes including the Lua-backed ones (`CDOTA_Modifier_Lua`, the three motion variants, `CDOTA_Modifier_ScriptedMotionController`). The class name is not inferred; it is the template argument of a symbol Valve shipped. That gives you a complete, name-accurate index of every shipped modifier implementation plus a per-class hook point. `Create` is nullary, so hook the *return*, not the arguments.

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

`DataTeamPlayer_t` is 96 fields and splits gold 23 ways — hero kill, creep, neutral, summon, bounty, Roshan, building, courier, ward kill, ability, deny, comeback, income, shared — matching the 23-value `EDOTA_ModifyGold_Reason` enum one for one, with the four spending buckets and `m_iGoldLostToDeath` beside them. `CDOTA_PlayerResource` adds `m_playerAbilityUpgradeOrder` as `AbilityID_t[25][24]` and `m_playerAbilityUpgradeTimes` alongside it — the complete skill build for every slot *with timestamps* — plus a 24×24 hero-damage matrix and a 24×24 assist matrix. This is replay-parser-grade data available live from schema offsets.

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

`CDOTA_ScriptedSpawner` has a 22-method Pulse API — `SpawnNPC`, `SetNPCType`, `SetCustomNPCName`, `SetHealth`, `SetInvulnerable`, `SetNPCWaypoint`, `UseAbility`, `SetAutomaticallyRespawn` — backed by an 18-field entity with three IO outputs (`m_OnAllUnitsKilled`, `m_OnUnitKilled`, `m_OnHealthLow`). Around it: `CDOTA_MapTree_API` cuts and regrows individual trees, `CDOTA_SimpleObstruction_API` toggles blockers whose schema separately controls FoW-blocking versus nav-blocking, `CDOTA_BaseNPC_API` issues movement orders and speech bubbles, `CDOTA_BaseNPC_Building_API` pushes invulnerability refcounts. That is a fairly complete undocumented encounter-scripting system, and the whole map's tree state is one `uint64[256]` bitfield — 16,384 trees in 2 KB.

The Pulse registry is self-documenting: all 500 Dota bindings are typed with parameter names and, for many, Valve's own English description read out of the binary. Note the tier is per-game, though — Dota's Pulse *runtime* entry points (`CPulseSystem::CreateInstance`, `CPulseGraphInstance::Unserialize`, `CPulseTypeManager::FindTypeByName`) are all experimental, where CS2 has `core` equivalents. The same applies to `CGameEntitySystem::AddEntityIOEvent`: `core` in CS2, experimental in Dota. Tiers are per-game, so check the tier in the artifact for the game you are targeting — a shared engine symbol can be first-class in one and a guess in the other.

`CLuaVM` comes out as a near-complete `IScriptVM` map — vtable slots 0 through 63, with `RegisterFunction`, `SetValue`, `CreateScope`, `LookupFunction`, `ExecuteFunction` and `RegisterScriptClass` all `verified` — so a native plugin can compile and run Lua into a live custom game, register C++ functions into the addon namespace, and redirect script output. Several slots carry an honest `mismatch` verdict (`GetRootTable`, `CreateTable`, `RegisterInstance`, `CScriptManager::CreateVM`): the slot index is good, the declaration is not. And `CBaseEntity_SharedAPI` exposes `RunScriptCode`, `CallScriptFunction` and `CallGlobalScriptFunction` as Pulse methods, so the two scripting systems bridge in both directions.

For test rigs, all 107 `dota_*` commands share one `verified` prototype and a validated handler address, so a single ~20-line detour shim covers the whole rules console surface. 67 Dota commands carry `client_can_execute` with no cheat bit — including `dota_create_unit`, `dota_create_item`, `dota_spawn_creeps`, `dota_spawn_neutrals` and `dota_treerespawn` — which is simultaneously a modding shortcut and a server-hardening checklist.

---

### What the schema and prototypes add

A flat offset dump cannot do any of the following, and each one is a real failure mode without it.

**`bases` makes inherited fields reachable at all.** `CCSPlayerPawn` declares 104 fields and *none* of them is health, team, life state or move type. Walking the base chain to `CEntityInstance` flattens it to 297 and puts `m_iHealth` at 1456, `m_iTeamNum` at 1572, `m_lifeState` at 1464. The same walk turns a map classname into a field table: `trigger_multiple` goes from 1 own field to 158 flattened.

**`bases` is also the only place multiple inheritance is expressed.** Treating a `CEconEntity` as `IHasAttributes` requires adding 3,136 bytes; for `CChicken` it is 3,728. In Dota, 16 ability classes carry a second base at +2144 — `CDOTA_Ability_Morphling_Waveform` and friends inherit `CHorizontalMotionController` there, `CDOTA_Ability_DataDriven` inherits `CDOTA_ActionRunner`. A naive `(Base*)ptr` cast at any of these sites corrupts memory silently.

**`enums` recovers field width, not just readability.** 812 CS2 fields report `size: 0`; the enum's own size is what makes them decodable. `CBaseEntity::m_MoveType`, `m_nPreviouslySetMoveType` and `m_nActualMoveType` sit at 1491/1492/1493 and are only three consecutive `u8`s because `MoveType_t` is one byte wide. Beyond that, 555 CS2 enums / 743 Dota give you the legal-value tables — damage-type bitmasks, hit groups, observer modes, and on Dota the entire gameplay vocabulary.

**`types` gives size and SysV class.** Size turns every generated accessor into a bounds check (12,330/12,330 CS2 fields pass). SysV class is what stops a struct-return call from corrupting the stack: a 12-byte `Vector` comes back in XMM registers (`sse`), a 48-byte `matrix3x4_t` through a hidden pointer (`memory`). That is what makes `CBaseEntity::GetEyePosition` callable correctly.

**A field's `name_hash` is stable across builds *and* across games.** 10,363 `Class::field` pairs exist in both artifacts; all 10,363 have identical hashes, and 2,589 of them sit at different offsets. So ship one hash-keyed table of the fields your plugin touches and bind offsets per build and per game at load. A hash that vanishes means a rename; a hash that moves means a rebind.

**A checked prototype is worth more than a declared one, and the verdict is the product.** `verified` (2,054 CS2 / 3,633 Dota) means declared arity matches the footprint measured in this build. `lower-bound` (82/99) means the declaration passes registers the callee never reads — compatible, but not the same claim. **`mismatch` (55/44) is the most immediately useful of the six**: it names community-circulated prototypes that are wrong for this binary and will load the wrong registers. `ambiguous` lists the surviving overloads for you to separate; `return-only` gives a return type and no arity claim; `unverified` means nothing checked it.

Two structural cross-checks come for free: all **226 CS2 entity outputs agree exactly with netvars** on class, member and byte offset, independently derived; and for all 759 CS2 commands present in both files, the dispatch form in `bindings` agrees with the prototype in `abi` — 674 `direct`, 81 `member` (extra leading `this`), 4 `interface`, zero disagreements. Hooking a member-form command with the free-function signature shifts every argument by one, and nothing in the command's name tells you which it is.

---

### The experimental band — read this before using any of it

`experimental` is 4,374 entries on CS2 and 5,947 on Dota, and it is a different kind of artifact from everything above.

**Resolvable locator. Unverified name. Never live-validated.** Every entry has `validated: null`, `corroboration: bare` (one source, nothing independently agreed) and `self_named: false`. What is real is the *locator* — an RTTI class plus vtable slot, or a byte signature — and the *measured register footprint*, which every entry carries. What is a guess is the label. 270 CS2 / 357 Dota entries carry `collision: true` (another guessed name resolved to the same target) and 42 / 150 carry `dead_weight: true` (the target is a stub).

The two games' bands are not the same product. CS2's is 3,230 vtable locators across 914 RTTI classes plus 1,144 byte signatures across 21 libraries — and **zero in `libserver`**. It is engine infrastructure: `CPhysicsBody`, `CVPhys2World`, `CEngineServer`, `CServerSideClient`, `CNetChan`, `CCvar`, `CSchemaSystem`. If the names are right, that is a whole telemetry, physics and cvar surface — `CNetChan::GetAvgLatency` at slot 11 measures `{int:1, ret=float}`, which is at least the shape of a `float GetX() const`. If they are wrong, you have called a numbered slot with the wrong idea of what it does. Anyone hunting there for an unnamed `CCSPlayerPawn` method will not find it.

Dota's band *does* reach gameplay: 1,402 byte signatures in `libserver`, roughly 350 of them DOTA-named — `CDOTAGameRules::KillCreeps`, `CDOTATurboGameMode::FilterModifyGold`, `CDOTA_Ability_*::OnSpellStart`. If those names are right it is a gold mine for custom-game work. Treat every one as a hypothesis.

One sub-band is self-checking, which makes it usable on different terms: the `CNetMessagePB<id, MessageType, (SignonGroup_t)g, …>` template instantiations bake a wire id, a protobuf class name, a signon group and a reliability flag into the mangled name. Unlike a bare `CFoo::Bar` guess, that is structured data you can falsify against live traffic in one command (`net_listallmessages`, `net_messageinfo`). Note that for Dota the *authoritative* message-id source is not this band at all — it is the schema enums `EDotaUserMessages`, `EBaseUserMessages` and `EDotaClientMessages`, which are deterministic. Use those for ids and the templates as corroboration.

The only defensible workflow for anything in this band: pick a candidate, check the measured footprint matches the semantics you expect, then confirm behaviour in-engine yourself before shipping.

---

### What is not covered

- **ConVars ship names, help and flags — but no defaults or ranges.** The default value is built in a stack structure at the registration site rather than passed as a literal, so it is not recoverable the way the rest is. `min`/`max` likewise. If you need the shipped default, read it off a running server.
- **No protobuf field layouts.** You get message ids and class names; you must supply the `.proto` definitions.
- **No game-event name tables.** The event *system* is there (post, register, the legacy bridge); the names (`player_death`, `dota_player_gained_level`) are not. On CS2 the practical substitutes are function-level equivalents and `logic_gameevent_listener`, which needs only a string.
- **No content names.** No `.vpcf` particle systems, no sound events, no model paths, no Dota KeyValues gameplay data (no ability special values, no hero base stats, no item costs).
- **Server-side only.** Neither game's artifacts contain a `client` library. No Panorama, no client prediction, no client-side anticheat surface.
- **Linux x86-64 only.** Every signature object carries exactly `{library, linux}`.
- **Some struct types are named but not laid out** — `CMoveData`, `CUserCmd`, `Ray_t`, `trace_t`, `InputData_t`, `Variant_t`, `EmitSound_t`, `SpawnGroup_t`. They appear in verified prototypes; you can pass pointers through them and cannot construct or inspect them from these files.
- **Bitfield netvars are unusable.** 52 CS2 / 122 Dota fields typed `bitfield:N` all report offset 0 and size 0 — the name is there, the location is not.
- **Nothing marks a field as networked.** A field entry is `{offset, type, kind, size, name_hash}`; there is no replicated/server-only distinction, so send-table-aware tooling is out of scope.

---

## How it works

Six stages: **read → derive → locate → measure → validate → emit.** The organising distinction, which everything else hangs off:

> **Some sources LOCATE a function. Others only DOCUMENT it.**
> A source that pairs a name with an address gives you a locator. A source that pairs a name with
> documentation gives you a catalogue entry and nothing to call. Conflating the two is the single most
> expensive mistake this project has made, and the artifacts keep them apart deliberately.

### 1. Read — the whole server, not just `libserver`

A dedicated server maps roughly 22 shared libraries, and `libserver.so` is only a fraction of the reachable engine surface. source2rosetta reads **all of them** (multilib) — `libserver`, `libengine2`, `libtier0`, `libnetworksystem`, `libschemasystem`, and the rest — and every locator it emits carries the library it belongs to.

ELF parsing is by hand. Three things make a stripped binary readable at all:

- **Relocations are resolved.** Every `SHT_RELA` section is processed (matched by type and flags, never by section name), so `.data.rel.ro` pointer slots — zero on disk — come back as their true as-loaded values. Three relocation types are handled; anything else is left alone.
- **`.eh_frame` enumeration** via `PT_GNU_EH_FRAME` recovers function extents where unwind info survives. Valve strips it from the *game* code, so this covers the statically-linked runtime tail and not much else — which is exactly why the next item exists.
- **Candidate entries** = relocation code-pointers (every vtable slot) ∪ decoded near-call targets, unioned with the `.eh_frame` starts. This is the function list everything downstream iterates.

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

Valve compiles two reflection systems into every module, and both are read directly.

**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 the typed schema requires a live process and why an offline run states `"schema": null` instead.

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

| source | what it pairs | locates? |
|---|---|---|
| **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 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`).

**Console commands are read from the registration call, not a registry walk.** CS2 registers through a handle-based `ConCommandRef` whose registry lives in tier0, so there is no static `ConCommand` object to scan for and nothing in the file points at a command name. The registration *call* still does — an ordinary call from a static initializer whose name, handler, description and flags are all constants in the instruction stream. source2rosetta tracks what each argument register provably holds and reads the vector at every call: **784 commands across 20 of the 22 libraries on CS2, 855 on Dota** (the two misses are libraries whose registrar the shape test does not find). The registrar is identified by what it *does* — it opens by writing the invalid-handle sentinel into its `ConCommandRef` — never by address and never by ranking call sites, so a library with three commands is as readable as one with three hundred.

Because name and handler come from one instruction sequence, Valve's own published command dump checks the result: **746 of 746 descriptions agree exactly, with no disagreements**, 742 of 780 distinct names appear in that dump, and 12 flag bits are matched to the flag names it prints. Three further flag bits occur and are deliberately left unnamed.

**Typed Pulse signatures, read offline.** A binding's record does not carry its signature; the accessors beside it return the parameter and return vectors, which are built at runtime and therefore zero in the file. The *code* that builds them is not, and every field is written to a fixed address from a `lea` or an immediate — so constant-propagating through the initializer recovers the full typed signature with no running process: **999/999 CS2 records and 859/859 Dota**, with parameter names, `PulseValueType_t` types, and the schema type a value refers to where the binding names one. Cross-checked against Valve's published metadata dump: **435 of 437** CS2 server bindings agree exactly, **293 of 294** on Dota (every difference a global-event binding, where Valve documents the same vector as an out-param).

### 4. Locate across builds — for everything Valve doesn't name

A stripped non-virtual function has no slot and no symbol, so it has to be *found*.

**The fingerprint** is a recompilation-invariant statistical description of a function: bounded-CFG structural counts, an 18-bucket mnemonic-class histogram, a 32-bucket sketch of the printable `.rodata` strings it references, and one hop of call-graph context (distinct callees plus an aggregate of each callee's own instructions, calls and branches) — **63 dimensions**, deliberately abstracted statistics and never raw bytes.

To be exact about what this is *not*: **no trained model, no machine learning, no learned or weighted metric, no embedding network.** Matching is nearest-history under a plain unweighted **L1** distance, accepted within a fixed threshold. The per-game "model" is a bundle of derived *facts* — vtable-alignment hops, reference-fingerprint windows, ABI-shape consensus, slot timelines — not a network.

**A signature resolves by one of three outcomes, and the artifact says which:**

1. **Strict** — a fingerprint match inside the threshold.
2. **Lenient** — no fingerprint check, but two or more distinct era-signatures vote for the same address.
3. **Unverified fallback** — a single candidate that every check rejected, shipped anyway and marked **`catalogue-unverified`**. This is **0.4% of CS2's core and 69.8% of Dota's**, so on Dota it is the common case, not an edge case. Read the marker.

Whatever the route, the shipped byte-signature is regenerated at the resolved address and confirmed **unique in its library**. That is a *usability* check, not corroboration of identity — the pattern is generated *from* that address, so a wrong address yields a signature that is unique and equally wrong. Identity comes from the match, the vote, or live validation; uniqueness only ensures a loader can find it.

**Catalogue vtable offsets are chained, not read.** For a named method, the slot in a *new* build is derived: dated slots are chained forward through per-build-pair vtable alignments that are themselves fingerprint-scored, then a recency-weighted vote emits a slot only above **80% confidence**. RTTI supplies the vtable and the ordering; which slot a named method occupies is an inference with a stated bar.

### 5. Measure the ABI — the guard a byte-signature can't provide

A signature sees a function's *body* drift and re-derives it. It cannot see the *argument list* change while the prologue stays recognisable — the signature still resolves and points at real code, yet a caller using the old prototype loads the wrong registers.

So each function's observable **SysV-AMD64 shape** (which argument registers are live-in, plus the return class) is recovered by a bounded backward-liveness pass and diffed across builds, flagging exactly those prototype changes and marking struct-by-value returns that are unsafe to blind-call.

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

This is what separates source2rosetta from a static dumper. `produce` and `integration-test` **launch their own** vanilla dedicated server (bots on an empty deathmatch for pawn games; a pawn-less game like Dota waits on a `ready_class` proxy) — no Steam, no separate instance, no human.

Two access paths, and they differ:

- **Reading** is `/proc/<pid>/mem` — no attach, no stop, no injection.
- **Calling** is a real debugger attach: `PTRACE_ATTACH`, save registers, write a scratch frame, run, restore. No injected *code*, but the process is stopped and its registers are written.

What gets checked:

- every **offset** lands on a real vtable slot, and every **signature** on live executable code;
- a gamedata function is **actually called** to prove it is the semantically right function, not a plausible byte-match (pawn games);
- derived probes are **fuzzed across changing game state** for many iterations;
- field **types** are read live for the typed netvars — and fields that are non-null live but zero on disk confirm the reader is seeing real runtime state, not stale disk bytes;
- a **hooked** function (a mod detoured it) is detected by a byte diff at a uniquely-resolved prologue and reported rather than failed.

The contract is blunt: **"degrades or stops loudly, never lies."** An entry live validation confidently rejects is dropped, not shipped under a banner claiming it resolves; if the oracle cannot run, the run fails rather than emit an unverified result.

### 7. Confidence tiers — nothing vanishes silently

| tier | meaning |
|---|---|
| `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`, `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, 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.

---

## What it guarantees — and what it refuses to ship

The tool's strongest claim is not what it produces but what it declines to produce.

### Release floors

A `produce` run **aborts rather than publish** a collapsed artifact. Each surface is gated separately, because each is matched by its own record shape — a Valve reshape that breaks one leaves the others intact, and a summed floor would stay satisfied while a whole surface silently vanished:

| surface | floored on | why it needs its own |
|---|---|---|
| Pulse bindings | registry record count | the registry can move independently of the typed signatures |
| typed Pulse signatures | recovered-signature count | the registry can read perfectly while the descriptor layout moves — every binding would ship signature-less |
| entity-IO records | inputs + outputs | |
| entity classnames | factory-record count | |
| 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 |
| 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:

- **entity-IO ABI** — hundreds of independent handlers against one declared prototype: CS2 715/715, Dota 624/624.
- **console-command ABI** — the callback *form* comes from the registration site, the *arity* from a liveness pass over a different function body: CS2 784/784, Dota 855/855.
- **console-command distinctness** — 784 commands → 783 distinct handlers (855 → 854 on Dota). Catches a layout change that would start returning a shared dispatch thunk: still resolving, still validating, still wrong.
- **Pulse receiver cross-check** — the registry's policy flag vs the independently recovered parameter list: 999/999 and 859/859 registrations.
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota.
- **EHANDLE class grouping** — Valve's naming vs the binary's destructor addresses: 0 of 44 CS2 / 41 Dota groups carry two classes.
- **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 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 live-fuzz fault rate or the RTTI class / base-graph size. A regression in either is reported, not refused.

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.

---

## Install & CLI usage

The two binaries have different audiences. **`source2rosetta`** (the deriver) is only needed to run the pipeline yourself, fork it, or add a game. **`source2rosetta-gen`** is needed by anyone using a release — a release is framework-neutral JSON, so something has to render it into your stack's format — but you can download the prebuilt binary from a `gen-v*` release instead of building it, provided you're on Linux x86-64. Anywhere else, build it from source.

```sh
# The deriver (`source2rosetta`) — the root binary.
cargo build --release                        # → ./target/release/source2rosetta

# The renderer (`source2rosetta-gen`) lives in the core crate and is NOT built by the root
# build — build it explicitly (or use --workspace). See crates/source2rosetta-core/README.md.
cargo build --release -p source2rosetta-core # → ./target/release/source2rosetta-gen
```

**Prerequisites.** Stable Rust for both binaries. The live half additionally needs a game install and **ptrace permission** (same user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`). The CI fuzz gate needs nightly Rust, `cargo-fuzz` and GNU `parallel`. The release runner needs `steamcmd`, `jq` and `python3`. Dependencies are deliberately few — ELF parsing, disassembly and the parallel primitive are in-tree rather than pulled in.

`--game <cs2|dota2>` is a global flag (default `cs2`), given before the subcommand: `source2rosetta --game dota2 produce …`.

| command | one line |
|---|---|
| `produce` | The whole per-game build in one command: derive → fold → (with `--game-dir`) validate-live + typed netvars → roll the model forward, into `--out-dir`. **`--game-dir` present = full live-validated build; absent = fast offline build (gamedata + model only). That flag is the entire offline/full switch.** |
| `corpus-model` | Distill a corpus of past builds into one shippable model (vtable-alignment hops, reference fingerprints, slot timelines), so future derivation needs only the model + the target binary, not the corpus. |
| `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. 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

Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/releases)**. For the offline path you need one file from there — the model (`model-<game>.json`) — plus the derive inputs, which ship in this repo under `mappings/`.

```sh
# OFFLINE — derive gamedata + roll the model forward. No server, fully deterministic.
./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

# FULL — the same, plus it launches its own vanilla+bots server to validate on the live
# process and read field types for the typed netvars. Adding --game-dir is the only change.
./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
```

- `--target <dir>` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.)
- `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.** The two forms are mutually exclusive and the CLI says so: `--seed` supplies all six, so passing one alongside it is a usage error rather than an input silently dropped.
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint raw build binaries on the fly).

Model-based derives are **forward-only**: the model describes history up to its newest build, so pointing one at an *older* target is not supported.

The launched server writes its log to `TMPDIR/<token>-produce.log`, one file per game, and binds a **fixed port** — so two games cannot be produced concurrently on one host without changing it.

---

## Fork it & distill your own model

Nothing is hosted — fork it, `cargo build --release`, and point it at a build on disk.

### What a fork inherits, and what it must supply

Everything engine-generic is inherited: ELF/RTTI/SchemaSystem/SysV reading, fingerprinting, the model machinery, live validation, the emitters. Two things a fork must produce for itself:

- **A catalogue** — the one required input. It is the list of functions you want gamedata *for*: each entry a name plus whatever historical evidence exists (dated vtable slots, per-era signatures, string anchors). Everything else in the seed is optional and defaults to empty. Without a catalogue the tool has nothing to look for.
- **A model**, distilled from a corpus of past builds — or downloaded from a release if you're forking this project's games.

The **seed bundle** collapses the loose inputs into one file with sections for catalogue, promotable names, candidates, full names, extra offsets, extra sigs, and contributions. `mappings/naming/` is a large frozen input with no in-repo producer — it is data, not something a build regenerates.

### Distilling

```sh
# Distill a corpus into a model (streaming, bounded RAM even over Dota's ~1k builds).
./target/release/source2rosetta --game cs2 corpus-model \
    --seed   mappings/seed-cs2.json \
    --corpus corpus/binaries \
    --out    model-cs2.json
```

`--class-scope` picks which classes get vtable-slot hops — the timelines that let a consumer derive an offset from the model alone:

- `clean` (default) — every real game class, dropping template instantiations, protobuf message shapes and NetworkVar chainers, whose hops nobody derives an offset from.
- `all` — those too.
- `catalogue` — only what the catalogue names. The catalogue's own classes are always included regardless of scope.

**Whatever scope you distill with, `fold-model` and `produce`'s sidecar fold must use the same one.** The model records its scope and the fold asserts on it, so a mismatch fails loudly — but CI does not pass the flag, so a non-default scope requires a workflow change too.

### Keeping a model fresh — the incremental fold

Once a model exists you never need the corpus again. `fold-model` rolls it forward one build, reading only the model plus the single new binary:

```sh
./target/release/source2rosetta --game cs2 fold-model \
    --model model-cs2.json \
    --seed  mappings/seed-cs2.json \
    --build <new-build-dir> \
    --out   model-cs2.next.json
```

`produce --corpus-model` runs exactly this fold as a sidecar, so a full build both derives *and* advances the model in one command.

The fold equals a full re-distill over the same builds under **three** conditions: the same `--class-scope` (asserted), the same catalogue the model was distilled from (**not** checked — production always folds with the distill's catalogue, but a newcomer gets only a warning), and no class re-appearing across the model's latest-build boundary. In that last case a class the model has never seen is back-filled with absent history — reduced coverage for that name until the next full re-distill, never a wrong offset.

### Getting the corpus (only to bootstrap a model)

A corpus is a directory of past builds, one subdirectory of `.so` files per build (`corpus/binaries/<label>/*.so`). Fetch it yourself, one time:

1. Use **DepotDownloader** — the self-contained release binary, **not** `dotnet tool install` (its NuGet package is pinned ancient).
2. Pull manifests from the **Linux binaries depot `2347773`** — *not* the content depot `2347770`. `2347773`'s manifest only advances when the binaries actually change, so its history already *is* the list of real recompiles; content micropatches only bump `2347770`. Read the manifest history off SteamDB, not the Steam client.
3. Download **oldest-first** (chronological = version order), then content-hash-dedup.

`filter-corpus` then collapses code-identical builds and segments toolchain eras, so you never fingerprint the same code twice. Its manifest is **advisory** — `corpus-model` reads a *directory*, not the manifest — so the usual pattern is to materialise the kept set as a directory of symlinks and point the distill at that. Nothing checks that the directory matches the manifest; that is on you.

A **partial corpus is fine** — fewer labels is a shallower history, not a broken model.

### Adding a game

Three edits: a `profile::GameProfile` const, a `Game` enum variant, and a match arm in `main`. The profile carries the library set, launch spec, pawn anchor, dead-weight vocabulary, per-surface floors and the game/content keys.

That is the mechanical part. The untested-per-game work is everything the profile *cannot* express: whether the engine era's SchemaSystem layout matches an existing one, whether the game boots to a state where the live oracle can run, and whether its dead-weight vocabulary actually filters that game's junk.

---

## Artifacts, schemas & output formats

A full `produce` run writes a self-contained release set per game into `--out-dir`:

| File | What it is | When |
|------|-----------|------|
| `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 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`.

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", "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" }
}
```

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

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.

### `functions` — one record each

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. Usually one or the other — but **a record may carry both**, and a handful of `core` CS2 records
do, so a consumer (and the live oracle) has to judge every locator a record holds rather than the first it
finds.

Two further locator keys appear where they were established:

- **`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. It is the record's own name
  class, stated explicitly: the deriver keys its slot timelines and alignment hops by that class and chains
  through it, so "the class chained through" and "the class in the name" are one fact. Directly folded
  offsets (the multilib ground-truth path) omit it and leave the consumer to split the name.
- **`anchors`** — distinctive string literals the function references, each unique to it within its library.
  Not a locator competing with the signature and the offset, 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.

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:

| verdict | meaning |
|---|---|
| `verified` | declared arity matches the footprint measured in *this* build |
| `lower-bound` | the declaration passes registers the callee never reads, and contradicts it in no register class — safe to call, but not the same claim as an exact match |
| `mismatch` | the callee reads a register the declaration does not mention — **do not call through it** |
| `return-only` | a return type is known and no parameter list, so there is no arity claim to check |
| `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).

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

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.

### `bindings` — what the binary declares

A LIST, because one function can be several — and because a `kind` tells a consumer which it got:

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

**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`.

### `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.

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

### `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

The distilled facts derivation reads instead of the corpus. Not a consumer artifact.

### Rendering — the `gen` binary

`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 `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

This is a publishable tool, so where declarations and names come from is a hard boundary, not a footnote.

**Harvested:** Valve-published artifacts (the shipped binaries themselves, Valve's own metadata and command dumps, symbolicated older macOS builds) and legitimately licensed open-source projects, credited in [ATTRIBUTIONS.md](ATTRIBUTIONS.md).

**Excluded, and this is enforced rather than intended:** the 2020 CS:GO source leak and anything descending from it; `hl2sdk`-derived vendored SDK trees, which are quarantined on ambiguous provenance; and one widely-copied engine header that carries an annotation tying it to a fork whose own commit messages reference leaked code — every copy of it across the ecosystem shares that lineage, so all of them are denylisted. Where a harvested repository vendors a rejected tree as a submodule, only that project's own sources are read, and the extractor **asserts** the exclusion holds rather than assuming it — a denylist that matches nothing reads as a guarantee while enforcing nothing.

**Joining is by function, never by name.** A third-party plugin keys its gamedata by its own labels, so a declaration is attached only when the label *is* one of our names, or when its byte signature matches exactly one of ours in the same library. Matching on a bare method name is gated hard and withdrawn when the binary contradicts it.

**The corpus is never redistributed.** It is Valve's binaries; the model distilled from it contains derived facts, not code.

---

## Known limitations

- **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 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`.
- **String anchors cover a minority, by design.** 480 catalogued + 77 derived on CS2, 103 + 22 on Dota. Two conditions do the filtering: an anchor names a *function*, so an entry whose locator points mid-function (a hook site rather than a prologue) gets none; and the string must be referenced only from inside that function. Most shipped functions reference no string unique to them, which is the real ceiling — 859 of 1,505 CS2 candidates fail on that alone.
- **Some declared returns are decided by source order.** Where sources disagree on a return type, the untrusted source is ranked last, but among the trusted ones the order is the order they were merged. A handful of names are settled that way, which is stated rather than papered over.

---

## Copyright

Valve, Counter-Strike, Dota and Source 2 are trademarks of Valve Corporation. This project is not affiliated with or endorsed by Valve. It ships no Valve code and no Valve binaries — only facts derived from publicly shipped files.

## License

AGPL-3.0. See [LICENSE](LICENSE).
