Compare commits

...

5 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
Kamal Tufekcic
22ab973f0c minor tweaks
All checks were successful
CI / lint (push) Successful in 16s
CI / fuzz (push) Successful in 2m3s
CI / test (push) Successful in 25s
2026-08-03 03:59:53 +03:00
16 changed files with 330 additions and 182 deletions

View file

@ -20,8 +20,9 @@ jobs:
runs-on: s2-runner
env:
GAME: ${{ github.event.inputs.game }}
STEAM_APPS: /home/cs2/.steam/SteamApps
STEAM_USER: source2rosetta
STEAM_HOME_ANON: /home/cs2
STEAM_HOME_AUTH: /home/cs2/steam-auth
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
OVERRIDE_DIR: /home/cs2/rosetta-override
steps:
@ -30,12 +31,36 @@ jobs:
- name: Update the install to the current build
run: |
case "$GAME" in
cs2) APPID=730 ;;
dota2) APPID=570 ;;
cs2) APPID=730; LOGIN=anonymous; STEAM_HOME="$STEAM_HOME_ANON" ;;
dota2) APPID=570; LOGIN="$STEAM_USER"; STEAM_HOME="$STEAM_HOME_AUTH" ;;
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
esac
echo "APPID=$APPID" >> "$GITHUB_ENV"
steamcmd +login "$STEAM_USER" +app_update "$APPID" +quit
env HOME="$STEAM_HOME" steamcmd +login "$LOGIN" +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
run: |

2
Cargo.lock generated
View file

@ -248,7 +248,7 @@ dependencies = [
[[package]]
name = "source2rosetta-core"
version = "2.1.0"
version = "3.0.2"
dependencies = [
"anyhow",
"clap",

View file

@ -28,18 +28,18 @@ The output is framework-neutral; `source2rosetta-gen` renders it into whatever y
## 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 |
|---|---|---|---|---|---|---|
| **CS2** | ~1,125 `core` + ~2,865 `high_confidence`, plus ~4,375 `experimental` name guesses | **300 VScript bindings (246 located)**, 580 Pulse bindings (127 host-callable), 784 commands, 1,551 ConVars, 715 entity inputs / 226 outputs, 474 classnames | ~2,055 `verified` + ~80 `lower-bound`, 55 `mismatch`, 261 `return-only` | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
| **Dota 2** | ~1,100 `core` + ~4,045 `high_confidence`, plus ~5,740 `experimental` | **1,841 VScript bindings (1,599 located)**, 500 Pulse bindings (99 host-callable), 855 commands, 1,171 ConVars, 624 entity inputs / 187 outputs, 3,528 classnames | ~2,800 `verified` + ~99 `lower-bound`, 44 `mismatch`, 1,594 `return-only` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
| **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,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.
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:
**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.
@ -98,7 +98,7 @@ What you do not get: `TryPlayerMove`, `WalkMove`, `Accelerate` and `TracePlayerB
#### Combat, damage and tracing
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two ABI notes. `CBaseEntity::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.
@ -170,7 +170,7 @@ Because command flags are decoded, the client-reachable attack surface is exactl
### 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.
@ -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.
**`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 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.
@ -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
`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.
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.
@ -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.
- **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.
- **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 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.
@ -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.
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.
@ -546,7 +546,7 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
- `--target <dir>` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.)
- `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.**
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.** The two forms are mutually exclusive and the CLI says so: `--seed` supplies all six, so passing one alongside it is a usage error rather than an input silently dropped.
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint raw build binaries on the fly).
Model-based derives are **forward-only**: the model describes history up to its newest build, so pointing one at an *older* target is not supported.
@ -669,16 +669,19 @@ configuration rather than code.
A **`FunctionRecord`** opens with its `tier` (`core` / `high_confidence` / `experimental`) and the locator
flattened to the top level. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a
non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with
`?` wildcards. By deriver convention an entry carries one or the other.
`?` wildcards. Usually one or the other — but **a record may carry both**, and a handful of `core` CS2 records
do, so a consumer (and the live oracle) has to judge every locator a record holds rather than the first it
finds.
Two further locator keys appear where they were established:
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone
locates nothing, since it only means anything relative to a particular vtable. Taken from the class the
derivation actually chained the offset through, never parsed out of the entry name: a base-declared method
routinely sits in a derived class's vtable, so those are different facts and only the measured one locates.
locates nothing, since it only means anything relative to a particular vtable. It is the record's own name
class, stated explicitly: the deriver keys its slot timelines and alignment hops by that class and chains
through it, so "the class chained through" and "the class in the name" are one fact. Directly folded
offsets (the multilib ground-truth path) omit it and leave the consumer to split the name.
- **`anchors`** — distinctive string literals the function references, each unique to it within its library.
Not a third locator competing with the sig-XOR-offset pair, but a supplement with a *different failure
Not a locator competing with the signature and the offset, but a supplement with a *different failure
mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that
moves instructions. Emitted alongside the signature, never instead of it.
@ -703,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
merge.
Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,987 is
exactly right about NAMES and describes **3,895 distinct functions**. And read it before hooking: two names on
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,888 distinct functions**. And read it before hooking: two names on
one address detoured independently is one trampoline chain claimed twice.
Grouping is by locator identity across `core` + `high_confidence` — a shipped pattern is generated at the

View file

@ -1,6 +1,6 @@
[package]
name = "source2rosetta-core"
version = "2.1.0"
version = "3.0.2"
edition = "2024"
description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)"
license = "AGPL-3.0-only"

View file

@ -23,15 +23,18 @@ pub struct Entry {
/// For a vtable-OFFSET locator: the class whose vtable the slot was measured on.
///
/// Part of the locator, not decoration — a slot index alone locates nothing, since it is only meaningful
/// relative to a particular class's vtable. Taken from the class whose vtable the derivation actually
/// chained the offset through, never parsed out of the entry name: a method declared on a base class
/// routinely sits in a derived class's vtable, so the name's class and the measured class are different
/// facts and only the second one locates anything.
/// relative to a particular class's vtable.
///
/// It is the entry name's own class, stated explicitly: the deriver keys its slot timelines and
/// alignment hops by the name's class and chains through that one, so "the class the offset was
/// chained through" and "the class in the name" are one fact rather than two. Directly folded offsets
/// (the multilib ground-truth path) carry no class at all and leave the consumer to split the name.
/// So a reader may treat this as a convenience copy — never as a second, independent attribution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// Distinctive string literals this function references, each unique to it within its library.
///
/// NOT a third locator competing with the sig-XOR-offset pair — a supplement with a DIFFERENT failure
/// NOT a locator competing with the signature and the offset — a supplement with a DIFFERENT failure
/// mode. A byte signature is a snapshot of one build's codegen; a string survives a recompile that
/// moves instructions. So a consumer that can resolve anchors (ModSharp's `refs.strings`) has a
/// locator that keeps working across the window between Valve shipping a build and us republishing,
@ -44,7 +47,9 @@ pub struct Entry {
}
impl Entry {
/// A signature-only locator (the deriver's sig-XOR-offset invariant as a constructor).
/// A signature-only locator. A convenience for the common shape, NOT an invariant — an entry may carry
/// a signature and an offset at once (the struct doc says so, and real `core` entries do), so anything
/// judging an entry must check every locator it holds rather than the first one it finds.
pub fn signature(library: impl Into<String>, linux: impl Into<String>) -> Entry {
Entry {
signature: Some(Sig {

View file

@ -11,8 +11,8 @@ fuzz_target!(|data: &[u8]| {
return;
};
let xr = xref::XrefIndex::build(&img);
// Exercise the lookups over a bounded set of the discovered call targets — none may panic.
for &t in xr.call_targets().iter().take(64) {
// Exercise the lookups over a bounded set of the discovered function entries — none may panic.
for &t in xr.entries().iter().take(64) {
let _ = xr.referrers(t);
let _ = xr.refs_to(t);
let _ = xr.containing_func(t);

View file

@ -609,9 +609,12 @@ pub(crate) fn gp_slot(r: Register) -> Option<usize> {
/// Registers a `call` destroys — every caller-saved GPR. A pointer that SURVIVES a call is in a
/// callee-saved register, which is exactly how a real `this` is kept across one.
///
/// One list, three shapes: this array, [`caller_saved_mask`]'s bitmask, and the slot indices `concmd`
/// clears after a call. They must agree — a register missing from one and present in another is a
/// tracker that forgets a value the machine kept, or keeps one the machine destroyed.
/// ONE list, and every shape of it is derived from this array: [`caller_saved_mask`]'s bitmask, the slot
/// indices [`caller_saved_slots`] hands the `concmd` and `vscript` value trackers, and `pulse`'s two
/// invalidation loops, which read it directly. Nothing transcribes it, because a register present in one
/// copy and missing from another is a tracker that forgets a value the machine kept, or keeps one the
/// machine destroyed — and a fork retargeting this (Windows/MSVC makes RSI and RDI callee-saved) has to
/// change exactly one place.
pub(crate) const CALLER_SAVED: [Register; 9] = [
Register::RAX,
Register::RCX,
@ -631,6 +634,16 @@ fn caller_saved_mask() -> u32 {
.fold(0u32, |m, s| m | (1 << s))
}
/// [`CALLER_SAVED`] as the `[_; 16]` slot indices the instruction readers clear after a call — the shape
/// `concmd` and `vscript` need, derived once here instead of transcribed into each.
pub(crate) fn caller_saved_slots() -> [usize; 9] {
let mut out = [0usize; 9];
for (i, &r) in CALLER_SAVED.iter().enumerate() {
out[i] = gp_slot(r).expect("every caller-saved register is a GPR");
}
out
}
/// The largest displacement the function reaches through the pointer it was handed in RDI — for a
/// member function, how far into `this` it touches.
///
@ -766,6 +779,22 @@ pub fn this_reach(img: &CodeImage, entry: u64) -> Option<u64> {
mod tests {
use super::*;
#[test]
fn every_shape_of_the_caller_saved_list_agrees_with_the_array() {
// The invariant `CALLER_SAVED` documents, checked rather than asserted. Both derived shapes are
// computed from the array here, so this can only fail if someone reintroduces a hand-written
// copy — which is exactly the drift that put a raw index list in `vscript` and a second register
// array in `pulse`.
let mask = caller_saved_mask();
let slots = caller_saved_slots();
assert_eq!(mask.count_ones() as usize, CALLER_SAVED.len());
assert_eq!(slots.len(), CALLER_SAVED.len());
for (&r, &s) in CALLER_SAVED.iter().zip(slots.iter()) {
assert_eq!(gp_slot(r), Some(s), "{r:?} lost its slot index");
assert_ne!(mask & (1 << s), 0, "{r:?} is missing from the bitmask");
}
}
// Decode a tiny hand-assembled straight-line function and recover its shape through the REAL
// per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while
// the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.)

View file

@ -71,21 +71,6 @@ const RSI: usize = 6;
const RDI: usize = 7;
const R8: usize = 8;
const R9: usize = 9;
/// Caller-saved under SysV: a call destroys any constant we were tracking in these. The `this` a
/// constructor threads through its registrations is callee-saved (rbx, r12-r15), so it survives — which
/// is what makes the member-callback form readable at all.
///
/// DERIVED from `abi::CALLER_SAVED` rather than re-listed. It is a fixed SysV fact and was spelled out
/// three times across two readers and the ABI measurer; a register present in one list and missing from
/// another is a tracker that either forgets a value the machine kept or keeps one it destroyed.
fn clobbered() -> [usize; 9] {
let mut out = [0usize; 9];
for (i, &r) in crate::abi::CALLER_SAVED.iter().enumerate() {
out[i] = crate::abi::gp_slot(r).expect("every caller-saved register is a GPR");
}
out
}
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
/// cap doubles as a validity gate.
const MAX_NAME: usize = 64;
@ -386,7 +371,11 @@ fn collect_sites(
// symbolic base for any unknown-valued register, and the store arm keys that base as
// `(reg, epoch, disp)`: without the bump, `rax` after two successive calls is ONE key
// space shared by two objects, where same-displacement stores overwrite each other.
for c in clobbered() {
//
// Only the caller-saved nine. The `this` a constructor threads through its registrations
// is callee-saved (rbx, r12-r15) and SURVIVES, which is what makes the member-callback
// form readable at all — so the list comes from `abi`, never from a local transcription.
for c in crate::abi::caller_saved_slots() {
end_life(&mut epoch, c as u8);
val[c] = V::Unknown;
}
@ -883,9 +872,9 @@ mod tests {
// made through the FIRST could be read back as a slot of the SECOND — and the member-callback
// recovery ships whatever executable pointer that merged window holds.
let mut epoch = [0u32; 16];
let clobber = clobbered();
let clobber = crate::abi::caller_saved_slots();
let before: Vec<u32> = clobber.iter().map(|&c| epoch[c]).collect();
for c in clobbered() {
for c in clobber {
end_life(&mut epoch, c as u8);
}
for (i, &c) in clobber.iter().enumerate() {

View file

@ -11,9 +11,10 @@
//! compares two builds of one named library and reads nothing game-specific.)
//! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server):
//! `corpus_model_cmd` (distill the corpus model, taking a [`pipeline::ClassScope`]), `fold_model_cmd`
//! (roll model N → N+1), `backfill_cmd` (cross-build name/offset timelines). The derive that consumes
//! a corpus source is reached through `produce::produce_cmd`, which builds one internally from its
//! `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private.
//! (roll model N → N+1, over a [`pipeline::CorpusModel`] that [`pipeline::load_model`] reads off disk —
//! the only way to build its first argument), `backfill_cmd` (cross-build name/offset timelines). The
//! derive that consumes a corpus source is reached through `produce::produce_cmd`, which builds one
//! internally from its `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private.
//! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd`
//! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is
//! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd`
@ -24,8 +25,10 @@
//!
//! # Low-level engine (implementation detail)
//! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint
//! metric, the sig/abi machinery, the data-parallel primitive, the name taxonomy). They stay `pub` for the fuzz
//! harness and advanced embedders, but carry NO stability promise — treat them as internal.
//! metric, the sig/abi machinery, the data-parallel primitive). They stay `pub` for the fuzz harness and
//! advanced embedders, but carry NO stability promise — treat them as internal. The name taxonomy is NOT
//! among them: it is crate-private, because the knob a fork retunes is the `GameProfile` vocabulary block
//! those predicates read, not the predicates.
// ---- supported API ----
pub mod pipeline;
@ -46,11 +49,15 @@ pub mod pulse;
pub mod rtti;
pub mod schema;
pub mod sig;
pub mod taxonomy;
pub mod valvetab;
pub mod vscript;
pub mod xref;
// ---- crate-private ----
// The name taxonomy: every item is `pub(crate)`, so publishing the module published an empty page. The
// per-game vocabulary it reads is the fork-retunable part, and that is already `pub` on `GameProfile`.
mod taxonomy;
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so
// existing `source2rosetta::{model, render}` paths keep resolving.
pub use source2rosetta_core::{model, render};

View file

@ -84,9 +84,11 @@ enum Cmd {
},
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
/// validate-live + typed netvars → merge → fold model, writing the release set
/// (`rosetta-<game>.json` + `model-<game>.json` + `manifest.json`) into --out-dir. No per-stage
/// intermediate files. **Pass `--game-dir` for a full, live-validated build; omit it for a fast
/// OFFLINE build (no server, so no live validation and a `null` schema).**
/// (`rosetta-<game>.json` + `manifest.json`, plus `model-<game>.json` when `--corpus-model` was the
/// source — the sidecar is that model rolled N → N+1, so a `--corpus` genesis run writes two files,
/// not three) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full,
/// live-validated build; omit it for a fast OFFLINE build (no server, so no live validation and a
/// `null` schema).**
Produce {
/// A launchable game install → the FULL build (boots a server for validate-live + typed netvars).
/// OMIT for an offline build. The offline/full switch — no separate flag.
@ -98,12 +100,13 @@ enum Cmd {
/// Server library to derive from; defaults to the active game's server lib.
#[arg(long)]
lib: Option<String>,
/// One bundled seed (catalogue + naming sections) — the release form. Replaces the loose
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags.
/// One bundled seed (catalogue + naming sections) — the release form. Carries everything the loose
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags carry, and
/// CONFLICTS with each of them: pass one form or the other, never a mix.
#[arg(long)]
seed: Option<PathBuf>,
/// Function catalogue (loose form; omit when using --seed).
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
catalogue: Option<PathBuf>,
/// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of
/// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path).
@ -119,22 +122,22 @@ enum Cmd {
target: PathBuf,
/// Optional: names eligible for promotion into high_confidence (from the naming producer flow).
/// Omit to promote nothing — the catalogue still derives in full.
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
promotable: Option<PathBuf>,
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
candidates: Option<PathBuf>,
/// Optional: the full-slice name universe. When set, the monolith also carries an `experimental`
/// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable
/// locator but an UNVERIFIED name).
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
full_names: Option<PathBuf>,
/// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}`
/// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate.
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
extra_offsets: Option<PathBuf>,
/// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib.
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
extra_sigs: Option<PathBuf>,
/// Declared C++ prototypes (`mappings/prototypes.json`) to judge against this build's measured
/// register footprints. Static repo input — omit and no function carries a declared prototype.
@ -171,12 +174,12 @@ enum Cmd {
/// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints
/// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus.
CorpusModel {
/// One bundled seed — the release form; its catalogue section is what gets distilled. Replaces the
/// loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
/// One bundled seed — the release form; its catalogue section is what gets distilled. CONFLICTS with
/// the loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
#[arg(long)]
seed: Option<PathBuf>,
/// Function catalogue (loose form; omit when using --seed).
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
catalogue: Option<PathBuf>,
#[arg(long)]
corpus: PathBuf,
@ -195,11 +198,12 @@ enum Cmd {
/// The existing model N (carries the `abi_obs` window the fold re-windows).
#[arg(long)]
model: PathBuf,
/// One bundled seed — the release form; its catalogue section is folded. Replaces the loose --catalogue.
/// One bundled seed — the release form; its catalogue section is folded. CONFLICTS with the loose
/// --catalogue.
#[arg(long)]
seed: Option<PathBuf>,
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
catalogue: Option<PathBuf>,
/// The one new build dir to fold in (holds the just-updated libserver.so etc.).
#[arg(long)]
@ -309,9 +313,11 @@ fn lib_or_default(prof: &profile::GameProfile, lib: Option<String>) -> String {
}
/// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle
/// (release form) or a loose `--catalogue` file. The seed's catalogue section parses to the same functions as
/// the loose `needed-functions.json`, so the distilled/folded model is identical either way. When a seed is
/// given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside its out-dir).
/// (release form) or a loose `--catalogue` file — never both; `catalogue` declares the conflict, so the
/// `None` arm here means the flag was genuinely absent. The seed's catalogue section parses to the same
/// functions as the loose `needed-functions.json`, so the distilled/folded model is identical either way.
/// When a seed is given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside
/// its out-dir).
fn model_catalogue(
prof: &profile::GameProfile,
seed: Option<PathBuf>,
@ -393,6 +399,9 @@ fn main() -> Result<()> {
bots,
} => {
// derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify).
// The bundle arm reads NONE of the loose bindings, which is only honest because each of them
// declares `conflicts_with = "seed"` — clap rejects the mix before dispatch rather than letting
// this arm drop an explicitly passed input on the floor.
let inputs = match seed {
Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?,
None => SeedInputs {
@ -511,3 +520,50 @@ fn main() -> Result<()> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// A dropped input is a silent skip, and this is the one place the CLI could produce one: the `--seed`
/// arms unpack every loose input themselves and never read the loose bindings, so an undeclared
/// conflict means `produce --seed s.json --full-names f.json` runs to exit 0 with `--full-names`
/// ignored — and a monolith with no experimental tier is exactly what a game with no naming harvest
/// legitimately ships, so no collapse floor downstream can tell the two apart.
#[test]
fn a_seed_bundle_refuses_the_loose_inputs_rather_than_ignoring_them() {
Cli::command().debug_assert();
let parse = |argv: &[&str]| {
let full: Vec<&str> = std::iter::once("source2rosetta")
.chain(argv.iter().copied())
.collect();
Cli::try_parse_from(&full)
};
let refused = |argv: &[&str]| {
assert!(
parse(argv).is_err(),
"accepted, so one of these inputs is silently dropped: {argv:?}"
);
};
let produce = ["produce", "--target", "t", "--out-dir", "o", "--seed", "s"];
for flag in [
"--catalogue",
"--promotable",
"--candidates",
"--full-names",
"--extra-offsets",
"--extra-sigs",
] {
refused(&[&produce[..], &[flag, "x"]].concat());
}
let model = ["--out", "o", "--seed", "s", "--catalogue", "c"];
refused(&[&["corpus-model", "--corpus", "c"][..], &model].concat());
refused(&[&["fold-model", "--model", "m", "--build", "b"][..], &model].concat());
// Either form ALONE still parses — the conflict must not have made the loose form unusable.
assert!(parse(&produce).is_ok());
let loose = ["--catalogue", "c", "--full-names", "f"];
let bare = &produce[..produce.len() - 2]; // the same command minus `--seed s`
assert!(parse(&[bare, &loose[..]].concat()).is_ok());
}
}

View file

@ -4850,9 +4850,11 @@ fn derive_offsets(
match chain_and_vote(&anchors, hv, target_idx) {
Some((pred, conf)) if conf >= 80 => {
gd.set_offset(f.name.clone(), pred as i64);
// The class the slot was chained THROUGH — the only one that makes the index meaningful.
// Recorded here rather than reconstructed later from the name, which would be a different
// (and sometimes wrong) fact: a base-declared method sits in a derived class's vtable.
// The class whose vtable this slot indexes — half the locator, since an index alone locates
// nothing. It is `class_of(f.name)`: `vtable_offset_timelines` builds `VtFunc::class` that
// way and both `hops` and `bv.fps` are keyed by it, so the class the chain walked and the
// class in the name are one fact, not two. Emitting it saves the consumer a name split; it
// does not add information the name lacks.
gd.set_class(f.name.clone(), f.class.clone());
off_ok += 1;
}

View file

@ -2124,17 +2124,30 @@ fn validate_live_cmd(
fn verify_live_cmd(prof: &GameProfile, pid: u32, dir: &Path, lib: &str) -> Result<OracleCounts> {
let img = load_lib(dir, lib)?;
let classes = schema::enumerate_schema(&img);
// The SAME floor `produce` applies, not merely non-empty: this half compares an offline read against a
// live one through the same `CI_*` constants on the same bytes, so a reshape's survivors agree with
// themselves at ~1.0 and a handful of classes looks like a clean run. See
// `GameProfile::min_schema_classes`.
ensure!(
classes.len() >= prof.min_schema_classes,
"offline schema derivation found {} classes in {lib} (floor {}) — refusing to verify a schema \
whose class table collapsed",
classes.len(),
prof.min_schema_classes
);
// A floor, not merely non-empty: this half compares an offline read against a live one through the same
// `CI_*` constants on the same bytes, so a reshape's survivors agree with themselves at ~1.0 and a
// handful of classes looks like a clean run.
//
// 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!(
classes.len() >= prof.min_schema_classes_lib,
"offline schema derivation found {} classes in {lib} (floor {}) — refusing to verify a schema \
whose class table collapsed",
classes.len(),
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 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
/// survives a reshape agrees with itself.
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`.
///
/// 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
min_vscript_classed: 150,
min_schema_enums: 250,
// CS2 recovers 1,899. A floor at 1,200 is well clear of build-to-build drift and nowhere near
// the range a `SchemaClassInfoData_t` reshape would leave.
// CS2 recovers 1,899 across every mapped library. A floor at 1,200 is well clear of build-to-build
// drift and nowhere near the range a `SchemaClassInfoData_t` reshape would leave.
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.
min_core_functions: 2_500,
game_key: "csgo",
@ -365,8 +377,10 @@ pub const DOTA: GameProfile = GameProfile {
// observed live: 1,638 of 1,841 bindings attributed across 63 classes
min_vscript_classed: 900,
min_schema_enums: 350,
// Dota recovers 2,962.
// Dota recovers 2,962 across every mapped library.
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.
min_core_functions: 3_000,
game_key: "dota",
@ -450,6 +464,23 @@ pub const DOTA: GameProfile = GameProfile {
mod tests {
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]
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

View file

@ -24,6 +24,9 @@
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
//! "fewer" into a failed release.
// Registers whose value a call destroys. The ONE list in `abi`, not a second copy of it — both loops
// below that invalidate across a call read it directly.
use crate::abi::CALLER_SAVED;
use crate::elf::CodeImage;
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
use std::collections::{BTreeMap, HashMap, HashSet};
@ -83,20 +86,6 @@ struct Trace {
ret: Option<(u64, u64)>,
}
/// Registers whose value a call destroys. Anything else the pass cannot evaluate is invalidated as the
/// instruction that writes it is seen, so the default is always "unknown" rather than "stale".
const CALLER_SAVED: [Register; 9] = [
Register::RAX,
Register::RCX,
Register::RDX,
Register::RSI,
Register::RDI,
Register::R8,
Register::R9,
Register::R10,
Register::R11,
];
fn full(r: Register) -> Register {
if r.is_gpr() { r.full_register() } else { r }
}
@ -362,26 +351,6 @@ fn record(img: &CodeImage, accessor: u64) -> Option<Record> {
})
}
/// Every CODE pointer an accessor's initializer stores into its record region, with the region base:
/// `(base, [(address written, code address written)])`.
///
/// A DIAGNOSTIC, and deliberately not part of any shipped artifact. The parameter records carry a
/// function pointer whose ROLE is not established — the record reader already has to look at these in
/// order to reject them as parameter names, so exposing them costs nothing and lets that question be
/// settled against evidence collected elsewhere (a runtime call-edge trace) rather than guessed. Nothing
/// here interprets them; they are raw measurements.
pub fn code_stores(img: &CodeImage, accessor: u64) -> Option<(u64, Vec<(u64, u64)>)> {
let r = record(img, accessor)?;
let stores =
r.t.writes
.iter()
.filter(|&(a, _)| *a >= r.base)
.filter(|&(_, p)| img.is_code(*p))
.map(|(&a, &p)| (a, p))
.collect();
Some((r.base, stores))
}
/// The spacings at which this record's `count` names could sit, given that element 0's name is at
/// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
@ -582,10 +551,10 @@ const SHIM_SLOTS: [Register; 6] = [
/// What an invocation shim was measured to read, and therefore what a caller has to supply.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ShimReads {
/// The argument slots actually read, named — `rcx`, `r8`, `stack0`.
/// The argument slots actually read, named — `rcx`, `r8`, `stack0`. The argument array (`r8`) is
/// stated here and nowhere else: reading it is the ordinary case and constrains a caller in no way,
/// so it needs no flag of its own beside the three that do.
pub reads: Vec<&'static str>,
/// Does it read the argument array (`r8`)?
pub args: bool,
/// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a
/// return, measured across both games with no exceptions.
pub sink: bool,
@ -634,6 +603,24 @@ pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> {
(r.base != 0).then_some((r.base, r.count))
}
/// What a read of one argument slot demands of a HOST caller.
///
/// The argument array (`r8`) demands nothing — the caller builds it, so reading it is the ordinary case
/// and `reads` already states it. The Pulse context (`rcx`) is VM-owned and cannot be supplied at all.
/// Everything else is a slot the caller would otherwise pass null.
///
/// A named arm rather than a fall-through for `r8` specifically: dropping it into the `_` catch-all would
/// mark every ordinary binding as needing a slot no host can fill, retiring the entire `args-only`
/// callable tier — a collapse that reads as "this build has no callable bindings", which is a legitimate
/// answer for a game and therefore invisible.
fn slot_need(r: Register, out: &mut ShimReads) {
match r {
Register::RCX => out.context = true,
Register::R8 => {}
_ => out.other = true,
}
}
/// Measure which of a shim's seven arguments it reads.
///
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
@ -725,11 +712,7 @@ pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
{
if live.contains_key(r) {
out.reads.push(name);
match *r {
Register::RCX => out.context = true,
Register::R8 => out.args = true,
_ => out.other = true,
}
slot_need(*r, &mut out);
}
}
if sink {
@ -802,26 +785,26 @@ mod tests {
let ctx = ShimReads {
context: true,
sink: true,
args: true,
reads: vec!["rcx", "r8", "stack0"],
..Default::default()
};
assert_eq!(ctx.needs(), "pulse-context");
let other = ShimReads {
other: true,
sink: true,
args: true,
reads: vec!["rdi", "r8", "stack0"],
..Default::default()
};
assert_eq!(other.needs(), "other-slots");
let sink = ShimReads {
sink: true,
args: true,
reads: vec!["r8", "stack0"],
..Default::default()
};
assert_eq!(sink.needs(), "output-sink");
// The callable tier: the argument array and nothing else.
let only = ShimReads {
args: true,
reads: vec!["r8"],
..Default::default()
};
assert_eq!(only.needs(), "args-only");
@ -829,6 +812,24 @@ mod tests {
assert_eq!(ShimReads::default().needs(), "args-only");
}
#[test]
fn reading_the_argument_array_leaves_a_shim_host_callable() {
// Asserted against the shipped rule rather than a copy of it. `r8` is the argument array the
// CALLER builds, so a read of it must impose nothing; the arm exists only to keep it out of the
// catch-all, where it would mark every ordinary binding uncallable at once.
let mut r8 = ShimReads::default();
slot_need(Register::R8, &mut r8);
assert_eq!(r8.needs(), "args-only");
let mut rcx = ShimReads::default();
slot_need(Register::RCX, &mut rcx);
assert_eq!(rcx.needs(), "pulse-context");
for r in [Register::RDI, Register::RSI, Register::RDX, Register::R9] {
let mut o = ShimReads::default();
slot_need(r, &mut o);
assert_eq!(o.needs(), "other-slots", "{r:?} is a slot a host must fill");
}
}
#[test]
fn pval_void_is_negative_one_and_still_a_type() {
assert!(valid_pval(0)); // PVAL_BOOL

View file

@ -188,9 +188,6 @@ fn xmm(r: Register) -> Option<u8> {
.filter(|i| *i < 16)
}
/// Registers a call clobbers, so a value cannot survive across one and be attributed to the wrong record.
const CLOBBER: [usize; 9] = [0, 1, 2, 6, 7, 8, 9, 10, 11];
/// A field of a particular record: which record, and the displacement within it.
type Slot = (u32, i64);
@ -278,7 +275,10 @@ pub fn vscript_functions(img: &CodeImage) -> Vec<VScriptFunc> {
dec.decode_out(&mut insn);
if insn.flow_control() == FlowControl::Call {
for c in CLOBBER {
// Registers a call clobbers, so a value cannot survive one and be attributed to the wrong
// record. Taken from `abi`, not transcribed as raw GPR indices — a second copy of a fixed
// SysV fact is a copy that can drift.
for c in crate::abi::caller_saved_slots() {
val[c] = V::Unknown;
scaled[c] = false;
recid[c] = None;

View file

@ -13,13 +13,12 @@
//! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding.
use crate::elf::CodeImage;
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, OpKind};
use iced_x86::{Decoder, DecoderOptions, Instruction, OpKind};
use std::collections::HashMap;
pub struct XrefIndex {
entries: Vec<u64>, // sorted, de-duped function entry addresses
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
call_targets: Vec<u64>, // sorted, de-duped near-call targets
}
impl XrefIndex {
@ -30,36 +29,29 @@ impl XrefIndex {
// Disassemble each function's [start, next) range independently across threads — this is the
// single biggest decode in the tool and the ranges vary wildly in size, so the atomic work
// scheduler load-balances them. Each task returns its (ref-pair, call-target) deltas; merging
// them in entry order (parallel_map preserves input order) reproduces the serial build
// byte-for-byte: refs[t] receives its srcs in the same (ascending entry, then instruction)
// order and call_targets is sorted afterwards.
type EntryData = (Vec<(u64, u64)>, Vec<u64>);
// scheduler load-balances them. Each task returns its ref-pair deltas; merging them in entry
// order (parallel_map preserves input order) reproduces the serial build byte-for-byte, because
// refs[t] receives its srcs in the same (ascending entry, then instruction) order.
let idxs: Vec<usize> = (0..entries.len()).collect();
let per_entry: Vec<EntryData> =
let per_entry: Vec<Vec<(u64, u64)>> =
crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| {
let start = entries[i];
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
let Some(code) = img.code_range(start, end) else {
return (Vec::new(), Vec::new());
return Vec::new();
};
let mut ref_pairs: Vec<(u64, u64)> = Vec::new();
let mut call_targets: Vec<u64> = Vec::new();
let mut insn = Instruction::default();
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
while dec.can_decode() {
dec.decode_out(&mut insn);
let src = insn.ip();
// Near call/jmp: the target is code; call targets double as function entries.
// Near call/jmp: the target is code.
if matches!(
insn.op0_kind(),
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
) {
let t = insn.near_branch_target();
ref_pairs.push((t, src));
if insn.flow_control() == FlowControl::Call {
call_targets.push(t);
}
ref_pairs.push((insn.near_branch_target(), src));
}
// RIP-relative memory operand: a reference to a string / global / code pointer.
if insn.is_ip_rel_memory_operand() {
@ -67,24 +59,16 @@ impl XrefIndex {
ref_pairs.push((t, src));
}
}
(ref_pairs, call_targets)
ref_pairs
});
let mut refs: HashMap<u64, Vec<u64>> = HashMap::new();
let mut call_targets = Vec::new();
for (ref_pairs, cts) in per_entry {
for ref_pairs in per_entry {
for (t, src) in ref_pairs {
refs.entry(t).or_default().push(src);
}
call_targets.extend(cts);
}
call_targets.sort_unstable();
call_targets.dedup();
Self {
entries,
refs,
call_targets,
}
Self { entries, refs }
}
/// The entry (function start) that contains `va`: the nearest entry at or below `va`.
@ -111,8 +95,11 @@ impl XrefIndex {
fs
}
pub fn call_targets(&self) -> &[u64] {
&self.call_targets
/// The function entries this index was built over, ascending — the union `locate::function_entries`
/// computes. Exposed because it is the domain of `containing_func`: a caller enumerating functions
/// should read it here rather than recompute the union and risk a different one.
pub fn entries(&self) -> &[u64] {
&self.entries
}
}