- Rust 99.5%
- Shell 0.5%
| .forgejo/workflows | ||
| crates/source2rosetta-core | ||
| fuzz | ||
| mappings | ||
| src | ||
| .gitignore | ||
| ATTRIBUTIONS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CONTRIBUTING.md | ||
| fuzz.sh | ||
| LICENSE | ||
| README.md | ||
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.
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest # always the newest build
curl -fsSLO $R/gamedata-cs2.json # WHERE functions are — signatures + vtable offsets
curl -fsSLO $R/netvars-cs2.json # field offsets and types
curl -fsSLO $R/abi-cs2.json # HOW to call them — parameter and return types, re-judged per build
Also published: bindings-<game>.json (the callable surface the binary declares about itself — Pulse
bindings, entity IO, console commands) and manifest.json (which build you got). The
artifacts section covers all of them.
The output is framework-neutral; source2rosetta-gen renders it into whatever your stack speaks — the gamedata into your framework's locator format, and abi-<game>.json into typed call sites for the same functions. The deriver behind it is a standalone Rust tool — you only need that if you're self-hosting the pipeline or adding a game.
Docs
- 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 — 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 — add or back-fill a gamedata entry.
- 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 | typed schema | model | one-time distill | |
|---|---|---|---|---|
| CS2 | ~1,125 core + ~2,620 high_confidence, plus ~4,375 experimental name guesses |
~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
| Dota 2 | ~1,930 core + ~2,450 high_confidence, plus ~5,950 experimental |
~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
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:
- A timer polls Steam every 15 minutes, comparing the installed build id against the live one.
- On a change it updates the install and runs a single
produce: derive → live-validate → typed netvars → roll the model forward. - It publishes an immutable
<game>-<buildid>-<patch>snapshot, then moves<game>-latestonto 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.) A failed run publishes nothing and leaves the previous release standing.
| you want | use |
|---|---|
| the newest build, always | …/releases/download/cs2-latest/gamedata-cs2.json |
| a specific build, pinned | …/releases/download/cs2-<buildid>-0/gamedata-cs2.json |
| 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:
gamedata-<game>.json— where the code is. Every entry is a hook point or a call target: a byte signature or an RTTI vtable slot, tiered and, forcore/high_confidence, checked against a running server.netvars-<game>.json— what the state is. Field offsets and types for every SchemaSystem class, plus the base graph, the enum tables and per-type sizes. This is the half that needs no hooking at all: a great deal of gameplay is readable and writable as plain memory.abi-<game>.json— whether it is safe to call. A declared prototype joined to the register footprint measured in this build, with a verdict per function.verifiedandlower-boundare callable;mismatchsays the prototype in circulation is wrong for this binary.bindings-<game>.json— what the binary declares about itself. Console commands with flags and handler addresses, entity inputs and outputs, map classname → C++ class, and the typed Pulse registry.
One thing to internalise before reading further, because it shapes every capability below: there are no ConVars in these artifacts, in either game. The command surface is 784 ConCommands on CS2 and 855 on Dota, and not one sv_airaccelerate, mp_freezetime, bot_quota or dota_gold_per_tick appears anywhere. This is a function-and-layout release, not a configuration release. A plugin built on it changes behaviour by detouring code and writing fields — never by setting a cvar.
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 worth respecting: CBaseEntity::DispatchTraceAttack and CBaseEntity::Event_Killed are verified — and Event_Killed measures as the CS2-shaped (CCSPlayerPawn*, CTakeDamageResult*), not the Source-1 CTakeDamageInfo const& everyone assumes — while abi:CBaseEntity::TakeDamage is tier core but verdict unverified: the declaration was never checked against this build. Build the struct by offsets and prefer the verified entry points.
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 this domain is unusually clean about. 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: abi-cs2.json has no entry for any CCSBot:: method. You get an address and a register count.
Be precise about the state machine, because it is easy to oversell. 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 worth flagging as a discovery: it has zero entity inputs and zero entity outputs, so anyone auditing the IO graph would conclude 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 defect to verify before shipping: 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.
Be honest about recipient filters, because several attractive claims depend on them. The filtered dispatchers (UTIL_DispatchParticleEffectFilter_Position/_Attachment, UTIL_SayTextFilter, UTIL_SayText2Filter, SoundOpGameSystem::StartSoundEventString) are real and verified — but the only recipient-filter symbol anywhere in gamedata-cs2.json is CRecipientFilter::AddAllPlayers, whose ABI entry is unverified with an empty parameter list. There is no AddRecipient, no per-team filter, no single-user filter. You get "dispatch to everyone" and a filter-shaped hole you must fill from your own framework. The genuinely per-client route that is covered is the point_soundevent entity: StartSoundOnSingleClient targets one player index and fires an m_onSoundFinished output when the sound ends.
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. Detecting a runtime change to those flags is not — 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 bigger but narrower: 919 CModifierFactory<…> entries and several hundred game-system factories account for most of it. The classic gameplay verbs a Dota modder expects — cast, apply damage, issue an order, add a modifier — are not in it. Dota's strength here is observation and schema; CS2's is invocation.
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: there is no AddNewModifier or RemoveModifierByName at any tier, and CDOTA_ModifierManager exposes only 7 of its 904 bytes — no vector of active buffs. You can hook creation and read a buff you hold; you cannot enumerate a unit's modifiers or apply one, except through the debug command dota_modifier_test <entityindex> <modifiername> <duration>, whose handler is verified.
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 is the strong direction. There is no gold or XP mutator at any usable tier (the only match is CDOTATurboGameMode::FilterModifyGold, experimental), so a plugin writes the ledger or tunes the passive knobs.
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 trap applies to CGameEntitySystem::AddEntityIOEvent: core in CS2, experimental in Dota. Any sentence that says "core" about a shared engine symbol without naming the game is unsafe.
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 u8s 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 arguably the most valuable output in the file: 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 worth knowing about because they cost you nothing: 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 genuinely self-checking and worth calling out: 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
- No ConVars, in either game. Commands only. No
sv_*/mp_*/dota_*tunables, no defaults, no flags, no ranges. - No protobuf field layouts. You get message ids and class names; you must supply the
.protodefinitions. - 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 andlogic_gameevent_listener, which needs only a string. - No content names. No
.vpcfparticle 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
clientlibrary. 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:Nall 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_RELAsection is processed (matched by type and flags, never by section name), so.data.rel.ropointer slots — zero on disk — come back as their true as-loaded values. Three relocation types are handled; anything else is left alone. .eh_frameenumeration viaPT_GNU_EH_FRAMErecovers 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_framestarts. 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.
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 typed netvars require a live process and why an offline run ships no netvars-<game>.json at all.
3. Names Valve ships in the binary — three sources, and only two 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 |
| Pulse binding registry | a qualified Class::Method with display name, description, call policy and a full typed signature |
no |
The Pulse registry does not locate anything, and the mistake is instructive. Its records carry two code pointers, which an early pass folded 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 that returns 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 honest yield from that table is zero locators — and a complete typed API surface, which ships separately as bindings-<game>.json.
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:
- Strict — a fingerprint match inside the threshold.
- Lenient — no fingerprint check, but two or more distinct era-signatures vote for the same address.
- 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 abi-<game>.json joins declared prototypes to that measurement and judges each one. See the manifest.
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) and no locator |
A function that couldn't be derived this build shows up as unresolved with a reason — it never just disappears.
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 |
| typed netvars | fraction of fields typed | a runtime type-layout reshape resolves every field "untyped" and would otherwise ship a typeless schema at exit 0 |
| schema enums | enum count | read by shape like the class table, so a reshape yields zero rather than wrong |
| live validation | pass rate, above a minimum sample |
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".
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.
- field-gap size calibration, the semantic call sweep, and a 500-iteration live fuzz.
One of these found a real defect on its first run: 34 of 715 handlers measured float arguments a void(ptr, ref) cannot have, which traced to the ABI reader treating a call as fall-through so a callee's return propagated backwards as a phantom argument.
What is NOT gated
Stated because "we check things" is worthless without a boundary. There is no floor on the validate-live drop rate, the live-fuzz fault rate, the schema class count, or the RTTI class / base-graph size. A regression in any of those is reported, not refused.
One oracle is currently reporting. The Pulse registry is keyed by qualified name, so a binding registered by several modules keeps one row, and every duplicate is compared against the row already present. On the current builds CS2 flags 331 of 419 repeat registrations and Dota 271 of 359 as disagreeing — so for those names bindings-<game>.json carries one module's account of the signature, not a merged one. It is flagged on every run and is not yet resolved; if you consume params/returns for a multiply-registered binding, know that.
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.
# 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. 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). |
Quickstart
Releases live on the releases page. 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/.
# 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 \
--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 \
--target <build-dir> \
--game-dir <cs2-install> \
--out-dir out
--target <dir>— the build directory to derive from;producerequires a directory and its libraries are searched by name. (Other subcommands accept a bare.soas well, which is howclassify-change --previs used.)--game-dir <install>— must be thegame/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.- 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
# 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:
./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:
- Use DepotDownloader — the self-contained release binary, not
dotnet tool install(its NuGet package is pinned ancient). - Pull manifests from the Linux binaries depot
2347773— not the content depot2347770.2347773's manifest only advances when the binaries actually change, so its history already is the list of real recompiles; content micropatches only bump2347770. Read the manifest history off SteamDB, not the Steam client. - 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 |
|---|---|---|
gamedata-<game>.json |
The monolith — the tiered function catalogue (signatures + vtable offsets) with provenance and live-validation folded inline | always |
abi-<game>.json |
The prototype manifest — declared parameter/return types, each judged against the footprint measured in this build | always |
bindings-<game>.json |
The declared callable surface — what the binary says about itself: Pulse bindings, entity IO, entity classnames, console commands | always |
netvars-<game>.json |
The typed schema — every SchemaSystem class → field → offset/type, plus the base graph and type layouts | full (--game-dir) runs only |
model-<game>.json |
The per-game model — the distilled facts derivation reads instead of the corpus | when the run folds an existing model |
manifest.json |
Volatile release metadata: { version, artifacts: [...] } |
always |
Wall-clock and other volatile metadata live only in manifest.json; the other artifacts carry no timestamp, so they are byte-reproducible — the same build in yields the same JSON out.
gamedata-<game>.json — the monolith
{
"meta": { "game_key", "game", "source_build", "version",
"counts": { "core", "high_confidence", "experimental", "unresolved" } },
"core": { "<fn name>": <MonoEntry>, ... },
"high_confidence": { "<fn name>": <MonoEntry>, ... },
"experimental": { "<fn name>": <MonoEntry>, ... },
"unresolved": { "<fn name>": { "reason", "detail" }, ... }
}
A MonoEntry is a locator flattened to the top level, plus its grading and — where the address resolved offline — its measured abi footprint inline. A virtual method ships as a bare integer offset (its RTTI vtable slot index); a non-virtual function as a signature object with the library it scans and a space-hex linux pattern with ? wildcards. By deriver convention an entry carries one or the other; the readers handle the rare both-present case deterministically.
reason on an unresolved entry comes from a closed vocabulary: sig-drifted, offset-low-conf, unresolved, abi-drift.
Console-command handlers ship under the key ConCommand::<name>. The prefix says what the entry is — the handler bound to that command — rather than claiming a C++ symbol; ent_fire's real method name appears nowhere in the binary.
abi-<game>.json — declared prototypes, judged against this build
Gamedata says where a function is; it never says what it takes. Types cannot be recovered from a stripped binary, so they come from declarations — and a declaration must be checked before anything calls through it, because a stale one produces a call that resolves, validates, and loads the wrong registers.
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.
In the artifact, parameters are spelled as pointers (CCommandContext*, CCommand*), not as the C++ reference types.
source2rosetta-gen --abi … --format <framework> turns this into call sites: typed C# fields for CounterStrikeSharp, a C++ typedef header for Metamod plugins, an [AddressKey] interface for ModSharp, and runtime type descriptors for Swiftly and Plugify. Both verified and lower-bound entries with a settled receiver are emitted, and every output marks the lower-bound ones.
bindings-<game>.json — the declared callable surface
What the binary says about itself, as opposed to what the derivation infers about it. Kept out of the monolith deliberately: gamedata answers "where is this function", this answers "what may I do with it, and how". Five sections:
pulse— bindings keyed by qualifiedClass::Method, each with Valve's display name and description, a decoded call policy (receiver kind, mutating, blocking) with the raw words beside it, and the recovered typed signature.entity_inputs— the map-facing input name, the C++ handler, its owning class where the schema join qualified it, and the handler's address (these also ship as gamedata).entity_outputs— the events an entity fires and where the subscriber list lives on the instance.entity_classes— map classname → the C++ class it constructs (func_door→CBaseDoor). Names to names, no addresses.commands— console commands with description, decoded flags, the raw flags word, the callback form, the measured ABI shape and the handler address (these also ship as gamedata).
descriptor on a Pulse binding is NOT a locator — see above. Any Pulse count is a count of registrations, not distinct bindings.
netvars-<game>.json — the typed schema
Every SchemaSystem class → field → offset and type, plus two sections that are easy to miss and load-bearing:
bases— the class base graph. Without it an inherited field is unresolvable.types— per-type size and SysV register class, needed to compute a by-value argument's register cost.
Of a field's attributes, type and kind are read from the live process; size and the name hash are derived offline.
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 takes the monolith (--from), the schema (--netvars) or the manifest (--abi) and renders the matching format. The input chooses what is rendered; the --format id chooses for whom.
Two things to know before you diff outputs:
- The default
cssharpgamedata output is JSONC — it carries comment banners, so a strict JSON parser will reject it. - The
swiftlygamedata 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
modelformat emits a tier-selectedGamedatawith nometa, so its output cannot be fed back in via--from.
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.
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.
experimentalis never live-validated. Resolvable locator, unverified name.- Offline runs ship no netvars and no
validatedstate, 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.
- 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.