Compare commits

...

4 commits

Author SHA1 Message Date
Kamal Tufekcic
bac78eb8fa fix steam token getting reset
All checks were successful
CI / lint (push) Successful in 15s
CI / test (push) Successful in 23s
CI / fuzz (push) Successful in 1m57s
2026-08-06 20:52:16 +03:00
Kamal Tufekcic
43e37679ed fix steam token getting reset
All checks were successful
CI / lint (push) Successful in 15s
CI / fuzz (push) Successful in 1m59s
CI / test (push) Successful in 23s
2026-08-06 20:15:56 +03:00
Kamal Tufekcic
36441687ff doc updpates
All checks were successful
CI / lint (push) Successful in 15s
CI / fuzz (push) Successful in 2m5s
CI / test (push) Successful in 23s
2026-08-04 04:02:16 +03:00
Kamal Tufekcic
a9665e55f9 fix floors measuring single lib instead all libs
All checks were successful
CI / lint (push) Successful in 15s
CI / fuzz (push) Successful in 2m3s
CI / test (push) Successful in 23s
2026-08-04 02:50:33 +03:00
4 changed files with 107 additions and 38 deletions

View file

@ -20,8 +20,9 @@ jobs:
runs-on: s2-runner runs-on: s2-runner
env: env:
GAME: ${{ github.event.inputs.game }} GAME: ${{ github.event.inputs.game }}
STEAM_APPS: /home/cs2/.steam/SteamApps
STEAM_USER: source2rosetta STEAM_USER: source2rosetta
STEAM_HOME_ANON: /home/cs2
STEAM_HOME_AUTH: /home/cs2/steam-auth
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
OVERRIDE_DIR: /home/cs2/rosetta-override OVERRIDE_DIR: /home/cs2/rosetta-override
steps: steps:
@ -30,12 +31,36 @@ jobs:
- name: Update the install to the current build - name: Update the install to the current build
run: | run: |
case "$GAME" in case "$GAME" in
cs2) APPID=730 ;; cs2) APPID=730; LOGIN=anonymous; STEAM_HOME="$STEAM_HOME_ANON" ;;
dota2) APPID=570 ;; dota2) APPID=570; LOGIN="$STEAM_USER"; STEAM_HOME="$STEAM_HOME_AUTH" ;;
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;; *) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
esac esac
echo "APPID=$APPID" >> "$GITHUB_ENV" env HOME="$STEAM_HOME" steamcmd +login "$LOGIN" +app_update "$APPID" +quit
steamcmd +login "$STEAM_USER" +app_update "$APPID" +quit
STEAM_APPS=""; SEEN=""; BUILD=""
for cand in "$STEAM_HOME/Steam/steamapps" "$STEAM_HOME/.steam/steam/steamapps" \
"$STEAM_HOME/.steam/SteamApps"; do
m="$cand/appmanifest_$APPID.acf"
[ -f "$m" ] || continue
# The same tree reached twice through a symlink is ONE tree, not a disagreement.
key=$(stat -Lc '%d:%i' "$m")
case " $SEEN " in *" $key "*) continue ;; esac
SEEN="$SEEN $key"
b=$(grep -oP '"buildid"[[:space:]]+"\K[0-9]+' "$m")
echo " candidate $cand -> buildid $b"
if [ -z "$STEAM_APPS" ]; then
STEAM_APPS="$cand"; BUILD="$b"
elif [ "$b" != "$BUILD" ]; then
echo "::error::two Steam app trees under $STEAM_HOME disagree — $STEAM_APPS says" \
"$BUILD, $cand says $b. One is stale; deriving from it would publish gamedata for" \
"a build nothing is running. Remove the stale tree or symlink it to the live one."
exit 1
fi
done
[ -n "$STEAM_APPS" ] || {
echo "::error::no appmanifest_$APPID.acf under $STEAM_HOME — did the update run?"; exit 1; }
echo "using $STEAM_APPS (buildid $BUILD)"
{ echo "APPID=$APPID"; echo "STEAM_APPS=$STEAM_APPS"; } >> "$GITHUB_ENV"
- name: Resolve the game paths + the new buildid - name: Resolve the game paths + the new buildid
run: | run: |

View file

@ -28,18 +28,18 @@ The output is framework-neutral; `source2rosetta-gen` renders it into whatever y
## Results ## Results
Ballpark from a recent build, on a 16-core desktop. These move build-to-build — treat them as orders of magnitude, not guarantees. Measured on CS2 build `24537688` and Dota 2 build `24541331`, 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 | | | 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 | | **CS2** | 1,086 `core` + 2,894 `high_confidence`, plus 4,374 `experimental` name guesses | **300 VScript bindings (247 located)**, 580 Pulse bindings (127 host-callable), 784 commands, 1,551 ConVars, 715 entity inputs / 226 outputs, 474 classnames | 2,158 `verified` + 92 `lower-bound`, 84 `mismatch`, 261 `return-only` | 1,899 classes / 12,331 fields | ~46 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 | | **Dota 2** | 1,097 `core` + 4,047 `high_confidence`, plus 5,817 `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,837 `verified` + 99 `lower-bound`, 45 `mismatch`, 1,594 `return-only` | 2,962 classes / 17,695 fields | ~735 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. **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. The VScript surface is the newest and it moves the `high_confidence` count more than anything else has: **+247 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. A full run live-validates what it ships and reports **0 dropped** on both games — for CS2 that is 3,972 entries carrying 2,848 signatures and 1,129 vtable offsets, all checked against a running server (Dota: 5,138 entries, 4,309 signatures, 829 offsets). 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.
--- ---
@ -74,9 +74,9 @@ The artifacts answer four different questions, and most useful work joins two or
Three of those are newer than the rest and worth calling out, because they change what a plugin can do: Three of those are newer than the rest and worth calling out, because they change what a plugin can do:
**The VScript registry closes the biggest gap in the Dota surface.** 1,841 bindings on Dota and 300 on CS2, each pairing the name a script author types with the C++ name, Valve's own English description, and a declared return type — and 1,599 / 246 of them fold into the gamedata as real locators. It is the only source here that supplies gameplay VERBS on Dota: `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget`, `ChangeTeam`, `ModifyGold` and `AddExperience` are absent from every tier of the previous release and present now, which is why the [Dota section below](#dota-2) reads differently from how it did. They are script-facing wrappers rather than the underlying methods, and for a caller that is a feature: the wrapper's argument shape is the one Valve declared for a content author to use safely, and the wrapper is what the engine itself invokes. **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 / 247 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. **ConVars ship with their flags.** 1,551 on CS2 across four libraries, 782 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. **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.
@ -98,7 +98,7 @@ What you do not get: `TryPlayerMove`, `WalkMove`, `Accelerate` and `TracePlayerB
#### Combat, damage and tracing #### 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. `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 prototype notes, and the second is the sharpest example in this file of why the locator and the prototype are separate facts. `CBaseEntity::Event_Killed` is `verified` and measures as the CS2-shaped `(CCSPlayerPawn*, CTakeDamageResult*)`, not the Source-1 `CTakeDamageInfo const&` everyone assumes. And `CBaseEntity::TakeDamage` — the funnel itself — is tier `core` with `validated: true`, and its prototype verdict is **`mismatch`**: the circulated declaration `(CTakeDamageInfo&)` accounts for two integer registers and this build's callee reads **three**. The address is right and hooking it is fine; *calling through that declaration* would load the wrong registers. 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. **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.
@ -170,7 +170,7 @@ Because command flags are decoded, the client-reachable attack surface is exactl
### Dota 2 ### 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. 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,962 schema classes / 17,695 fields against 1,899 / 12,331. 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. 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.
@ -220,13 +220,13 @@ A flat offset dump cannot do any of the following, and each one is a real failur
**`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. **`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. **`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, 524 CS2 enums / 710 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. **`types` gives size and SysV class.** Size turns every generated accessor into a bounds check (12,331/12,331 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 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. **A checked prototype is worth more than a declared one, and the verdict is the product.** `verified` (2,158 CS2 / 2,837 Dota) means declared arity matches the footprint measured in this build. `lower-bound` (92/99) means the declaration passes registers the callee never reads — compatible, but not the same claim. **`mismatch` (84/45) 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. 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.
@ -234,13 +234,13 @@ Two structural cross-checks come for free: all **226 CS2 entity outputs agree ex
### The experimental band — read this before using any of it ### 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. `experimental` is 4,374 entries on CS2 and 5,817 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). **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 / 355 Dota entries carry `collision: true` (another guessed name resolved to the same target) and 42 / 130 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. 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. Dota's band *does* reach gameplay: 2,040 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. 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.
@ -453,7 +453,7 @@ A dozen free, mostly two-sided checks run on **every** derive and are reported.
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota. - **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. - **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. - **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. - **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,916/1,916 Dota. Note the population: this reads `libserver` alone, where the release floor counts the union across every mapped library (1,899 / 2,962). Two different numbers for two different questions.
- **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 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. - **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. - **field-gap size calibration**, the semantic call sweep, and a 500-iteration live fuzz.
@ -467,7 +467,7 @@ So one check asks the other question, from two things the binary states about an
**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. **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. Across the ~3,980 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. **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.
@ -706,8 +706,8 @@ already uses, so all four ship and each one names the other three. On CS2 that i
names, 4.3% of the resolved surface**; on Dota it is a single group, because that catalogue is far less of a names, 4.3% of the resolved surface**; on Dota it is a single group, because that catalogue is far less of a
merge. merge.
Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,987 is Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,980 is
exactly right about NAMES and describes **3,895 distinct functions**. And read it before hooking: two names on exactly right about NAMES and describes **3,888 distinct functions**. And read it before hooking: two names on
one address detoured independently is one trampoline chain claimed twice. 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 Grouping is by locator identity across `core` + `high_confidence` — a shipped pattern is generated at the

View file

@ -2124,17 +2124,30 @@ fn validate_live_cmd(
fn verify_live_cmd(prof: &GameProfile, pid: u32, dir: &Path, lib: &str) -> Result<OracleCounts> { fn verify_live_cmd(prof: &GameProfile, pid: u32, dir: &Path, lib: &str) -> Result<OracleCounts> {
let img = load_lib(dir, lib)?; let img = load_lib(dir, lib)?;
let classes = schema::enumerate_schema(&img); let classes = schema::enumerate_schema(&img);
// The SAME floor `produce` applies, not merely non-empty: this half compares an offline read against a // A floor, not merely non-empty: this half compares an offline read against a live one through the same
// live one through the same `CI_*` constants on the same bytes, so a reshape's survivors agree with // `CI_*` constants on the same bytes, so a reshape's survivors agree with themselves at ~1.0 and a
// themselves at ~1.0 and a handful of classes looks like a clean run. See // handful of classes looks like a clean run.
// `GameProfile::min_schema_classes`. //
// It is `min_schema_classes_LIB`, because this enumerates ONE library while `produce`'s floor counts the
// union across all of them — see `GameProfile::min_schema_classes_lib`. Only `server_lib` has a
// calibrated count, so any other library is enumerated and reported rather than judged against a number
// that does not describe it.
if lib == prof.server_lib {
ensure!( ensure!(
classes.len() >= prof.min_schema_classes, classes.len() >= prof.min_schema_classes_lib,
"offline schema derivation found {} classes in {lib} (floor {}) — refusing to verify a schema \ "offline schema derivation found {} classes in {lib} (floor {}) — refusing to verify a schema \
whose class table collapsed", whose class table collapsed",
classes.len(), classes.len(),
prof.min_schema_classes prof.min_schema_classes_lib
); );
} else {
eprintln!(
"NOTE: {lib} is not {}, which is the only library with a calibrated class floor — \
enumerated {} classes, collapse check SKIPPED",
prof.server_lib,
classes.len()
);
}
let live = live::LiveProcess::attach(pid)?; let live = live::LiveProcess::attach(pid)?;
let base = live let base = live

View file

@ -154,6 +154,16 @@ pub struct GameProfile {
/// offline/live layout comparison reads the same bytes through the same `CI_*` constants, so whatever /// offline/live layout comparison reads the same bytes through the same `CI_*` constants, so whatever
/// survives a reshape agrees with itself. /// survives a reshape agrees with itself.
pub min_schema_classes: usize, pub min_schema_classes: usize,
/// Collapse floor for the schema CLASS table read from a SINGLE library — the live oracle's population.
///
/// Distinct from [`min_schema_classes`](Self::min_schema_classes), and the two may never be shared: that
/// one counts the union across every mapped library, this one counts `server_lib` alone, and the union is
/// roughly twice as large. A floor calibrated on the union rejects every healthy build when applied here,
/// because the honest single-library count sits below it by construction.
///
/// Calibrated the same way as its sibling — well under the observed count, a collapse detector rather
/// than a tight bound — and it only applies to `server_lib`, the one library whose count is calibrated.
pub min_schema_classes_lib: usize,
/// Collapse floor for the DERIVED function tiers — `core + high_confidence`. /// Collapse floor for the DERIVED function tiers — `core + high_confidence`.
/// ///
/// Every table read out of the binary has one of these; the tool's headline product did not, and the /// Every table read out of the binary has one of these; the tool's headline product did not, and the
@ -252,9 +262,11 @@ pub const CS2: GameProfile = GameProfile {
// observed live: 271 of 300 bindings attributed across 24 classes // observed live: 271 of 300 bindings attributed across 24 classes
min_vscript_classed: 150, min_vscript_classed: 150,
min_schema_enums: 250, min_schema_enums: 250,
// CS2 recovers 1,899. A floor at 1,200 is well clear of build-to-build drift and nowhere near // CS2 recovers 1,899 across every mapped library. A floor at 1,200 is well clear of build-to-build
// the range a `SchemaClassInfoData_t` reshape would leave. // drift and nowhere near the range a `SchemaClassInfoData_t` reshape would leave.
min_schema_classes: 1_200, min_schema_classes: 1_200,
// libserver.so alone holds 852 of those; the live oracle reads that library only.
min_schema_classes_lib: 550,
// CS2 ships 1,086 core + 2,899 high-confidence = 3,985. // CS2 ships 1,086 core + 2,899 high-confidence = 3,985.
min_core_functions: 2_500, min_core_functions: 2_500,
game_key: "csgo", game_key: "csgo",
@ -365,8 +377,10 @@ pub const DOTA: GameProfile = GameProfile {
// observed live: 1,638 of 1,841 bindings attributed across 63 classes // observed live: 1,638 of 1,841 bindings attributed across 63 classes
min_vscript_classed: 900, min_vscript_classed: 900,
min_schema_enums: 350, min_schema_enums: 350,
// Dota recovers 2,962. // Dota recovers 2,962 across every mapped library.
min_schema_classes: 2_000, min_schema_classes: 2_000,
// libserver.so alone holds 1,916 of those; the live oracle reads that library only.
min_schema_classes_lib: 1_250,
// Dota ships 1,096 + 4,047 = 5,143. // Dota ships 1,096 + 4,047 = 5,143.
min_core_functions: 3_000, min_core_functions: 3_000,
game_key: "dota", game_key: "dota",
@ -450,6 +464,23 @@ pub const DOTA: GameProfile = GameProfile {
mod tests { mod tests {
use super::*; use super::*;
/// The two class floors count DIFFERENT populations — the all-library union and `server_lib` alone —
/// so a profile that gives them the same value has calibrated one of them against the other's
/// population, which rejects every healthy build on whichever site got the larger number.
#[test]
fn the_single_library_class_floor_is_strictly_below_the_all_library_one() {
for prof in [&CS2, &DOTA] {
assert!(
prof.min_schema_classes_lib < prof.min_schema_classes,
"{}: single-library floor {} must sit below the all-library floor {} — one library \
cannot hold more classes than every library",
prof.token,
prof.min_schema_classes_lib,
prof.min_schema_classes
);
}
}
#[test] #[test]
fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() { fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() {
// The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec // The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec